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:
@@ -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(),
|
||||||
|
)
|
||||||
+14
-9
@@ -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,
|
|
||||||
)
|
|
||||||
+129
-7
@@ -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.
|
||||||
+7
-7
@@ -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)
|
||||||
+7
-5
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user