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:
@@ -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<String>) {
|
||||
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<String>) {
|
||||
RelayEngine(
|
||||
advertisedUrl,
|
||||
store,
|
||||
info,
|
||||
runtimeConfig,
|
||||
policyBuilder,
|
||||
stateFile = stateFile,
|
||||
seedAuthorization = seedAuthorization,
|
||||
parallelVerify = parallelVerify,
|
||||
negentropySettings = negentropySettings,
|
||||
)
|
||||
@@ -176,13 +180,6 @@ fun main(args: Array<String>) {
|
||||
* 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,
|
||||
|
||||
@@ -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!!)
|
||||
|
||||
@@ -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<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
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user