refactor(geode): move Nip86Server (+ adminPubkeys) into RelayEngine

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<HexKey> = 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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-12 18:44:50 -04:00
parent 445fbf8cda
commit 72e7f37b61
4 changed files with 47 additions and 36 deletions
@@ -23,13 +23,10 @@ package com.vitorpamplona.geode
import com.vitorpamplona.geode.server.Nip11HttpRoute import com.vitorpamplona.geode.server.Nip11HttpRoute
import com.vitorpamplona.geode.server.Nip86HttpRoute import com.vitorpamplona.geode.server.Nip86HttpRoute
import com.vitorpamplona.geode.server.WebSocketSessionPump 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.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession 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.Nip86HttpHandler
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.application.serverConfig import io.ktor.server.application.serverConfig
import io.ktor.server.cio.CIO 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. */ /** Pass 0 to let the OS pick a free port. Read [url] after [start] to learn it. */
val port: Int = 0, val port: Int = 0,
val path: String = "/", 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<HexKey> = emptySet(),
/** /**
* Ktor CIO acceptor-thread count. `null` keeps Ktor's default. * Ktor CIO acceptor-thread count. `null` keeps Ktor's default.
* Lift on machines with many cores when targeting 10k+ * 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. */ /** Ktor CIO call-handling thread count. `null` keeps Ktor's default. */
val callGroupSize: Int? = null, 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 * NIP-86 HTTP adapter. Wraps the engine's [RelayEngine.nip86Server]
* still runs the canonical NIP-86 flow but every request fails * with the NIP-98 HTTP flow ([Nip86HttpHandler]) and binds it to
* the allow-list check ([Nip86HttpHandler.Response.NotAdmin] → * Ktor's request lifecycle. The admin pubkey allow-list lives on
* 403). Uniform code path: the transport doesn't branch on * the engine, not here — turning admin on is a relay-level
* "admin enabled?". * decision, not a transport-level one.
* *
* The NIP-98 binding URL is derived from `relay.url` via [toHttp] * The NIP-98 binding URL is derived from `relay.url` via [toHttp]
* because NIP-86 mandates it: admin requests target the same URI * because NIP-86 mandates it: admin requests target the same URI
@@ -117,10 +91,15 @@ class KtorRelay(
* `ws(s)://`. This makes accidental misconfiguration impossible — * `ws(s)://`. This makes accidental misconfiguration impossible —
* the operator's configured `[info].relay_url` is the single * the operator's configured `[info].relay_url` is the single
* source of truth. * 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 = private val nip86Route =
Nip86HttpRoute( 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 }) 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 // NIP-86: POST application/nostr+json+rpc with a NIP-98
// signed Authorization header → JSON-RPC dispatch. // 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). // every request fails the allow-list check (403).
post(path) { post(path) {
nip86Route.handle(call) nip86Route.handle(call)
@@ -143,6 +143,7 @@ fun main(args: Array<String>) {
policyBuilder, policyBuilder,
parallelVerify = parallelVerify, parallelVerify = parallelVerify,
negentropySettings = negentropySettings, negentropySettings = negentropySettings,
adminPubkeys = config.admin.pubkeys.toSet(),
) )
val server = val server =
KtorRelay( KtorRelay(
@@ -150,7 +151,6 @@ fun main(args: Array<String>) {
host = host, host = host,
port = port, port = port,
path = path, path = path,
adminPubkeys = config.admin.pubkeys.toSet(),
connectionGroupSize = config.network.connection_group_size, connectionGroupSize = config.network.connection_group_size,
workerGroupSize = config.network.worker_group_size, workerGroupSize = config.network.worker_group_size,
callGroupSize = config.network.call_group_size, callGroupSize = config.network.call_group_size,
@@ -23,6 +23,7 @@ package com.vitorpamplona.geode
import com.vitorpamplona.geode.config.BannedEntry import com.vitorpamplona.geode.config.BannedEntry
import com.vitorpamplona.geode.config.RuntimeConfig import com.vitorpamplona.geode.config.RuntimeConfig
import com.vitorpamplona.geode.config.RuntimeConfigData 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.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer 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.nip77Negentropy.NegentropySettings
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanListPolicy import com.vitorpamplona.quartz.nip86RelayManagement.server.BanListPolicy
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@@ -92,6 +94,14 @@ class RelayEngine(
* per-connection session cap). Defaults to strfry-parity values. * per-connection session cap). Defaults to strfry-parity values.
*/ */
negentropySettings: NegentropySettings = NegentropySettings.Default, 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<HexKey> = emptySet(),
) : AutoCloseable { ) : AutoCloseable {
/** /**
* The runtime state to install at boot: the persisted snapshot if * 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. * Writes the current state (NIP-11 doc + ban lists) to disk.
* No-op when no `stateFile` was configured (see [RuntimeConfig.save]). * No-op when no `stateFile` was configured (see [RuntimeConfig.save]).
@@ -79,13 +79,12 @@ class Nip86EndToEndTest {
// server expects to differ from where it actually listens. // server expects to differ from where it actually listens.
val freePort = java.net.ServerSocket(0).use { it.localPort } val freePort = java.net.ServerSocket(0).use { it.localPort }
val url = "ws://127.0.0.1:$freePort/".normalizeRelayUrl() val url = "ws://127.0.0.1:$freePort/".normalizeRelayUrl()
relay = RelayEngine(url = url) relay = RelayEngine(url = url, adminPubkeys = setOf(admin.pubKey))
server = server =
KtorRelay( KtorRelay(
relay = relay, relay = relay,
host = "127.0.0.1", host = "127.0.0.1",
port = freePort, port = freePort,
adminPubkeys = setOf(admin.pubKey),
).start() ).start()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient } val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient }