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
+1 -1
View File
@@ -81,7 +81,7 @@ swap was simulating, with none of the swap's race surface.
### B — per-relay event-loop pool sizing ✅ shipped ### B — per-relay event-loop pool sizing ✅ shipped
Three optional knobs added to `[network]` in `RelayConfig`: Three optional knobs added to `[network]` in `StaticConfig`:
```toml ```toml
[network] [network]
@@ -127,7 +127,7 @@ for unlimited). 500_000 is well above the floor. Pure config change in
for operators who tune it down to fit smaller WS frame budgets. for operators who tune it down to fit smaller WS frame budgets.
Note: `LimitsSection.max_ws_frame_bytes` Note: `LimitsSection.max_ws_frame_bytes`
(`geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayConfig.kt:148`) (`geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt:148`)
applies to the WebSocket layer. After hex-encoding a 500_000-byte applies to the WebSocket layer. After hex-encoding a 500_000-byte
negentropy payload doubles to ~1_000_000 bytes on the wire; ensure negentropy payload doubles to ~1_000_000 bytes on the wire; ensure
`max_ws_frame_bytes` is at least 2 MB (or unlimited) in default `max_ws_frame_bytes` is at least 2 MB (or unlimited) in default
@@ -20,13 +20,12 @@
*/ */
package com.vitorpamplona.geode 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.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy 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.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy 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.RejectFutureEventsPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy 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, * Every section is enforced: `[info]` populates the NIP-11 doc,
* `[network]` controls the bind, `[database]` chooses the SQLite path, * `[network]` controls the bind, `[database]` chooses the SQLite path,
* `[options]` toggles AUTH/verify/future-skew, `[limits]` and * `[options]` toggles AUTH/verify/future-skew, `[limits]` plugs into
* `[authorization]` plug into the relay's policy stack. * 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: * CLI flags:
* --config <file> TOML config (see config.example.toml) * --config <file> TOML config (see config.example.toml)
@@ -68,11 +69,11 @@ import java.io.File
fun main(args: Array<String>) { fun main(args: Array<String>) {
val a = parseArgs(args) val a = parseArgs(args)
val config: RelayConfig = val config: StaticConfig =
a a
.opt("--config") .opt("--config")
?.let { RelayConfig.fromFile(File(it)) } ?.let { StaticConfig.fromFile(File(it)) }
?: RelayConfig() ?: StaticConfig()
val host = a.opt("--host") ?: config.network.host val host = a.opt("--host") ?: config.network.host
val port = a.opt("--port")?.toInt() ?: config.network.port 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) } 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 = val negentropySettings =
NegentropySettings( NegentropySettings(
frameSizeLimit = config.negentropy.frame_size_limit, frameSizeLimit = config.negentropy.frame_size_limit,
@@ -123,6 +136,7 @@ fun main(args: Array<String>) {
info, info,
policyBuilder, policyBuilder,
stateFile = stateFile, stateFile = stateFile,
seedAuthorization = seedAuthorization,
parallelVerify = parallelVerify, parallelVerify = parallelVerify,
negentropySettings = negentropySettings, negentropySettings = negentropySettings,
) )
@@ -166,11 +180,18 @@ fun main(args: Array<String>) {
* *
* Order matters — cheap rejection paths run before expensive ones: * Order matters — cheap rejection paths run before expensive ones:
* 1. AUTH (drops everything if not authenticated) * 1. AUTH (drops everything if not authenticated)
* 2. Future-timestamp + allow/deny lists * 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: RelayConfig, config: StaticConfig,
advertisedUrl: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, advertisedUrl: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
requireAuth: Boolean, requireAuth: Boolean,
verifySigs: Boolean, verifySigs: Boolean,
@@ -186,14 +207,6 @@ private fun composePolicy(
pieces += RejectFutureEventsPolicy(secs) 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) { if (verifySigs) {
// When parallel verify is on, the IngestQueue handles EVENT // When parallel verify is on, the IngestQueue handles EVENT
// verification on the writer's CPU fan-out — but AUTH events // verification on the writer's CPU fan-out — but AUTH events
@@ -20,9 +20,10 @@
*/ */
package com.vitorpamplona.geode package com.vitorpamplona.geode
import com.vitorpamplona.geode.persistence.BannedEntry import com.vitorpamplona.geode.config.AuthorizationSeed
import com.vitorpamplona.geode.persistence.RelayPersistedState import com.vitorpamplona.geode.config.BannedEntry
import com.vitorpamplona.geode.persistence.RelayStateStore 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.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
@@ -71,6 +72,20 @@ class RelayEngine(
* everything in memory only — fine for tests. * everything in memory only — fine for tests.
*/ */
stateFile: File? = null, 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]
@@ -88,7 +103,27 @@ class RelayEngine(
*/ */
negentropySettings: NegentropySettings = NegentropySettings.Default, negentropySettings: NegentropySettings = NegentropySettings.Default,
) : AutoCloseable { ) : 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`, * 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 * atomically. Readers (the NIP-11 GET endpoint) re-read on every
* request so changes are visible immediately, no restart needed. * request so changes are visible immediately, no restart needed.
* *
* If a [RelayStateStore] is configured and the snapshot exists, * Initialized from [effectiveAtBoot] — a persisted admin override
* the persisted info doc takes precedence over the constructor * wins, otherwise we fall back to the static-config seed. The
* default — operators expect their last `changerelayname` to * `!!` is load-bearing: the seed is always built with a non-null
* survive a restart. * 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 @Volatile
var info: RelayInfo = var info: RelayInfo = RelayInfo(effectiveAtBoot.info!!)
stateStore?.load()?.info?.let { RelayInfo(it) } ?: info
private set private set
/** Mutates the live NIP-11 doc. Called by [Nip86Server]. */ /** Mutates the live NIP-11 doc. Called by [Nip86Server]. */
@@ -120,33 +156,31 @@ class RelayEngine(
val banStore: BanStore = BanStore(onMutation = { snapshot() }) val banStore: BanStore = BanStore(onMutation = { snapshot() })
init { init {
// Seed the in-memory ban state from disk *without* triggering // Seed the in-memory ban state from [effectiveAtBoot] *without*
// [snapshot] on every entry — the snapshot is exactly what we // firing [snapshot] on every entry — the snapshot is exactly
// just loaded. // what we just loaded (or, on first boot, the static seed we
stateStore?.load()?.let { snap -> // just synthesized).
banStore.seedFromSnapshot( banStore.seedFromSnapshot(
bannedPubkeys = snap.bannedPubkeys.map { it.key to it.reason }, bannedPubkeys = effectiveAtBoot.bannedPubkeys.map { it.key to it.reason },
allowedPubkeys = snap.allowedPubkeys.map { it.key to it.reason }, allowedPubkeys = effectiveAtBoot.allowedPubkeys.map { it.key to it.reason },
bannedEvents = snap.bannedEvents.map { it.key to it.reason }, bannedEvents = effectiveAtBoot.bannedEvents.map { it.key to it.reason },
allowedKinds = snap.allowedKinds, allowedKinds = effectiveAtBoot.allowedKinds,
disallowedKinds = snap.disallowedKinds, disallowedKinds = effectiveAtBoot.disallowedKinds,
) )
}
} }
/** /**
* Writes the current state (NIP-11 doc + ban lists) to disk. * 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 * Best-effort: any I/O failure is logged to stderr and swallowed
* so an unwritable disk doesn't take the relay down. Operators * so an unwritable disk doesn't take the relay down. Operators
* monitor for missing snapshots out-of-band. * monitor for missing snapshots out-of-band.
*/ */
fun snapshot() { fun snapshot() {
val s = stateStore ?: return
runCatching { runCatching {
s.save( runtimeConfig.save(
RelayPersistedState( RuntimeConfigData(
info = info.document, info = info.document,
bannedPubkeys = banStore.listBannedPubkeys().map { (k, r) -> BannedEntry(k, r) }, bannedPubkeys = banStore.listBannedPubkeys().map { (k, r) -> BannedEntry(k, r) },
allowedPubkeys = banStore.listAllowedPubkeys().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 * 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 * consult this list. Add a NIP here when its handler is wired
* into [com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession] * into [com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession]
* (or in this module's policy stack). * (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 import java.io.File
/** /**
* Operator-facing configuration. Section layout matches nostr-rs-relay's * Operator-facing **boot-time** configuration. Parsed once from a TOML
* `config.toml` so existing configs can be ported with little churn. * 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 * Every section is optional; values not set fall back to sensible
* defaults (or, for fields also exposed on the CLI, the CLI value wins). * defaults (or, for fields also exposed on the CLI, the CLI value wins).
*/ */
data class RelayConfig( data class StaticConfig(
val info: InfoSection = InfoSection(), val info: InfoSection = InfoSection(),
val network: NetworkSection = NetworkSection(), val network: NetworkSection = NetworkSection(),
val database: DatabaseSection = DatabaseSection(), val database: DatabaseSection = DatabaseSection(),
@@ -213,9 +217,10 @@ data class RelayConfig(
val pubkeys: List<String> = emptyList(), val pubkeys: List<String> = emptyList(),
val public_url: String? = null, val public_url: String? = null,
/** /**
* Path for the JSON snapshot that persists NIP-86 admin state * Path for the JSON snapshot that backs [RuntimeConfig]
* (ban lists + the live NIP-11 doc) across restarts. When * NIP-86 admin state (ban lists + the live NIP-11 doc) that
* unset, admin state is in-memory only. * survives restarts. When unset, runtime config is in-memory
* only.
* *
* Convention: place next to the SQLite event-store file * Convention: place next to the SQLite event-store file
* e.g. `[database].file = "/var/lib/geode/events.db"` * e.g. `[database].file = "/var/lib/geode/events.db"`
@@ -228,10 +233,10 @@ data class RelayConfig(
private val mapper = tomlMapper { } private val mapper = tomlMapper { }
/** Parse a TOML string. */ /** 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. */ /** 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 * 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) * 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`). * 3. The CLI override (handled in `Main.kt`).
*/ */
fun advertisedUrl(config: RelayConfig): NormalizedRelayUrl = fun advertisedUrl(config: StaticConfig): NormalizedRelayUrl =
( (
config.info.relay_url config.info.relay_url
?: defaultUrl(config.network) ?: 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 * 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. * 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.geode.RelayEngine
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -32,7 +32,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
class PersistenceTest { class RuntimeConfigTest {
private lateinit var dir: File private lateinit var dir: File
private lateinit var stateFile: File private lateinit var stateFile: File
private val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") private val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
@@ -156,10 +156,10 @@ class PersistenceTest {
} }
@Test @Test
fun stateStoreRoundTripsAllSections() { fun runtimeConfigRoundTripsAllSections() {
val ss = RelayStateStore(stateFile) val rc = RuntimeConfig(stateFile, seed = RuntimeConfigData(info = Nip11RelayInformation()))
val state = val state =
RelayPersistedState( RuntimeConfigData(
info = Nip11RelayInformation(name = "x", description = "y"), info = Nip11RelayInformation(name = "x", description = "y"),
bannedPubkeys = listOf(BannedEntry("aa", "spam"), BannedEntry("bb", null)), bannedPubkeys = listOf(BannedEntry("aa", "spam"), BannedEntry("bb", null)),
allowedPubkeys = listOf(BannedEntry("cc", "trusted")), allowedPubkeys = listOf(BannedEntry("cc", "trusted")),
@@ -167,14 +167,136 @@ class PersistenceTest {
allowedKinds = listOf(1, 7), allowedKinds = listOf(1, 7),
disallowedKinds = listOf(4, 1059), disallowedKinds = listOf(4, 1059),
) )
ss.save(state) rc.save(state)
val loaded = ss.load()!! val loaded = rc.load()!!
assertEquals("x", loaded.info!!.name) assertEquals("x", loaded.info!!.name)
assertEquals(2, loaded.bannedPubkeys.size) assertEquals(2, loaded.bannedPubkeys.size)
assertEquals(listOf(1, 7), loaded.allowedKinds) assertEquals(listOf(1, 7), loaded.allowedKinds)
assertEquals(listOf(4, 1059), loaded.disallowedKinds) 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 * Manual `Nip11RelayInformation.copy` the class isn't a data
* class so Kotlin doesn't generate one. We only need `name` here. * 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.assertNotNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
class RelayConfigTest { class StaticConfigTest {
@Test @Test
fun emptyTomlYieldsAllDefaults() { fun emptyTomlYieldsAllDefaults() {
val c = RelayConfig.fromToml("") val c = StaticConfig.fromToml("")
assertEquals("0.0.0.0", c.network.host) assertEquals("0.0.0.0", c.network.host)
assertEquals(7447, c.network.port) assertEquals(7447, c.network.port)
assertEquals("/", c.network.path) assertEquals("/", c.network.path)
@@ -42,7 +42,7 @@ class RelayConfigTest {
@Test @Test
fun verifySignaturesCanBeExplicitlyDisabled() { fun verifySignaturesCanBeExplicitlyDisabled() {
val c = RelayConfig.fromToml("[options]\nverify_signatures = false") val c = StaticConfig.fromToml("[options]\nverify_signatures = false")
assertEquals(false, c.options.verify_signatures) assertEquals(false, c.options.verify_signatures)
} }
@@ -78,7 +78,7 @@ class RelayConfigTest {
kind_blacklist = [4, 1059] kind_blacklist = [4, 1059]
""".trimIndent() """.trimIndent()
val c = RelayConfig.fromToml(toml) val c = StaticConfig.fromToml(toml)
assertEquals("wss://relay.example.com/", c.info.relay_url) assertEquals("wss://relay.example.com/", c.info.relay_url)
assertEquals("Example", c.info.name) assertEquals("Example", c.info.name)
@@ -104,7 +104,7 @@ class RelayConfigTest {
@Test @Test
fun supportedNipsRenderedAsStringsInNip11Doc() { fun supportedNipsRenderedAsStringsInNip11Doc() {
val c = val c =
RelayConfig.fromToml( StaticConfig.fromToml(
""" """
[info] [info]
supported_nips = [1, 11, 42] supported_nips = [1, 11, 42]
@@ -137,7 +137,7 @@ class RelayConfigTest {
"config.example.toml not found in any of: ${candidates.joinToString { it.absolutePath }}", "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("wss://relay.example.com/", c.info.relay_url)
assertEquals(true, c.options.verify_signatures) assertEquals(true, c.options.verify_signatures)
@@ -147,7 +147,7 @@ class RelayConfigTest {
@Test @Test
fun missingSectionsAreOptional() { fun missingSectionsAreOptional() {
val c = RelayConfig.fromToml("[info]\nname = \"only-info\"") val c = StaticConfig.fromToml("[info]\nname = \"only-info\"")
assertEquals("only-info", c.info.name) assertEquals("only-info", c.info.name)
// Defaults preserved for unspecified sections. // Defaults preserved for unspecified sections.
assertEquals(7447, c.network.port) assertEquals(7447, c.network.port)
@@ -30,12 +30,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolic
* non-empty pubkey allow list, or kind disallowed / not in the kind * non-empty pubkey allow list, or kind disallowed / not in the kind
* allow list. * allow list.
* *
* This is the runtime-mutable counterpart of the static * Functionally equivalent to (and a superset of) the static
* [com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy] + * [com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy] +
* [com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy] * [com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy].
* both layers compose: the event must clear all stacked policies. * Callers that wire a [BanStore] should NOT also install those static
* NIP-86 admin RPC mutations land in the [BanStore]; the static * policies: NIP-86 admin RPC mutations land in the [BanStore], so the
* policies stay frozen at boot-time config values. * static policies would silently diverge after the first admin call.
* Geode seeds the [BanStore] from `[authorization]` at first boot
* instead — see `com.vitorpamplona.geode.config.RuntimeConfig`.
*/ */
class BanListPolicy( class BanListPolicy(
val banStore: BanStore, val banStore: BanStore,