refactor(geode): simplify RelayEngine + trim StaticConfig comments

RelayEngine (224 → 185 lines):
  - Extract BanStore <-> RuntimeConfigData mapping helpers
    (seedInto, snapshotOf) into geode.config — bidirectional
    conversion now has names instead of being inline boilerplate
    repeated between the init block and snapshot().
  - Collapse the standalone init block into a .apply on the
    banStore declaration. Method reference ::snapshot for the
    onMutation hook.
  - Drop the effectiveAtBoot field's 12-line KDoc (rename to `boot`
    locally, terse). Trim verbose property KDocs to the load-bearing
    facts (why-not-what).

StaticConfig (255 → 182 lines):
  - Drop resolveInfo's unused `advertisedUrl` parameter and the
    workaround comment that justified keeping it for "future
    fields". YAGNI — add back when actually needed. Two callers
    updated.
  - Trim trivial KDocs (fromToml/fromFile, field names that
    document themselves like require_auth, reject_future_seconds,
    supported_nips, database.file).
  - Collapse NetworkSection's three thread-pool KDocs (~16 lines)
    into a single section-level KDoc.
  - Compact parallel_verify, AdminSection, AdminSection.state_file,
    and advertisedUrl companion KDocs while keeping the
    load-bearing facts (invariants, strfry interop strings,
    surprising defaults).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-12 19:01:36 -04:00
