refactor(geode): split config into StaticConfig + RuntimeConfig with seeding

Rename RelayConfig → StaticConfig and the persistence/RelayStateStore →
config/RuntimeConfig so the two configuration tiers — boot-time TOML
versus operator-mutable runtime state — are obvious from the class
names. The `persistence/` package is gone; everything related to config
lives in `config/`.

For overlapping fields (NIP-11 info doc, pubkey / kind allow-deny
lists), StaticConfig now seeds RuntimeConfig on first boot via the new
RuntimeConfig(seed = …) constructor and an AuthorizationSeed type. The
runtime file wins from then on: later edits to [authorization] in the
TOML are ignored once an admin RPC has written the snapshot. As a
consequence, KindAllowDenyPolicy and PubkeyAllowDenyPolicy are no
longer stacked in geode's policy chain — BanListPolicy already reads
the same lists from the BanStore, and double-stacking would silently
diverge after the first admin mutation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-12 15:51:19 -04:00
parent 9c0873b178
commit b0ad540453
11 changed files with 408 additions and 173 deletions
@@ -20,13 +20,12 @@
*/
package com.vitorpamplona.geode
import com.vitorpamplona.geode.config.RelayConfig
import com.vitorpamplona.geode.config.AuthorizationSeed
import com.vitorpamplona.geode.config.StaticConfig
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
@@ -50,8 +49,10 @@ import java.io.File
*
* Every section is enforced: `[info]` populates the NIP-11 doc,
* `[network]` controls the bind, `[database]` chooses the SQLite path,
* `[options]` toggles AUTH/verify/future-skew, `[limits]` and
* `[authorization]` plug into the relay's policy stack.
* `[options]` toggles AUTH/verify/future-skew, `[limits]` plugs into
* the relay's policy stack, and `[authorization]` seeds the runtime
* [com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore] on
* first boot (see [com.vitorpamplona.geode.config.RuntimeConfig]).
*
* CLI flags:
* --config <file> TOML config (see config.example.toml)
@@ -68,11 +69,11 @@ import java.io.File
fun main(args: Array<String>) {
val a = parseArgs(args)
val config: RelayConfig =
val config: StaticConfig =
a
.opt("--config")
?.let { RelayConfig.fromFile(File(it)) }
?: RelayConfig()
?.let { StaticConfig.fromFile(File(it)) }
?: StaticConfig()
val host = a.opt("--host") ?: config.network.host
val port = a.opt("--port")?.toInt() ?: config.network.port
@@ -110,6 +111,18 @@ fun main(args: Array<String>) {
}
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 negentropySettings =
NegentropySettings(
frameSizeLimit = config.negentropy.frame_size_limit,
@@ -123,6 +136,7 @@ fun main(args: Array<String>) {
info,
policyBuilder,
stateFile = stateFile,
seedAuthorization = seedAuthorization,
parallelVerify = parallelVerify,
negentropySettings = negentropySettings,
)
@@ -166,11 +180,18 @@ fun main(args: Array<String>) {
*
* Order matters — cheap rejection paths run before expensive ones:
* 1. AUTH (drops everything if not authenticated)
* 2. Future-timestamp + allow/deny lists
* 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: RelayConfig,
config: StaticConfig,
advertisedUrl: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
requireAuth: Boolean,
verifySigs: Boolean,
@@ -186,14 +207,6 @@ private fun composePolicy(
pieces += RejectFutureEventsPolicy(secs)
}
val auth = config.authorization
if (auth.kind_whitelist.isNotEmpty() || auth.kind_blacklist.isNotEmpty()) {
pieces += KindAllowDenyPolicy(auth.kind_whitelist.toSet(), auth.kind_blacklist.toSet())
}
if (auth.pubkey_whitelist.isNotEmpty() || auth.pubkey_blacklist.isNotEmpty()) {
pieces += PubkeyAllowDenyPolicy(auth.pubkey_whitelist.toSet(), auth.pubkey_blacklist.toSet())
}
if (verifySigs) {
// When parallel verify is on, the IngestQueue handles EVENT
// verification on the writer's CPU fan-out — but AUTH events
@@ -20,9 +20,10 @@
*/
package com.vitorpamplona.geode
import com.vitorpamplona.geode.persistence.BannedEntry
import com.vitorpamplona.geode.persistence.RelayPersistedState
import com.vitorpamplona.geode.persistence.RelayStateStore
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.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
@@ -71,6 +72,20 @@ class RelayEngine(
* 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]
@@ -88,7 +103,27 @@ class RelayEngine(
*/
negentropySettings: NegentropySettings = NegentropySettings.Default,
) : AutoCloseable {
private val stateStore: RelayStateStore? = stateFile?.let { RelayStateStore(it) }
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.
*/
private val effectiveAtBoot: RuntimeConfigData = runtimeConfig.effective()
/**
* NIP-11 doc. Mutable so NIP-86 admin RPCs (`changerelayname`,
@@ -96,14 +131,15 @@ class RelayEngine(
* atomically. Readers (the NIP-11 GET endpoint) re-read on every
* request so changes are visible immediately, no restart needed.
*
* If a [RelayStateStore] is configured and the snapshot exists,
* the persisted info doc takes precedence over the constructor
* default — operators expect their last `changerelayname` to
* survive a restart.
* 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.
*/
@Volatile
var info: RelayInfo =
stateStore?.load()?.info?.let { RelayInfo(it) } ?: info
var info: RelayInfo = RelayInfo(effectiveAtBoot.info!!)
private set
/** Mutates the live NIP-11 doc. Called by [Nip86Server]. */
@@ -120,33 +156,31 @@ class RelayEngine(
val banStore: BanStore = BanStore(onMutation = { snapshot() })
init {
// Seed the in-memory ban state from disk *without* triggering
// [snapshot] on every entry — the snapshot is exactly what we
// just loaded.
stateStore?.load()?.let { snap ->
banStore.seedFromSnapshot(
bannedPubkeys = snap.bannedPubkeys.map { it.key to it.reason },
allowedPubkeys = snap.allowedPubkeys.map { it.key to it.reason },
bannedEvents = snap.bannedEvents.map { it.key to it.reason },
allowedKinds = snap.allowedKinds,
disallowedKinds = snap.disallowedKinds,
)
}
// Seed the in-memory ban state from [effectiveAtBoot] *without*
// firing [snapshot] on every entry — the snapshot is exactly
// what we just loaded (or, on first boot, the static seed we
// just synthesized).
banStore.seedFromSnapshot(
bannedPubkeys = effectiveAtBoot.bannedPubkeys.map { it.key to it.reason },
allowedPubkeys = effectiveAtBoot.allowedPubkeys.map { it.key to it.reason },
bannedEvents = effectiveAtBoot.bannedEvents.map { it.key to it.reason },
allowedKinds = effectiveAtBoot.allowedKinds,
disallowedKinds = effectiveAtBoot.disallowedKinds,
)
}
/**
* Writes the current state (NIP-11 doc + ban lists) to disk.
* No-op when no `stateFile` was configured.
* No-op when no `stateFile` was configured (see [RuntimeConfig.save]).
*
* Best-effort: any I/O failure is logged to stderr and swallowed
* so an unwritable disk doesn't take the relay down. Operators
* monitor for missing snapshots out-of-band.
*/
fun snapshot() {
val s = stateStore ?: return
runCatching {
s.save(
RelayPersistedState(
runtimeConfig.save(
RuntimeConfigData(
info = info.document,
bannedPubkeys = banStore.listBannedPubkeys().map { (k, r) -> BannedEntry(k, r) },
allowedPubkeys = banStore.listAllowedPubkeys().map { (k, r) -> BannedEntry(k, r) },
@@ -45,7 +45,7 @@ data class RelayInfo(
/**
* NIPs this relay implements out of the box. Single source of
* truth both [default] and [com.vitorpamplona.geode.config.RelayConfig.resolveInfo]
* truth both [default] and [com.vitorpamplona.geode.config.StaticConfig.resolveInfo]
* consult this list. Add a NIP here when its handler is wired
* into [com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession]
* (or in this module's policy stack).
@@ -0,0 +1,157 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
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
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
/**
* On-disk handle for the relay's **runtime** configuration the
* operator-mutable bits that [StaticConfig] deliberately can't carry
* because they change while the relay is running:
*
* - the live NIP-11 info doc (so `changerelayname/description/icon`
* survive a restart), and
* - the NIP-86 ban / allow / kind lists for pubkeys, events, and
* kinds (NIP-86 admin RPC mutates these directly).
*
* One JSON file per relay. Lives next to the SQLite event store by
* convention, but the path is configurable independently via
* [StaticConfig.AdminSection.state_file]. Atomic write via temp +
* atomic rename so a crash mid-save can never leave the file
* half-written.
*
* **Precedence with [StaticConfig].** [StaticConfig] supplies the
* [seed] used the first time the relay boots without a persisted
* file: the NIP-11 `[info]` section becomes the initial info doc,
* and `[authorization]` lists become the initial pubkey / kind
* allow / deny lists. As soon as the file exists on disk written
* by the first NIP-86 RPC mutation the seed is never consulted
* again. From then on the file is the only source of truth; later
* edits to `[authorization]` in the TOML are ignored at boot.
*
* Pass `file = null` to keep everything in memory (useful for tests):
* [load] then returns `null` and [save] becomes a no-op, so the
* relay falls back to the [seed] forever.
*
* The [RuntimeConfigData] schema intentionally mirrors NIP-86 list
* responses (`pubkey + reason`, `id + reason`) so a future
* operator-tools CLI can read these straight from disk without
* translation.
*/
class RuntimeConfig(
val file: File?,
/**
* Initial values derived from [StaticConfig] the baseline the
* relay uses on first boot, before any NIP-86 RPC has produced a
* file. Once [save] writes the file, [effective] returns the
* persisted snapshot instead and this seed is discarded.
*/
private val seed: RuntimeConfigData,
) {
/** Load the snapshot from disk, or `null` if no file is configured / yet exists. */
@Synchronized
fun load(): RuntimeConfigData? {
val f = file ?: return null
if (!f.exists()) return null
return try {
json.decodeFromString(RuntimeConfigData.serializer(), f.readText())
} catch (e: Exception) {
// Corrupt file — log to stderr and refuse to overwrite. The
// operator chooses whether to fix or delete; we don't blow
// away their state silently.
System.err.println("warning: failed to read relay state file ${f.absolutePath}: ${e.message}")
null
}
}
/**
* Atomically write the snapshot. No-op when `file == null` the
* caller is running in memory-only mode (tests, ephemeral relays)
* and there's nothing to persist.
*/
@Synchronized
fun save(state: RuntimeConfigData) {
val f = file ?: return
f.parentFile?.let { if (!it.exists()) it.mkdirs() }
val tmp = File(f.parentFile ?: f.absoluteFile.parentFile, "${f.name}.tmp")
tmp.writeText(json.encodeToString(RuntimeConfigData.serializer(), state))
Files.move(
tmp.toPath(),
f.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE,
)
}
/**
* The state the relay should serve right now: the persisted
* snapshot if the file exists, otherwise the [seed] from
* [StaticConfig]. Call this once at boot to initialize in-memory
* caches (NIP-11 doc, ban store); subsequent admin RPCs mutate
* those caches in place and call [save], they don't re-read from
* disk.
*/
fun effective(): RuntimeConfigData = load() ?: seed
private val json =
Json {
prettyPrint = true
encodeDefaults = false
ignoreUnknownKeys = true
}
}
@Serializable
data class RuntimeConfigData(
val info: Nip11RelayInformation? = null,
val bannedPubkeys: List<BannedEntry> = emptyList(),
val allowedPubkeys: List<BannedEntry> = emptyList(),
val bannedEvents: List<BannedEntry> = emptyList(),
val allowedKinds: List<Int> = emptyList(),
val disallowedKinds: List<Int> = emptyList(),
)
@Serializable
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(),
)
@@ -29,13 +29,17 @@ import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import java.io.File
/**
* Operator-facing configuration. Section layout matches nostr-rs-relay's
* `config.toml` so existing configs can be ported with little churn.
* Operator-facing **boot-time** configuration. Parsed once from a TOML
* file at startup and treated as immutable thereafter anything that
* needs to change while the relay is running lives in [RuntimeConfig].
*
* Section layout matches nostr-rs-relay's `config.toml` so existing
* configs can be ported with little churn.
*
* Every section is optional; values not set fall back to sensible
* defaults (or, for fields also exposed on the CLI, the CLI value wins).
*/
data class RelayConfig(
data class StaticConfig(
val info: InfoSection = InfoSection(),
val network: NetworkSection = NetworkSection(),
val database: DatabaseSection = DatabaseSection(),
@@ -213,9 +217,10 @@ data class RelayConfig(
val pubkeys: List<String> = emptyList(),
val public_url: String? = null,
/**
* Path for the JSON snapshot that persists NIP-86 admin state
* (ban lists + the live NIP-11 doc) across restarts. When
* unset, admin state is in-memory only.
* Path for the JSON snapshot that backs [RuntimeConfig]
* NIP-86 admin state (ban lists + the live NIP-11 doc) that
* survives restarts. When unset, runtime config is in-memory
* only.
*
* Convention: place next to the SQLite event-store file
* e.g. `[database].file = "/var/lib/geode/events.db"`
@@ -228,10 +233,10 @@ data class RelayConfig(
private val mapper = tomlMapper { }
/** Parse a TOML string. */
fun fromToml(toml: String): RelayConfig = mapper.decode<RelayConfig>(toml)
fun fromToml(toml: String): StaticConfig = mapper.decode<StaticConfig>(toml)
/** Load a TOML config file. */
fun fromFile(file: File): RelayConfig = mapper.decode<RelayConfig>(file.toPath())
fun fromFile(file: File): StaticConfig = mapper.decode<StaticConfig>(file.toPath())
/**
* Returns the URL the relay advertises in NIP-11 and NIP-42
@@ -240,7 +245,7 @@ data class RelayConfig(
* 2. The `network` section's host/port/path (with 0.0.0.0 127.0.0.1)
* 3. The CLI override (handled in `Main.kt`).
*/
fun advertisedUrl(config: RelayConfig): NormalizedRelayUrl =
fun advertisedUrl(config: StaticConfig): NormalizedRelayUrl =
(
config.info.relay_url
?: defaultUrl(config.network)
@@ -1,98 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.persistence
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
/**
* On-disk snapshot of the relay's *operator-mutable* state the
* NIP-11 info doc (so `changerelayname/description/icon` survive a
* restart) and the NIP-86 ban / allow / kind lists.
*
* One JSON file per relay. Lives next to the SQLite event store by
* convention, but the path is configurable independently. Atomic
* write via temp + atomic rename so a crash mid-save can never leave
* the file half-written.
*
* The schema below intentionally mirrors NIP-86 list responses
* (`pubkey + reason`, `id + reason`) so a future operator-tools CLI
* can read these straight from disk without translation.
*/
class RelayStateStore(
val file: File,
) {
/** Load the snapshot from disk, or `null` if the file does not yet exist. */
@Synchronized
fun load(): RelayPersistedState? {
if (!file.exists()) return null
return try {
json.decodeFromString(RelayPersistedState.serializer(), file.readText())
} catch (e: Exception) {
// Corrupt file — log to stderr and refuse to overwrite. The
// operator chooses whether to fix or delete; we don't blow
// away their state silently.
System.err.println("warning: failed to read relay state file ${file.absolutePath}: ${e.message}")
null
}
}
/** Atomically write the snapshot. */
@Synchronized
fun save(state: RelayPersistedState) {
file.parentFile?.let { if (!it.exists()) it.mkdirs() }
val tmp = File(file.parentFile ?: file.absoluteFile.parentFile, "${file.name}.tmp")
tmp.writeText(json.encodeToString(RelayPersistedState.serializer(), state))
Files.move(
tmp.toPath(),
file.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE,
)
}
private val json =
Json {
prettyPrint = true
encodeDefaults = false
ignoreUnknownKeys = true
}
}
@Serializable
data class RelayPersistedState(
val info: Nip11RelayInformation? = null,
val bannedPubkeys: List<BannedEntry> = emptyList(),
val allowedPubkeys: List<BannedEntry> = emptyList(),
val bannedEvents: List<BannedEntry> = emptyList(),
val allowedKinds: List<Int> = emptyList(),
val disallowedKinds: List<Int> = emptyList(),
)
@Serializable
data class BannedEntry(
val key: String,
val reason: String? = null,
)
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.persistence
package com.vitorpamplona.geode.config
import com.vitorpamplona.geode.RelayEngine
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -32,7 +32,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PersistenceTest {
class RuntimeConfigTest {
private lateinit var dir: File
private lateinit var stateFile: File
private val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
@@ -156,10 +156,10 @@ class PersistenceTest {
}
@Test
fun stateStoreRoundTripsAllSections() {
val ss = RelayStateStore(stateFile)
fun runtimeConfigRoundTripsAllSections() {
val rc = RuntimeConfig(stateFile, seed = RuntimeConfigData(info = Nip11RelayInformation()))
val state =
RelayPersistedState(
RuntimeConfigData(
info = Nip11RelayInformation(name = "x", description = "y"),
bannedPubkeys = listOf(BannedEntry("aa", "spam"), BannedEntry("bb", null)),
allowedPubkeys = listOf(BannedEntry("cc", "trusted")),
@@ -167,14 +167,136 @@ class PersistenceTest {
allowedKinds = listOf(1, 7),
disallowedKinds = listOf(4, 1059),
)
ss.save(state)
val loaded = ss.load()!!
rc.save(state)
val loaded = rc.load()!!
assertEquals("x", loaded.info!!.name)
assertEquals(2, loaded.bannedPubkeys.size)
assertEquals(listOf(1, 7), loaded.allowedKinds)
assertEquals(listOf(4, 1059), loaded.disallowedKinds)
}
@Test
fun effectiveFallsBackToStaticSeedWhenNothingPersisted() {
// Fresh file → no override → static seed wins.
val seed =
RuntimeConfigData(
info = Nip11RelayInformation(name = "from-toml", description = "seeded"),
allowedKinds = listOf(1, 7),
bannedPubkeys = listOf(BannedEntry("aa")),
)
val rc = RuntimeConfig(stateFile, seed = seed)
val eff = rc.effective()
val info = eff.info!!
assertEquals("from-toml", info.name)
assertEquals("seeded", info.description)
assertEquals(listOf(1, 7), eff.allowedKinds)
assertEquals(listOf("aa"), eff.bannedPubkeys.map { it.key })
}
@Test
fun effectivePrefersPersistedOverrideOverStaticSeed() {
val seed =
RuntimeConfigData(
info = Nip11RelayInformation(name = "from-toml"),
allowedKinds = listOf(1, 7),
)
val rc = RuntimeConfig(stateFile, seed = seed)
rc.save(
RuntimeConfigData(
info = Nip11RelayInformation(name = "admin-renamed"),
allowedKinds = listOf(42),
),
)
val eff = rc.effective()
assertEquals("admin-renamed", eff.info!!.name)
// Static seed is fully ignored — persisted snapshot wins for
// every field, not just info.
assertEquals(listOf(42), eff.allowedKinds)
}
@Test
fun nullFileMakesSaveANoOpAndAlwaysReturnsSeed() {
// In-memory mode: even after save(), there's nothing on disk to
// load, so the seed continues to win.
val seed = RuntimeConfigData(info = Nip11RelayInformation(name = "memory-only"))
val rc = RuntimeConfig(file = null, seed = seed)
rc.save(RuntimeConfigData(info = Nip11RelayInformation(name = "ignored")))
assertNull(rc.load())
assertEquals("memory-only", rc.effective().info!!.name)
}
@Test
fun authorizationSeedFlowsIntoBanStoreOnFirstBootThenFileTakesOver() {
val pk = "a".repeat(64)
// 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),
),
)
try {
assertTrue(r1.banStore.isBanned(pk), "static-seed pubkey ban must apply on first boot")
assertEquals(listOf(1, 7), r1.banStore.listAllowedKinds())
} finally {
r1.close()
}
// First boot wrote nothing to disk yet (we never mutated), so
// the file is still absent — seed still wins on restart.
assertTrue(!stateFile.exists(), "no admin mutation yet → no snapshot")
// Trigger a mutation so the file gets written.
val r2 = RelayEngine(url = url, stateFile = stateFile)
try {
r2.banStore.banPubkey("b".repeat(64))
} finally {
r2.close()
}
assertTrue(stateFile.exists())
// 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),
),
)
try {
assertTrue(r3.banStore.isBanned("b".repeat(64)), "persisted file ban must survive restart")
assertTrue(!r3.banStore.isBanned("c".repeat(64)), "new static seed must be ignored once file exists")
assertTrue(r3.banStore.listAllowedKinds().isEmpty(), "new static kind seed must be ignored once file exists")
} finally {
r3.close()
}
}
@Test
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)
try {
assertEquals("static-name", engine.info.document.name)
assertEquals("static-desc", engine.info.document.description)
} finally {
engine.close()
}
}
/**
* Manual `Nip11RelayInformation.copy` the class isn't a data
* class so Kotlin doesn't generate one. We only need `name` here.
@@ -26,10 +26,10 @@ import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class RelayConfigTest {
class StaticConfigTest {
@Test
fun emptyTomlYieldsAllDefaults() {
val c = RelayConfig.fromToml("")
val c = StaticConfig.fromToml("")
assertEquals("0.0.0.0", c.network.host)
assertEquals(7447, c.network.port)
assertEquals("/", c.network.path)
@@ -42,7 +42,7 @@ class RelayConfigTest {
@Test
fun verifySignaturesCanBeExplicitlyDisabled() {
val c = RelayConfig.fromToml("[options]\nverify_signatures = false")
val c = StaticConfig.fromToml("[options]\nverify_signatures = false")
assertEquals(false, c.options.verify_signatures)
}
@@ -78,7 +78,7 @@ class RelayConfigTest {
kind_blacklist = [4, 1059]
""".trimIndent()
val c = RelayConfig.fromToml(toml)
val c = StaticConfig.fromToml(toml)
assertEquals("wss://relay.example.com/", c.info.relay_url)
assertEquals("Example", c.info.name)
@@ -104,7 +104,7 @@ class RelayConfigTest {
@Test
fun supportedNipsRenderedAsStringsInNip11Doc() {
val c =
RelayConfig.fromToml(
StaticConfig.fromToml(
"""
[info]
supported_nips = [1, 11, 42]
@@ -137,7 +137,7 @@ class RelayConfigTest {
"config.example.toml not found in any of: ${candidates.joinToString { it.absolutePath }}",
)
val c = RelayConfig.fromFile(example)
val c = StaticConfig.fromFile(example)
assertEquals("wss://relay.example.com/", c.info.relay_url)
assertEquals(true, c.options.verify_signatures)
@@ -147,7 +147,7 @@ class RelayConfigTest {
@Test
fun missingSectionsAreOptional() {
val c = RelayConfig.fromToml("[info]\nname = \"only-info\"")
val c = StaticConfig.fromToml("[info]\nname = \"only-info\"")
assertEquals("only-info", c.info.name)
// Defaults preserved for unspecified sections.
assertEquals(7447, c.network.port)