diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 297bece0a..20eb3efee 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -20,7 +20,9 @@ */ 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.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy @@ -110,18 +112,22 @@ fun main(args: Array) { composePolicy(config, advertisedUrl, requireAuth, verifySigs, parallelVerify) } - val stateFile = config.admin.state_file?.let { File(it) } // Static [authorization] feeds the runtime BanStore only when no // state file exists yet. As soon as the operator's first admin RPC // writes the file, BanListPolicy consults the persisted lists // exclusively — later edits to [authorization] in the TOML are // ignored at boot. - val seedAuthorization = - AuthorizationSeed( - allowedPubkeys = config.authorization.pubkey_whitelist, - bannedPubkeys = config.authorization.pubkey_blacklist, - allowedKinds = config.authorization.kind_whitelist, - disallowedKinds = config.authorization.kind_blacklist, + val runtimeConfig = + RuntimeConfig( + file = config.admin.state_file?.let { File(it) }, + 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, + disallowedKinds = config.authorization.kind_blacklist, + ), ) val negentropySettings = NegentropySettings( @@ -133,10 +139,8 @@ fun main(args: Array) { RelayEngine( advertisedUrl, store, - info, + runtimeConfig, policyBuilder, - stateFile = stateFile, - seedAuthorization = seedAuthorization, parallelVerify = parallelVerify, negentropySettings = negentropySettings, ) @@ -176,13 +180,6 @@ fun main(args: Array) { * 1. AUTH (drops everything if not authenticated) * 2. Future-timestamp rejection * 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( config: StaticConfig, diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt index 306704c40..8923521b2 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt @@ -20,7 +20,6 @@ */ 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 @@ -35,7 +34,6 @@ import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings import com.vitorpamplona.quartz.nip86RelayManagement.server.BanListPolicy import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore import kotlinx.coroutines.SupervisorJob -import java.io.File import kotlin.coroutines.CoroutineContext /** @@ -58,34 +56,26 @@ import kotlin.coroutines.CoroutineContext class RelayEngine( val url: NormalizedRelayUrl, 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 }, 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 * [com.vitorpamplona.quartz.nip01Core.relay.server.IngestQueue] @@ -103,25 +93,11 @@ class RelayEngine( */ negentropySettings: NegentropySettings = NegentropySettings.Default, ) : 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 file exists, otherwise the seed derived from - * [com.vitorpamplona.geode.config.StaticConfig]. Read once here - * so the `info` property initializer and the [banStore] seeding - * in [init] agree on the same source. + * the file exists, otherwise the seed baked into [runtimeConfig]. + * Read once here so the `info` property initializer and the + * [banStore] seeding in [init] agree on the same source. */ private val effectiveAtBoot: RuntimeConfigData = runtimeConfig.effective() @@ -132,11 +108,11 @@ class RelayEngine( * request so changes are visible immediately, no restart needed. * * Initialized from [effectiveAtBoot] — a persisted admin override - * wins, otherwise we fall back to the static-config seed. The - * `!!` is load-bearing: the seed is always built with a non-null - * info above, so the only way we reach a null here is a manually - * corrupted state file, which we'd rather crash on than serve - * empty NIP-11 from. + * wins, otherwise we fall back to the seed baked into + * [runtimeConfig]. The `!!` is load-bearing: the seed is always + * built with a non-null info, so the only way we reach a null + * here is a manually corrupted state file, which we'd rather + * crash on than serve empty NIP-11 from. */ @Volatile var info: RelayInfo = RelayInfo(effectiveAtBoot.info!!) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt index 8f272161b..20e4187c5 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.geode.config -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -140,18 +139,3 @@ data class BannedEntry( val key: String, 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 = emptyList(), - val bannedPubkeys: List = emptyList(), - val allowedKinds: List = emptyList(), - val disallowedKinds: List = emptyList(), -) diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt index 682757df3..ab13996e8 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/KtorRelayTest.kt @@ -20,6 +20,8 @@ */ 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.testing.preload import com.vitorpamplona.geode.testing.publish @@ -337,7 +339,15 @@ class KtorRelayTest { 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 = KtorRelay(customRelay, host = "127.0.0.1", port = freePort).start() try { diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/config/RuntimeConfigTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/config/RuntimeConfigTest.kt index 269a184e8..10bab8a7d 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/config/RuntimeConfigTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/config/RuntimeConfigTest.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.geode.config import com.vitorpamplona.geode.RelayEngine +import com.vitorpamplona.geode.RelayInfo import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import java.io.File @@ -48,9 +49,15 @@ class RuntimeConfigTest { 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 fun firstBootWritesNothingUntilFirstMutation() { - val relay = RelayEngine(url = url, stateFile = stateFile) + val relay = relayPersisted() try { // No mutation yet → file does not exist. assertTrue(!stateFile.exists(), "fresh relay must not eagerly write a snapshot") @@ -62,7 +69,7 @@ class RuntimeConfigTest { @Test fun banPubkeyTriggersSnapshotAndSurvivesRestart() { val pk = "a".repeat(64) - val r1 = RelayEngine(url = url, stateFile = stateFile) + val r1 = relayPersisted() try { r1.banStore.banPubkey(pk, "spam") } finally { @@ -71,7 +78,7 @@ class RuntimeConfigTest { assertTrue(stateFile.exists(), "snapshot must be written after a mutation") // Fresh relay reads the snapshot and sees the ban. - val r2 = RelayEngine(url = url, stateFile = stateFile) + val r2 = relayPersisted() try { assertTrue(r2.banStore.isBanned(pk)) assertEquals("spam", r2.banStore.listBannedPubkeys()[0].second) @@ -82,14 +89,14 @@ class RuntimeConfigTest { @Test fun updateInfoSurvivesRestart() { - val r1 = RelayEngine(url = url, stateFile = stateFile) + val r1 = relayPersisted() try { r1.updateInfo { it.copy(name = "renamed") } } finally { r1.close() } - val r2 = RelayEngine(url = url, stateFile = stateFile) + val r2 = relayPersisted() try { assertEquals("renamed", r2.info.document.name) } finally { @@ -99,7 +106,7 @@ class RuntimeConfigTest { @Test fun allowKindRoundTripsAcrossRestart() { - val r1 = RelayEngine(url = url, stateFile = stateFile) + val r1 = relayPersisted() try { r1.banStore.allowKind(1) r1.banStore.allowKind(7) @@ -108,7 +115,7 @@ class RuntimeConfigTest { r1.close() } - val r2 = RelayEngine(url = url, stateFile = stateFile) + val r2 = relayPersisted() try { assertEquals(listOf(1, 7), r2.banStore.listAllowedKinds()) assertEquals(listOf(4), r2.banStore.listDisallowedKinds()) @@ -121,7 +128,7 @@ class RuntimeConfigTest { fun corruptStateFileIsTolerated() { stateFile.writeText("not valid json {") // Should not throw — just log and start fresh. - val r = RelayEngine(url = url, stateFile = stateFile) + val r = relayPersisted() try { assertTrue(r.banStore.listBannedPubkeys().isEmpty()) assertTrue(r.banStore.listAllowedKinds().isEmpty()) @@ -133,7 +140,7 @@ class RuntimeConfigTest { @Test fun snapshotWriteIsAtomicViaTempFile() { // After a mutation completes, no `.tmp` file should remain. - val r = RelayEngine(url = url, stateFile = stateFile) + val r = relayPersisted() try { r.banStore.banPubkey("b".repeat(64)) val tmp = File(dir, "admin.json.tmp") @@ -145,7 +152,7 @@ class RuntimeConfigTest { @Test fun missingStateFileMeansInMemoryOnly() { - val r = RelayEngine(url = url) // no stateFile + val r = RelayEngine(url = url) // default runtimeConfig: file=null try { r.banStore.banPubkey("c".repeat(64)) // No snapshot path → nothing on disk in our temp dir. @@ -231,14 +238,11 @@ class RuntimeConfigTest { // First boot, no file → BanStore is seeded from static // authorization. val r1 = - RelayEngine( - url = url, - stateFile = stateFile, - seedAuthorization = - AuthorizationSeed( - bannedPubkeys = listOf(pk), - allowedKinds = listOf(1, 7), - ), + relayPersisted( + defaultSeed().copy( + bannedPubkeys = listOf(BannedEntry(pk)), + allowedKinds = listOf(1, 7), + ), ) try { assertTrue(r1.banStore.isBanned(pk), "static-seed pubkey ban must apply on first boot") @@ -251,7 +255,7 @@ class RuntimeConfigTest { assertTrue(!stateFile.exists(), "no admin mutation yet → no snapshot") // Trigger a mutation so the file gets written. - val r2 = RelayEngine(url = url, stateFile = stateFile) + val r2 = relayPersisted() try { r2.banStore.banPubkey("b".repeat(64)) } finally { @@ -262,14 +266,11 @@ class RuntimeConfigTest { // Now restart with a *different* static seed: it must be // ignored — the file is authoritative. val r3 = - RelayEngine( - url = url, - stateFile = stateFile, - seedAuthorization = - AuthorizationSeed( - bannedPubkeys = listOf("c".repeat(64)), - allowedKinds = listOf(99), - ), + relayPersisted( + defaultSeed().copy( + bannedPubkeys = listOf(BannedEntry("c".repeat(64))), + allowedKinds = listOf(99), + ), ) try { assertTrue(r3.banStore.isBanned("b".repeat(64)), "persisted file ban must survive restart") @@ -284,11 +285,8 @@ class RuntimeConfigTest { fun seedFromStaticConfigFlowsThroughToNip11Reply() { // End-to-end via RelayEngine: a static-derived info reaches the // engine's info property without any persisted runtime override. - val seeded = - com.vitorpamplona.geode.RelayInfo( - Nip11RelayInformation(name = "static-name", description = "static-desc"), - ) - val engine = RelayEngine(url = url, info = seeded, stateFile = stateFile) + val seeded = Nip11RelayInformation(name = "static-name", description = "static-desc") + val engine = relayPersisted(defaultSeed(info = seeded)) try { assertEquals("static-name", engine.info.document.name) assertEquals("static-desc", engine.info.document.description)