parent 72e7f37b61
commit 14342e12c2
5 changed files with 92 additions and 182 deletions
@@ -104,7 +104,7 @@ fun main(args: Array<String>) {
val info =
cliInfoFile?.let { RelayInfo.fromFile(it) }
?: config.resolveInfo(advertisedUrl)
?: config.resolveInfo()
val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl)
@@ -20,9 +20,10 @@
*/
package com.vitorpamplona.geode
import com.vitorpamplona.geode.config.BannedEntry
import com.vitorpamplona.geode.config.RuntimeConfig
import com.vitorpamplona.geode.config.RuntimeConfigData
import com.vitorpamplona.geode.config.seedInto
import com.vitorpamplona.geode.config.snapshotOf
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
@@ -103,67 +104,40 @@ class RelayEngine(
*/
adminPubkeys: Set<HexKey> = emptySet(),
) : AutoCloseable {
/**
* The runtime state to install at boot: the persisted snapshot if
* the file exists, otherwise the seed baked into [runtimeConfig].
* Read once here so the `info` property initializer and the
* [banStore] seeding in [init] agree on the same source.
*/
private val effectiveAtBoot: RuntimeConfigData = runtimeConfig.effective()
private val boot: RuntimeConfigData = runtimeConfig.effective()
/**
* NIP-11 doc. Mutable so NIP-86 admin RPCs (`changerelayname`,
* `changerelaydescription`, `changerelayicon`) can swap the doc
* atomically. Readers (the NIP-11 GET endpoint) re-read on every
* request so changes are visible immediately, no restart needed.
*
* Initialized from [effectiveAtBoot] — a persisted admin override
* wins, otherwise we fall back to the seed baked into
* [runtimeConfig]. The `!!` is load-bearing: the seed is always
* built with a non-null info, so the only way we reach a null
* here is a manually corrupted state file, which we'd rather
* crash on than serve empty NIP-11 from.
* Live NIP-11 doc. Mutable via [updateInfo] so NIP-86 admin RPCs
* can swap it atomically; readers (NIP-11 GET) re-read every
* request so changes are visible immediately. The `!!` is load-
* bearing: the seed always has a non-null info, so a null here
* means a manually corrupted state file — fail loud over serving
* empty NIP-11.
*/
@Volatile
var info: RelayInfo = RelayInfo(effectiveAtBoot.info!!)
var info: RelayInfo = RelayInfo(boot.info!!)
private set
/** Mutates the live NIP-11 doc. Called by [Nip86Server]. */
/** Mutates the live NIP-11 doc and persists the snapshot. */
fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
info = RelayInfo(transform(info.document))
snapshot()
}
/**
* Runtime-mutable ban / allow lists. NIP-86 RPC handlers in
* [Nip86Server] mutate this; the policy stack consults it on
* every accept call via [BanListPolicy].
* Runtime-mutable ban / allow lists. Mutated by [Nip86Server] via
* NIP-86 RPCs; consulted on every EVENT by [BanListPolicy]. Seeded
* at boot from the persisted snapshot (or the static [runtimeConfig]
* seed on first boot) without firing the mutation hook.
*/
val banStore: BanStore = BanStore(onMutation = { snapshot() })
init {
// 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,
)
}
val banStore: BanStore =
BanStore(onMutation = ::snapshot)
.apply { boot.seedInto(this) }
/**
* NIP-86 admin RPC dispatcher. Owns the ban / allow / kind list
* mutations, the NIP-11 `changerelay*` mutations (via an
* [Nip86Server.InfoHolder] adapter that wraps [info] / [updateInfo]),
* the event-store purge on each ban method (via `store.delete`),
* and the admin allow-list ([adminPubkeys]). Transport-agnostic:
* `KtorRelay` wraps it with `Nip86HttpHandler` for HTTP/NIP-98;
* an in-process tool could call `dispatch(adminPubkey, req)`
* directly.
* NIP-86 admin RPC dispatcher. Transport-agnostic — `KtorRelay`
* wraps it with `Nip86HttpHandler` for HTTP/NIP-98; an in-process
* tool could call `dispatch(adminPubkey, req)` directly.
*/
val nip86Server: Nip86Server =
Nip86Server(
@@ -179,25 +153,14 @@ class RelayEngine(
)
/**
* Writes the current state (NIP-11 doc + ban lists) to disk.
* 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.
* Writes the current state (NIP-11 doc + ban lists) to disk via
* [RuntimeConfig.save] — no-op when no state file is configured.
* Best-effort: I/O failures are logged to stderr and swallowed so
* an unwritable disk doesn't take the relay down.
*/
fun snapshot() {
runCatching {
runtimeConfig.save(
RuntimeConfigData(
info = info.document,
bannedPubkeys = banStore.listBannedPubkeys().map { (k, r) -> BannedEntry(k, r) },
allowedPubkeys = banStore.listAllowedPubkeys().map { (k, r) -> BannedEntry(k, r) },
bannedEvents = banStore.listBannedEvents().map { (k, r) -> BannedEntry(k, r) },
allowedKinds = banStore.listAllowedKinds(),
disallowedKinds = banStore.listDisallowedKinds(),
),
)
runtimeConfig.save(snapshotOf(info.document, banStore))
}.onFailure {
System.err.println("warning: failed to write relay state file: ${it.message}")
}
@@ -206,10 +169,9 @@ class RelayEngine(
val server =
NostrServer(
store,
// Always prepend a BanListPolicy so NIP-86 admin actions
// bite. When the operator-supplied builder returns
// [EmptyPolicy] we use the dynamic policy alone; otherwise
// we stack them so both layers must accept.
// Always prepend BanListPolicy so NIP-86 admin actions bite.
// EmptyPolicy from the caller means "no extra policies" — use
// BanListPolicy alone; otherwise stack so both must accept.
policyBuilder = {
val user = policyBuilder()
if (user === EmptyPolicy) BanListPolicy(banStore) else user + BanListPolicy(banStore)
@@ -21,6 +21,7 @@
package com.vitorpamplona.geode.config
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
@@ -139,3 +140,28 @@ data class BannedEntry(
val key: String,
val reason: String? = null,
)
/** Bulk-load this snapshot into [banStore] without firing its `onMutation` hook. */
fun RuntimeConfigData.seedInto(banStore: BanStore) {
banStore.seedFromSnapshot(
bannedPubkeys = bannedPubkeys.map { it.key to it.reason },
allowedPubkeys = allowedPubkeys.map { it.key to it.reason },
bannedEvents = bannedEvents.map { it.key to it.reason },
allowedKinds = allowedKinds,
disallowedKinds = disallowedKinds,
)
}
/** Capture the current state of [info] + [banStore] as a fresh snapshot. */
fun snapshotOf(
info: Nip11RelayInformation,
banStore: BanStore,
): RuntimeConfigData =
RuntimeConfigData(
info = info,
bannedPubkeys = banStore.listBannedPubkeys().map { (k, r) -> BannedEntry(k, r) },
allowedPubkeys = banStore.listAllowedPubkeys().map { (k, r) -> BannedEntry(k, r) },
bannedEvents = banStore.listBannedEvents().map { (k, r) -> BannedEntry(k, r) },
allowedKinds = banStore.listAllowedKinds(),
disallowedKinds = banStore.listDisallowedKinds(),
)
@@ -34,10 +34,8 @@ import java.io.File
* 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).
* configs can be ported with little churn. Every section is optional;
* CLI flags override file values where both exist.
*/
data class StaticConfig(
val info: InfoSection = InfoSection(),
@@ -48,11 +46,7 @@ data class StaticConfig(
val admin: AdminSection = AdminSection(),
val negentropy: NegentropySection = NegentropySection(),
) {
/**
* Maps the `[info]` section into a [RelayInfo] used by the NIP-11
* endpoint. `relay_url` and CLI overrides take precedence.
*/
fun resolveInfo(advertisedUrl: NormalizedRelayUrl): RelayInfo =
fun resolveInfo(): RelayInfo =
RelayInfo(
Nip11RelayInformation(
name = info.name ?: RelayInfo.NAME,
@@ -71,12 +65,7 @@ data class StaticConfig(
language_tags = info.language_tags,
tags = info.tags,
),
).also {
// Touch [advertisedUrl] so the parameter isn't unused — we keep
// it in the signature because future fields (e.g. self-pubkey
// selection, fee URLs) will want it.
advertisedUrl.url
}
)
data class InfoSection(
val relay_url: String? = null,
@@ -87,7 +76,6 @@ data class StaticConfig(
val icon: String? = null,
val software: String? = null,
val version: String? = null,
/** NIP numbers as ints (e.g. `[1, 9, 11]`). Stringified at render time. */
val supported_nips: List<Int>? = null,
val privacy_policy: String? = null,
val terms_of_service: String? = null,
@@ -96,91 +84,50 @@ data class StaticConfig(
val tags: List<String>? = null,
)
/**
* Bind config + Ktor CIO thread-pool sizes. The three `*_size`
* fields default to Ktor's per-CPU sizing; lift them on big-VM
* deployments targeting 10k+ concurrent connections.
*/
data class NetworkSection(
val host: String = "0.0.0.0",
val port: Int = 7447,
val path: String = "/",
/**
* Ktor CIO acceptor-thread count. `null` (default) keeps Ktor's
* default sizing — fine up to a few thousand concurrent
* connections. On big-VM deployments targeting 10k+
* connections, lift this to roughly half the available cores
* so the acceptor doesn't starve workers.
*/
val connection_group_size: Int? = null,
/**
* Ktor CIO worker-thread count (handles socket I/O). `null`
* keeps Ktor's default. Each connection's WebSocket read/write
* is dispatched onto this pool; for many idle long-lived
* connections the pool can stay small, but 10k+ connections
* benefit from sizing this to the full CPU count.
*/
val worker_group_size: Int? = null,
/**
* Ktor CIO call-handling thread count. `null` keeps Ktor's
* default. Sized higher than [worker_group_size] because each
* call (incl. WebSocket upgrade) may suspend on I/O — at
* 10k+ connections, ~4× cores is a reasonable starting point.
*/
val call_group_size: Int? = null,
)
data class DatabaseSection(
/** True keeps an in-memory SQLite db (default — events vanish on restart). */
val in_memory: Boolean = true,
/** Filesystem path for a persistent SQLite db. Ignored when [in_memory] is true. */
val file: String? = null,
)
data class OptionsSection(
/** Reject events whose `created_at` is more than this many seconds in the future. */
val reject_future_seconds: Int? = null,
/** Require NIP-42 AUTH for REQ/EVENT/COUNT. */
val require_auth: Boolean = false,
/**
* Drop events whose Schnorr signature does not verify. **Defaults
* to `true`**: any relay accepting traffic from real clients
* should verify signatures, and verifying-by-default closes the
* footgun of forgetting the flag. Set explicitly to `false` only
* for trusted-input scenarios (test fixtures, mirror replays).
* Defaults to `true`: any relay accepting real traffic should
* verify Schnorr signatures, and verifying-by-default closes
* the footgun of forgetting the flag. Set false only for
* trusted-input scenarios (test fixtures, mirror replays).
*/
val verify_signatures: Boolean = true,
/**
* Run signature verification in parallel inside the IngestQueue
* (CPU fan-out across `Dispatchers.Default`) instead of serially
* on each connection's WebSocket pump. Tier-3 of the
* `event-ingestion-batching` plan. Wins scale with how many
* EVENTs a single connection sends back-to-back: ~CPU_COUNT×
* verify-step speed-up on burst publishes. Set false to keep
* the legacy in-policy verify path.
*
* Only takes effect when [verify_signatures] is also true.
*/
/** CPU fan-out verification in the IngestQueue. No-op when [verify_signatures] is false. */
val parallel_verify: Boolean = true,
)
/**
* NIP-77 negentropy tuning. Defaults track strfry
* (`hoytech/strfry`) so a Geode relay accepts the same workload
* shape and exchanges the same NEG-MSG round-trip size as
* strfry — the de-facto reference implementation.
* NIP-77 negentropy tuning. Defaults track strfry (`hoytech/strfry`)
* so a Geode relay accepts the same workload shape and exchanges
* the same NEG-MSG round-trip size:
*
* - [frame_size_limit] mirrors strfry's hard-coded
* `Negentropy ne(storage, 500'000)` in `RelayNegentropy.cpp`.
* Hex-encoded that's ~1 MB on the wire per NEG-MSG. Ktor's
* WebSocket layer does not impose a default frame cap, so this
* payload is delivered intact unless a reverse proxy is
* misconfigured.
* - [max_sync_events] mirrors strfry's
* `relay__negentropy__maxSyncEvents`. NEG-OPEN whose snapshot
* exceeds this returns
* `["NEG-ERR", "<subId>", "blocked: too many query results"]`.
* - [max_sessions_per_connection] caps concurrent NEG-OPEN
* sessions held by a single connection. strfry shares its
* 200-cap with REQ subs via `relay__maxSubsPerConnection`;
* Geode counts NEG independently for now (REQ has no cap yet).
* Overflow returns NOTICE
* `"too many concurrent NEG requests"` (matches strfry).
* - [frame_size_limit]: strfry's hard-coded `Negentropy ne(storage, 500'000)`.
* - [max_sync_events]: strfry's `relay__negentropy__maxSyncEvents`;
* NEG-OPEN over this returns `["NEG-ERR", "<subId>", "blocked: too many query results"]`.
* - [max_sessions_per_connection]: NEG sessions held by one connection;
* overflow returns NOTICE `"too many concurrent NEG requests"`.
*/
data class NegentropySection(
val frame_size_limit: Long = 500_000L,
@@ -196,30 +143,19 @@ data class StaticConfig(
)
/**
* NIP-86 relay management API. When [pubkeys] is non-empty,
* `KtorRelay` exposes a POST endpoint at the relay path
* that accepts JSON-RPC admin requests authenticated via NIP-98
* HTTP-Auth. Only requests signed by one of these pubkeys are
* dispatched.
*
* The NIP-98 URL binding compares the signed `u` tag against the
* `http(s)://` equivalent of the relay's WebSocket URL — i.e.
* `[info].relay_url` with the scheme swapped (per NIP-86's
* "same URI as `ws(s)://`, called via `http(s)://`"). Set
* `[info].relay_url` to the canonical public URL when running
* behind TLS termination or a reverse proxy.
* NIP-86 admin. [pubkeys] non-empty opens the POST endpoint at the
* relay path; only NIP-98 tokens signed by these pubkeys dispatch.
* The NIP-98 URL binding uses `[info].relay_url` with the scheme
* swapped (`ws(s)://` → `http(s)://`) per NIP-86 — set
* `[info].relay_url` to the canonical public URL when behind TLS
* termination or a reverse proxy.
*/
data class AdminSection(
val pubkeys: List<String> = emptyList(),
/**
* 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"`
* pairs with `[admin].state_file = "/var/lib/geode/events.db.admin.json"`.
* JSON snapshot path for [RuntimeConfig] (ban lists + live
* NIP-11 doc). Convention: place next to the SQLite event file,
* e.g. `events.db` ↔ `events.db.admin.json`.
*/
val state_file: String? = null,
)
@@ -227,24 +163,16 @@ data class StaticConfig(
companion object {
private val mapper = tomlMapper { }
/** Parse a TOML string. */
fun fromToml(toml: String): StaticConfig = mapper.decode<StaticConfig>(toml)
/** Load a TOML config file. */
fun fromFile(file: File): StaticConfig = mapper.decode<StaticConfig>(file.toPath())
/**
* Returns the URL the relay advertises in NIP-11 and NIP-42
* challenges. Picks (in order):
* 1. `info.relay_url` from the config
* 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`).
* URL the relay advertises in NIP-11 and NIP-42 challenges:
* `info.relay_url` if set, else built from `network.host:port/path`
* with `0.0.0.0` rewritten to `127.0.0.1`.
*/
fun advertisedUrl(config: StaticConfig): NormalizedRelayUrl =
(
config.info.relay_url
?: defaultUrl(config.network)
).normalizeRelayUrl()
fun advertisedUrl(config: StaticConfig): NormalizedRelayUrl = (config.info.relay_url ?: defaultUrl(config.network)).normalizeRelayUrl()
private fun defaultUrl(net: NetworkSection): String {
val host = if (net.host == "0.0.0.0") "127.0.0.1" else net.host
@@ -105,13 +105,7 @@ class StaticConfigTest {
supported_nips = [1, 11, 42]
""".trimIndent(),
)
val info =
c.resolveInfo(
"ws://127.0.0.1:7447/".let {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
.normalize(it)
},
)
val info = c.resolveInfo()
assertEquals(listOf("1", "11", "42"), info.document.supported_nips)
}