diff --git a/geode/plans/2026-05-07-connection-scaling.md b/geode/plans/2026-05-07-connection-scaling.md index c8c8c521d..2b8bfd430 100644 --- a/geode/plans/2026-05-07-connection-scaling.md +++ b/geode/plans/2026-05-07-connection-scaling.md @@ -81,7 +81,7 @@ swap was simulating, with none of the swap's race surface. ### 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 [network] diff --git a/geode/plans/2026-05-07-negentropy-large-corpus.md b/geode/plans/2026-05-07-negentropy-large-corpus.md index 55d057d01..b9e550b81 100644 --- a/geode/plans/2026-05-07-negentropy-large-corpus.md +++ b/geode/plans/2026-05-07-negentropy-large-corpus.md @@ -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. 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 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 diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 021de8d95..48b85079e 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -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 TOML config (see config.example.toml) @@ -68,11 +69,11 @@ import java.io.File fun main(args: Array) { 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) { } 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) { info, policyBuilder, stateFile = stateFile, + seedAuthorization = seedAuthorization, parallelVerify = parallelVerify, negentropySettings = negentropySettings, ) @@ -166,11 +180,18 @@ fun main(args: Array) { * * 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 diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt index 1d8c67d6e..306704c40 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt @@ -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) }, diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt index ebf41edbe..298dcdfda 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt @@ -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). diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt new file mode 100644 index 000000000..8f272161b --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt @@ -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 = emptyList(), + val allowedPubkeys: List = emptyList(), + val bannedEvents: List = emptyList(), + val allowedKinds: List = emptyList(), + val disallowedKinds: List = 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 = emptyList(), + val bannedPubkeys: List = emptyList(), + val allowedKinds: List = emptyList(), + val disallowedKinds: List = emptyList(), +) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt similarity index 92% rename from geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayConfig.kt rename to geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index c900c4869..b782ed7d3 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -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 = 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(toml) + fun fromToml(toml: String): StaticConfig = mapper.decode(toml) /** Load a TOML config file. */ - fun fromFile(file: File): RelayConfig = mapper.decode(file.toPath()) + fun fromFile(file: File): StaticConfig = mapper.decode(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) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/persistence/RelayStateStore.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/persistence/RelayStateStore.kt deleted file mode 100644 index 7bb7f0d49..000000000 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/persistence/RelayStateStore.kt +++ /dev/null @@ -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 = emptyList(), - val allowedPubkeys: List = emptyList(), - val bannedEvents: List = emptyList(), - val allowedKinds: List = emptyList(), - val disallowedKinds: List = emptyList(), -) - -@Serializable -data class BannedEntry( - val key: String, - val reason: String? = null, -) diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/persistence/PersistenceTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/config/RuntimeConfigTest.kt similarity index 57% rename from geode/src/test/kotlin/com/vitorpamplona/geode/persistence/PersistenceTest.kt rename to geode/src/test/kotlin/com/vitorpamplona/geode/config/RuntimeConfigTest.kt index 0dd60cea4..269a184e8 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/persistence/PersistenceTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/config/RuntimeConfigTest.kt @@ -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. diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/config/RelayConfigTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt similarity index 93% rename from geode/src/test/kotlin/com/vitorpamplona/geode/config/RelayConfigTest.kt rename to geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt index 166a38050..a674b9d30 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/config/RelayConfigTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt index 398f93d7d..20405c066 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt @@ -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 * 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.PubkeyAllowDenyPolicy] — - * both layers compose: the event must clear all stacked policies. - * NIP-86 admin RPC mutations land in the [BanStore]; the static - * policies stay frozen at boot-time config values. + * [com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy]. + * Callers that wire a [BanStore] should NOT also install those static + * policies: NIP-86 admin RPC mutations land in the [BanStore], so the + * 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( val banStore: BanStore,