refactor(geode): inject RuntimeConfig into RelayEngine, mirroring EventStore

RelayEngine used to take three params that were really RuntimeConfig
construction details — info: RelayInfo, stateFile: File?,
seedAuthorization: AuthorizationSeed — and assembled the RuntimeConfig
internally. Replace that with a single runtimeConfig: RuntimeConfig
param (with an in-memory default), matching how store: IEventStore is
already passed in. Main.kt builds the RuntimeConfig from StaticConfig
at the assembly layer where the TOML→runtime mapping is most explicit.

AuthorizationSeed is dropped — it only existed to shuttle data into
the now-removed seedAuthorization param. Callers build RuntimeConfigData
directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-12 16:40:14 -04:00
parent f4bcf38caf
commit a010b37d6c
5 changed files with 82 additions and 117 deletions
@@ -20,7 +20,9 @@
*/ */
package com.vitorpamplona.geode package com.vitorpamplona.geode
import com.vitorpamplona.geode.config.AuthorizationSeed import com.vitorpamplona.geode.config.BannedEntry
import com.vitorpamplona.geode.config.RuntimeConfig
import com.vitorpamplona.geode.config.RuntimeConfigData
import com.vitorpamplona.geode.config.StaticConfig import com.vitorpamplona.geode.config.StaticConfig
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
@@ -110,18 +112,22 @@ fun main(args: Array<String>) {
composePolicy(config, advertisedUrl, requireAuth, verifySigs, parallelVerify) composePolicy(config, advertisedUrl, requireAuth, verifySigs, parallelVerify)
} }
val stateFile = config.admin.state_file?.let { File(it) }
// Static [authorization] feeds the runtime BanStore only when no // Static [authorization] feeds the runtime BanStore only when no
// state file exists yet. As soon as the operator's first admin RPC // state file exists yet. As soon as the operator's first admin RPC
// writes the file, BanListPolicy consults the persisted lists // writes the file, BanListPolicy consults the persisted lists
// exclusively — later edits to [authorization] in the TOML are // exclusively — later edits to [authorization] in the TOML are
// ignored at boot. // ignored at boot.
val seedAuthorization = val runtimeConfig =
AuthorizationSeed( RuntimeConfig(
allowedPubkeys = config.authorization.pubkey_whitelist, file = config.admin.state_file?.let { File(it) },
bannedPubkeys = config.authorization.pubkey_blacklist, seed =
RuntimeConfigData(
info = info.document,
allowedPubkeys = config.authorization.pubkey_whitelist.map { BannedEntry(it) },
bannedPubkeys = config.authorization.pubkey_blacklist.map { BannedEntry(it) },
allowedKinds = config.authorization.kind_whitelist, allowedKinds = config.authorization.kind_whitelist,
disallowedKinds = config.authorization.kind_blacklist, disallowedKinds = config.authorization.kind_blacklist,
),
) )
val negentropySettings = val negentropySettings =
NegentropySettings( NegentropySettings(
@@ -133,10 +139,8 @@ fun main(args: Array<String>) {
RelayEngine( RelayEngine(
advertisedUrl, advertisedUrl,
store, store,
info, runtimeConfig,
policyBuilder, policyBuilder,
stateFile = stateFile,
seedAuthorization = seedAuthorization,
parallelVerify = parallelVerify, parallelVerify = parallelVerify,
negentropySettings = negentropySettings, negentropySettings = negentropySettings,
) )
@@ -176,13 +180,6 @@ fun main(args: Array<String>) {
* 1. AUTH (drops everything if not authenticated) * 1. AUTH (drops everything if not authenticated)
* 2. Future-timestamp rejection * 2. Future-timestamp rejection
* 3. Signature verification (most expensive — Schnorr verify) * 3. Signature verification (most expensive — Schnorr verify)
*
* Pubkey and kind allow / deny lists are NOT installed here — they
* live in the runtime [com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore],
* which `RelayEngine` always wraps with `BanListPolicy`. The static
* `[authorization]` section seeds the BanStore on first boot via
* [com.vitorpamplona.geode.config.AuthorizationSeed]; afterwards
* NIP-86 admin RPCs own those lists.
*/ */
private fun composePolicy( private fun composePolicy(
config: StaticConfig, config: StaticConfig,
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.geode package com.vitorpamplona.geode
import com.vitorpamplona.geode.config.AuthorizationSeed
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
@@ -35,7 +34,6 @@ 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 kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import java.io.File
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
/** /**
@@ -58,34 +56,26 @@ import kotlin.coroutines.CoroutineContext
class RelayEngine( class RelayEngine(
val url: NormalizedRelayUrl, val url: NormalizedRelayUrl,
val store: IEventStore = EventStore(dbName = null, relay = url), val store: IEventStore = EventStore(dbName = null, relay = url),
info: RelayInfo = RelayInfo.default(url), /**
* Runtime configuration handle — owns the persistence path (when
* any), the NIP-11 doc seed, and the seed for the NIP-86 ban /
* allow / kind lists. Mirrors how [store] is built externally and
* passed in: tests use the in-memory default
* (`RuntimeConfig(file=null, seed=...)`); `Main.kt` builds one
* from `StaticConfig` so first-boot defaults flow from TOML and
* subsequent admin mutations are persisted next to the SQLite
* event store.
*
* The default constructs an in-memory-only handle whose seed
* advertises the supported NIPs via [RelayInfo.default].
*/
private val runtimeConfig: RuntimeConfig =
RuntimeConfig(
file = null,
seed = RuntimeConfigData(info = RelayInfo.default(url).document),
),
policyBuilder: () -> IRelayPolicy = { EmptyPolicy }, policyBuilder: () -> IRelayPolicy = { EmptyPolicy },
parentContext: CoroutineContext = SupervisorJob(), parentContext: CoroutineContext = SupervisorJob(),
/**
* Optional path for the operator-state JSON snapshot. When set,
* the file is loaded at boot to seed [info] and [banStore], and
* rewritten atomically on every NIP-86 mutation and
* [updateInfo] call so admin actions survive restarts.
*
* Convention: place next to the SQLite event-store file
* (e.g. `events.db` → `events.db.admin.json`). `null` keeps
* everything in memory only — fine for tests.
*/
stateFile: File? = null,
/**
* Static-config-derived allow / deny seed for the [BanStore].
* Used only on first boot — i.e. when [stateFile] doesn't yet
* exist. Once an admin RPC writes the file, the persisted snapshot
* is the sole source of truth and this seed is ignored on every
* subsequent boot.
*
* Pair with omitting `KindAllowDenyPolicy` / `PubkeyAllowDenyPolicy`
* from [policyBuilder] — [BanListPolicy] (always installed) reads
* the same lists from the [BanStore], so stacking the static
* policies would double-enforce the same rules at boot and then
* silently diverge after the first admin mutation.
*/
seedAuthorization: AuthorizationSeed = AuthorizationSeed(),
/** /**
* Run Schnorr signature verification in parallel inside the * Run Schnorr signature verification in parallel inside the
* [com.vitorpamplona.quartz.nip01Core.relay.server.IngestQueue] * [com.vitorpamplona.quartz.nip01Core.relay.server.IngestQueue]
@@ -103,25 +93,11 @@ class RelayEngine(
*/ */
negentropySettings: NegentropySettings = NegentropySettings.Default, negentropySettings: NegentropySettings = NegentropySettings.Default,
) : AutoCloseable { ) : AutoCloseable {
private val runtimeConfig: RuntimeConfig =
RuntimeConfig(
file = stateFile,
seed =
RuntimeConfigData(
info = info.document,
allowedPubkeys = seedAuthorization.allowedPubkeys.map { BannedEntry(it) },
bannedPubkeys = seedAuthorization.bannedPubkeys.map { BannedEntry(it) },
allowedKinds = seedAuthorization.allowedKinds,
disallowedKinds = seedAuthorization.disallowedKinds,
),
)
/** /**
* The runtime state to install at boot: the persisted snapshot if * The runtime state to install at boot: the persisted snapshot if
* the file exists, otherwise the seed derived from * the file exists, otherwise the seed baked into [runtimeConfig].
* [com.vitorpamplona.geode.config.StaticConfig]. Read once here * Read once here so the `info` property initializer and the
* so the `info` property initializer and the [banStore] seeding * [banStore] seeding in [init] agree on the same source.
* in [init] agree on the same source.
*/ */
private val effectiveAtBoot: RuntimeConfigData = runtimeConfig.effective() private val effectiveAtBoot: RuntimeConfigData = runtimeConfig.effective()
@@ -132,11 +108,11 @@ class RelayEngine(
* request so changes are visible immediately, no restart needed. * request so changes are visible immediately, no restart needed.
* *
* Initialized from [effectiveAtBoot] — a persisted admin override * Initialized from [effectiveAtBoot] — a persisted admin override
* wins, otherwise we fall back to the static-config seed. The * wins, otherwise we fall back to the seed baked into
* `!!` is load-bearing: the seed is always built with a non-null * [runtimeConfig]. The `!!` is load-bearing: the seed is always
* info above, so the only way we reach a null here is a manually * built with a non-null info, so the only way we reach a null
* corrupted state file, which we'd rather crash on than serve * here is a manually corrupted state file, which we'd rather
* empty NIP-11 from. * crash on than serve empty NIP-11 from.
*/ */
@Volatile @Volatile
var info: RelayInfo = RelayInfo(effectiveAtBoot.info!!) var info: RelayInfo = RelayInfo(effectiveAtBoot.info!!)
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.geode.config package com.vitorpamplona.geode.config
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -140,18 +139,3 @@ data class BannedEntry(
val key: String, val key: String,
val reason: String? = null, val reason: String? = null,
) )
/**
* Static-config-derived seed for the operator allow / deny lists.
* Built from [StaticConfig.AuthorizationSection] in `Main.kt` and
* handed to [com.vitorpamplona.geode.RelayEngine], which composes it
* with the static-derived NIP-11 doc into a [RuntimeConfigData] seed
* for [RuntimeConfig]. Used only when no runtime file exists yet —
* once an admin RPC has written one, the file wins forever.
*/
data class AuthorizationSeed(
val allowedPubkeys: List<HexKey> = emptyList(),
val bannedPubkeys: List<HexKey> = emptyList(),
val allowedKinds: List<Int> = emptyList(),
val disallowedKinds: List<Int> = emptyList(),
)
@@ -20,6 +20,8 @@
*/ */
package com.vitorpamplona.geode package com.vitorpamplona.geode
import com.vitorpamplona.geode.config.RuntimeConfig
import com.vitorpamplona.geode.config.RuntimeConfigData
import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.preload import com.vitorpamplona.geode.testing.preload
import com.vitorpamplona.geode.testing.publish import com.vitorpamplona.geode.testing.publish
@@ -337,7 +339,15 @@ class KtorRelayTest {
supported_nips = listOf("1", "11", "42"), supported_nips = listOf("1", "11", "42"),
), ),
) )
val customRelay = RelayEngine(customUrl, info = customInfo) val customRelay =
RelayEngine(
customUrl,
runtimeConfig =
RuntimeConfig(
file = null,
seed = RuntimeConfigData(info = customInfo.document),
),
)
val customServer = val customServer =
KtorRelay(customRelay, host = "127.0.0.1", port = freePort).start() KtorRelay(customRelay, host = "127.0.0.1", port = freePort).start()
try { try {
@@ -21,6 +21,7 @@
package com.vitorpamplona.geode.config package com.vitorpamplona.geode.config
import com.vitorpamplona.geode.RelayEngine import com.vitorpamplona.geode.RelayEngine
import com.vitorpamplona.geode.RelayInfo
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import java.io.File import java.io.File
@@ -48,9 +49,15 @@ class RuntimeConfigTest {
dir.deleteRecursively() dir.deleteRecursively()
} }
/** Default RuntimeConfigData seed for tests: just an info doc, no authorization seeds. */
private fun defaultSeed(info: Nip11RelayInformation = RelayInfo.default(url).document) = RuntimeConfigData(info = info)
/** RelayEngine bound to [stateFile] with the given (or default) seed. */
private fun relayPersisted(seed: RuntimeConfigData = defaultSeed()) = RelayEngine(url = url, runtimeConfig = RuntimeConfig(file = stateFile, seed = seed))
@Test @Test
fun firstBootWritesNothingUntilFirstMutation() { fun firstBootWritesNothingUntilFirstMutation() {
val relay = RelayEngine(url = url, stateFile = stateFile) val relay = relayPersisted()
try { try {
// No mutation yet → file does not exist. // No mutation yet → file does not exist.
assertTrue(!stateFile.exists(), "fresh relay must not eagerly write a snapshot") assertTrue(!stateFile.exists(), "fresh relay must not eagerly write a snapshot")
@@ -62,7 +69,7 @@ class RuntimeConfigTest {
@Test @Test
fun banPubkeyTriggersSnapshotAndSurvivesRestart() { fun banPubkeyTriggersSnapshotAndSurvivesRestart() {
val pk = "a".repeat(64) val pk = "a".repeat(64)
val r1 = RelayEngine(url = url, stateFile = stateFile) val r1 = relayPersisted()
try { try {
r1.banStore.banPubkey(pk, "spam") r1.banStore.banPubkey(pk, "spam")
} finally { } finally {
@@ -71,7 +78,7 @@ class RuntimeConfigTest {
assertTrue(stateFile.exists(), "snapshot must be written after a mutation") assertTrue(stateFile.exists(), "snapshot must be written after a mutation")
// Fresh relay reads the snapshot and sees the ban. // Fresh relay reads the snapshot and sees the ban.
val r2 = RelayEngine(url = url, stateFile = stateFile) val r2 = relayPersisted()
try { try {
assertTrue(r2.banStore.isBanned(pk)) assertTrue(r2.banStore.isBanned(pk))
assertEquals("spam", r2.banStore.listBannedPubkeys()[0].second) assertEquals("spam", r2.banStore.listBannedPubkeys()[0].second)
@@ -82,14 +89,14 @@ class RuntimeConfigTest {
@Test @Test
fun updateInfoSurvivesRestart() { fun updateInfoSurvivesRestart() {
val r1 = RelayEngine(url = url, stateFile = stateFile) val r1 = relayPersisted()
try { try {
r1.updateInfo { it.copy(name = "renamed") } r1.updateInfo { it.copy(name = "renamed") }
} finally { } finally {
r1.close() r1.close()
} }
val r2 = RelayEngine(url = url, stateFile = stateFile) val r2 = relayPersisted()
try { try {
assertEquals("renamed", r2.info.document.name) assertEquals("renamed", r2.info.document.name)
} finally { } finally {
@@ -99,7 +106,7 @@ class RuntimeConfigTest {
@Test @Test
fun allowKindRoundTripsAcrossRestart() { fun allowKindRoundTripsAcrossRestart() {
val r1 = RelayEngine(url = url, stateFile = stateFile) val r1 = relayPersisted()
try { try {
r1.banStore.allowKind(1) r1.banStore.allowKind(1)
r1.banStore.allowKind(7) r1.banStore.allowKind(7)
@@ -108,7 +115,7 @@ class RuntimeConfigTest {
r1.close() r1.close()
} }
val r2 = RelayEngine(url = url, stateFile = stateFile) val r2 = relayPersisted()
try { try {
assertEquals(listOf(1, 7), r2.banStore.listAllowedKinds()) assertEquals(listOf(1, 7), r2.banStore.listAllowedKinds())
assertEquals(listOf(4), r2.banStore.listDisallowedKinds()) assertEquals(listOf(4), r2.banStore.listDisallowedKinds())
@@ -121,7 +128,7 @@ class RuntimeConfigTest {
fun corruptStateFileIsTolerated() { fun corruptStateFileIsTolerated() {
stateFile.writeText("not valid json {") stateFile.writeText("not valid json {")
// Should not throw — just log and start fresh. // Should not throw — just log and start fresh.
val r = RelayEngine(url = url, stateFile = stateFile) val r = relayPersisted()
try { try {
assertTrue(r.banStore.listBannedPubkeys().isEmpty()) assertTrue(r.banStore.listBannedPubkeys().isEmpty())
assertTrue(r.banStore.listAllowedKinds().isEmpty()) assertTrue(r.banStore.listAllowedKinds().isEmpty())
@@ -133,7 +140,7 @@ class RuntimeConfigTest {
@Test @Test
fun snapshotWriteIsAtomicViaTempFile() { fun snapshotWriteIsAtomicViaTempFile() {
// After a mutation completes, no `.tmp` file should remain. // After a mutation completes, no `.tmp` file should remain.
val r = RelayEngine(url = url, stateFile = stateFile) val r = relayPersisted()
try { try {
r.banStore.banPubkey("b".repeat(64)) r.banStore.banPubkey("b".repeat(64))
val tmp = File(dir, "admin.json.tmp") val tmp = File(dir, "admin.json.tmp")
@@ -145,7 +152,7 @@ class RuntimeConfigTest {
@Test @Test
fun missingStateFileMeansInMemoryOnly() { fun missingStateFileMeansInMemoryOnly() {
val r = RelayEngine(url = url) // no stateFile val r = RelayEngine(url = url) // default runtimeConfig: file=null
try { try {
r.banStore.banPubkey("c".repeat(64)) r.banStore.banPubkey("c".repeat(64))
// No snapshot path → nothing on disk in our temp dir. // No snapshot path → nothing on disk in our temp dir.
@@ -231,12 +238,9 @@ class RuntimeConfigTest {
// First boot, no file → BanStore is seeded from static // First boot, no file → BanStore is seeded from static
// authorization. // authorization.
val r1 = val r1 =
RelayEngine( relayPersisted(
url = url, defaultSeed().copy(
stateFile = stateFile, bannedPubkeys = listOf(BannedEntry(pk)),
seedAuthorization =
AuthorizationSeed(
bannedPubkeys = listOf(pk),
allowedKinds = listOf(1, 7), allowedKinds = listOf(1, 7),
), ),
) )
@@ -251,7 +255,7 @@ class RuntimeConfigTest {
assertTrue(!stateFile.exists(), "no admin mutation yet → no snapshot") assertTrue(!stateFile.exists(), "no admin mutation yet → no snapshot")
// Trigger a mutation so the file gets written. // Trigger a mutation so the file gets written.
val r2 = RelayEngine(url = url, stateFile = stateFile) val r2 = relayPersisted()
try { try {
r2.banStore.banPubkey("b".repeat(64)) r2.banStore.banPubkey("b".repeat(64))
} finally { } finally {
@@ -262,12 +266,9 @@ class RuntimeConfigTest {
// Now restart with a *different* static seed: it must be // Now restart with a *different* static seed: it must be
// ignored — the file is authoritative. // ignored — the file is authoritative.
val r3 = val r3 =
RelayEngine( relayPersisted(
url = url, defaultSeed().copy(
stateFile = stateFile, bannedPubkeys = listOf(BannedEntry("c".repeat(64))),
seedAuthorization =
AuthorizationSeed(
bannedPubkeys = listOf("c".repeat(64)),
allowedKinds = listOf(99), allowedKinds = listOf(99),
), ),
) )
@@ -284,11 +285,8 @@ class RuntimeConfigTest {
fun seedFromStaticConfigFlowsThroughToNip11Reply() { fun seedFromStaticConfigFlowsThroughToNip11Reply() {
// End-to-end via RelayEngine: a static-derived info reaches the // End-to-end via RelayEngine: a static-derived info reaches the
// engine's info property without any persisted runtime override. // engine's info property without any persisted runtime override.
val seeded = val seeded = Nip11RelayInformation(name = "static-name", description = "static-desc")
com.vitorpamplona.geode.RelayInfo( val engine = relayPersisted(defaultSeed(info = seeded))
Nip11RelayInformation(name = "static-name", description = "static-desc"),
)
val engine = RelayEngine(url = url, info = seeded, stateFile = stateFile)
try { try {
assertEquals("static-name", engine.info.document.name) assertEquals("static-name", engine.info.document.name)
assertEquals("static-desc", engine.info.document.description) assertEquals("static-desc", engine.info.document.description)