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.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<HexKey> = 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)
@@ -143,6 +143,7 @@ fun main(args: Array<String>) {
policyBuilder,
parallelVerify = parallelVerify,
negentropySettings = negentropySettings,
adminPubkeys = config.admin.pubkeys.toSet(),
)
val server =
KtorRelay(
@@ -150,7 +151,6 @@ fun main(args: Array<String>) {
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,
@@ -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<HexKey> = 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]).
@@ -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 }