From e214143defaed16f531aca6a0c5b77bfe669da68 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 01:21:58 +0000 Subject: [PATCH] feat(relay): TOML config file (--config /path/to/relay.toml) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds operator-facing TOML configuration to :quartz-relay, with the section layout deliberately mirroring nostr-rs-relay's config.toml so existing operators can port across with little churn. Sections parsed AND enforced today: [info] — NIP-11 doc fields (name, description, contact, pubkey, software, supported_nips, …); replaces the previous hardcoded RelayInfo.default() [network] — host, port, path [database] — in_memory toggle + file path [options] — verify_signatures, require_auth (compose to the right IRelayPolicy stack) Sections parsed today but NOT YET ENFORCED (forward-compat for the upcoming rate-limit / authorization work — relay logs a warning when they're set): [limits] — max_event_bytes, messages_per_sec, … [authorization] — pubkey_whitelist/blacklist, kind_whitelist/blacklist [options].reject_future_seconds [network].remote_ip_header CLI flag precedence over the config file is preserved: --host, --port, --path, --info, --db, --auth, --verify all override the matching field. Adds: - cc.ekblad:4koma 1.2.0 for TOML parsing - quartz-relay/config.example.toml as the canonical operator reference - 5 unit tests (defaults, full parse, NIP-11 mapping, bundled example file, optional sections) --- gradle/libs.versions.toml | 2 + quartz-relay/build.gradle.kts | 5 + quartz-relay/config.example.toml | 66 +++++++ .../com/vitorpamplona/quartz/relay/Main.kt | 110 ++++++++--- .../quartz/relay/config/RelayConfig.kt | 174 ++++++++++++++++++ .../quartz/relay/config/RelayConfigTest.kt | 155 ++++++++++++++++ 6 files changed, 486 insertions(+), 26 deletions(-) create mode 100644 quartz-relay/config.example.toml create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfig.kt create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfigTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 08f9feda3..1429ebde0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -82,6 +82,7 @@ core = "1.7.0" mavenPublish = "0.36.0" sqlite = "2.6.2" ktor = "3.4.1" +fourkoma = "1.2.0" [libraries] abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" } @@ -179,6 +180,7 @@ okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" } ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" } ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" } +fourkoma = { module = "cc.ekblad:4koma", version.ref = "fourkoma" } secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } diff --git a/quartz-relay/build.gradle.kts b/quartz-relay/build.gradle.kts index 5c03df463..1c93fc035 100644 --- a/quartz-relay/build.gradle.kts +++ b/quartz-relay/build.gradle.kts @@ -42,6 +42,11 @@ dependencies { api(libs.ktor.server.cio) api(libs.ktor.server.websockets) + // TOML parsing for the operator config file. Mirrors the section + // layout of nostr-rs-relay's config.toml so existing operators can + // port their configs nearly verbatim. + implementation(libs.fourkoma) + testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.secp256k1.kmp.jni.jvm) diff --git a/quartz-relay/config.example.toml b/quartz-relay/config.example.toml new file mode 100644 index 000000000..0b357edc3 --- /dev/null +++ b/quartz-relay/config.example.toml @@ -0,0 +1,66 @@ +# Example config for quartz-relay. Section layout mirrors +# nostr-rs-relay's config.toml so existing operators can port across. +# +# Run with: +# ./gradlew :quartz-relay:run --args="--config /etc/quartz-relay.toml" +# +# CLI flags override individual values: e.g. `--port 8888` wins over +# `[network].port`. + +[info] +# The wss:// URL clients use to reach this relay (mandatory for NIP-42 +# AUTH challenges). If not set, the relay synthesises one from the +# [network] section. +relay_url = "wss://relay.example.com/" +name = "Example Quartz Relay" +description = "A quartz-relay deployment." +contact = "admin@example.com" +# Operator pubkey (NIP-11). Optional. +# pubkey = "..." +# Override the supported NIPs advertised on the NIP-11 endpoint. If +# omitted, the relay advertises the NIPs it actually implements. +# supported_nips = [1, 9, 11, 40, 42, 45, 50, 62] + +[network] +host = "0.0.0.0" +port = 7447 +path = "/" +# Set when behind a reverse proxy (nginx/Caddy/Cloudflare). Required +# before any IP-based rate limit means anything. Parsed today, enforced +# once rate limits land. +# remote_ip_header = "X-Forwarded-For" + +[database] +# True keeps an in-memory SQLite db (events vanish on restart). Useful +# for tests; set false + `file = "..."` for persistent storage. +in_memory = false +file = "/var/lib/quartz-relay/events.db" + +[options] +# Drop events whose Schnorr signature does not verify. Strongly +# recommended for any relay accepting traffic from real clients. +verify_signatures = true +# Require clients to NIP-42 AUTH before REQ/EVENT/COUNT. +require_auth = false +# Reject events whose `created_at` is more than this many seconds in the +# future. Parsed today, enforced once the matching policy lands. +# reject_future_seconds = 1800 + +# --- Sections below are parsed today but NOT YET ENFORCED. They are +# accepted for forward compatibility — the matching enforcement code is +# tracked separately. The relay logs a warning for each used section. --- + +[limits] +# max_event_bytes = 131072 +# max_ws_message_bytes = 1048576 +# max_ws_frame_bytes = 1048576 +# messages_per_sec = 10 +# subscriptions_per_min = 60 +# max_subscriptions_per_session = 32 +# max_filters_per_req = 10 + +[authorization] +# pubkey_whitelist = [] +# pubkey_blacklist = [] +# kind_whitelist = [] +# kind_blacklist = [] diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt index d9908dd4d..8f038d94e 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt @@ -27,40 +27,65 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.relay.config.RelayConfig import java.io.File /** - * Standalone entry point. Run with `./gradlew :quartz-relay:run` (when the - * application plugin is configured) or `java -cp ... Main`. + * Standalone entry point. * - * Usage: - * --host bind address (default 0.0.0.0) - * --port tcp port (default 7447, 0 to autobind) - * --path

ws path (default /) - * --info NIP-11 doc file (default: built-in) - * --db sqlite db path (default: in-memory) - * --auth require NIP-42 AUTH for REQ/EVENT/COUNT - * --verify verify event signatures (recommended for any - * relay accepting traffic from real clients) + * Run with: + * ./gradlew :quartz-relay:run --args="--config /etc/quartz-relay.toml" + * or + * java -cp ... com.vitorpamplona.quartz.relay.MainKt --port 7447 --verify + * + * Configuration precedence (highest to lowest): + * 1. CLI flags (`--host`, `--port`, …) + * 2. TOML file passed via `--config ` + * 3. Built-in defaults (host=0.0.0.0, port=7447, in-memory db, …) + * + * Sections currently parsed AND enforced: `[info]`, `[network]`, + * `[database]`, `[options]`. Sections parsed but not yet enforced + * (forward-compat for the rate-limit / authorization work): + * `[limits]`, `[authorization]`. + * + * CLI flags: + * --config TOML config (see config.example.toml) + * --host bind address (default from config or 0.0.0.0) + * --port tcp port (default from config or 7447, 0 to autobind) + * --path

ws path (default from config or /) + * --info NIP-11 doc file (overrides [info] section) + * --db sqlite db path (overrides [database].file) + * --auth require NIP-42 AUTH (sets options.require_auth = true) + * --verify verify event signatures (sets options.verify_signatures = true) */ fun main(args: Array) { val a = parseArgs(args) - val host = a.opt("--host") ?: "0.0.0.0" - val port = a.opt("--port")?.toInt() ?: 7447 - val path = a.opt("--path") ?: "/" - val infoFile = a.opt("--info")?.let { File(it) } - val dbFile = a.opt("--db") - val requireAuth = a.flag("--auth") - val verifySigs = a.flag("--verify") - val urlStr = "ws://$host:$port$path" - // For binding 0.0.0.0 we still want to scope the relay to a "public" url - // shape for NIP-42 challenge validation; use the host the operator - // exposes (--info usually carries the public URL). Fall back to - // 127.0.0.1 so localhost smoke tests work. - val advertisedUrl = (if (host == "0.0.0.0") "ws://127.0.0.1:$port$path" else urlStr).normalizeRelayUrl() + val config: RelayConfig = + a + .opt("--config") + ?.let { RelayConfig.fromFile(File(it)) } + ?: RelayConfig() - val info = infoFile?.let { RelayInfo.fromFile(it) } ?: RelayInfo.default(advertisedUrl) + val host = a.opt("--host") ?: config.network.host + val port = a.opt("--port")?.toInt() ?: config.network.port + val path = a.opt("--path") ?: config.network.path + + val cliInfoFile = a.opt("--info")?.let { File(it) } + val dbFile = a.opt("--db") ?: config.database.file?.takeUnless { config.database.in_memory } + val requireAuth = a.flag("--auth") || config.options.require_auth + val verifySigs = a.flag("--verify") || config.options.verify_signatures + + // Advertised URL: explicit `info.relay_url` wins, then build from + // host/port/path. 0.0.0.0 bind → 127.0.0.1 in the URL so NIP-42 + // challenges are well-formed. + val advertisedHost = if (host == "0.0.0.0") "127.0.0.1" else host + val advertisedUrl = + (config.info.relay_url ?: "ws://$advertisedHost:$port$path").normalizeRelayUrl() + + val info = + cliInfoFile?.let { RelayInfo.fromFile(it) } + ?: config.resolveInfo(advertisedUrl) val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl) @@ -72,6 +97,8 @@ fun main(args: Array) { else -> { -> EmptyPolicy } } + warnUnenforcedSections(config) + val relay = Relay(advertisedUrl, store, info, policyBuilder) val server = LocalRelayServer(relay, host = host, port = port, path = path).start() @@ -83,12 +110,43 @@ fun main(args: Array) { ) println("quartz-relay listening on ${server.url}") - println("NIP-11 info doc: curl -H 'Accept: application/nostr+json' http://$host:$port$path") + println("NIP-11 info doc: curl -H 'Accept: application/nostr+json' http://$advertisedHost:$port$path") // Park the main thread; shutdown hook handles teardown. Thread.currentThread().join() } +/** Surface a warning when the operator has set sections we don't yet enforce. */ +private fun warnUnenforcedSections(config: RelayConfig) { + val warnings = mutableListOf() + val l = config.limits + if (l.max_event_bytes != null || + l.max_ws_message_bytes != null || + l.max_ws_frame_bytes != null || + l.messages_per_sec != null || + l.subscriptions_per_min != null || + l.max_subscriptions_per_session != null || + l.max_filters_per_req != null + ) { + warnings += "[limits] section is parsed but NOT YET ENFORCED — rate limits / message size caps are pending." + } + val auth = config.authorization + if (auth.pubkey_whitelist.isNotEmpty() || + auth.pubkey_blacklist.isNotEmpty() || + auth.kind_whitelist.isNotEmpty() || + auth.kind_blacklist.isNotEmpty() + ) { + warnings += "[authorization] section is parsed but NOT YET ENFORCED — pubkey/kind allow-deny lists are pending." + } + if (config.options.reject_future_seconds != null) { + warnings += "[options].reject_future_seconds is parsed but NOT YET ENFORCED." + } + if (config.network.remote_ip_header != null) { + warnings += "[network].remote_ip_header is parsed but NOT YET ENFORCED — IP-based limits are pending." + } + warnings.forEach { System.err.println("warning: $it") } +} + private class Args( private val opts: Map, private val flags: Set, diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfig.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfig.kt new file mode 100644 index 000000000..fb31fa071 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfig.kt @@ -0,0 +1,174 @@ +/* + * 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.quartz.relay.config + +import cc.ekblad.toml.decode +import cc.ekblad.toml.tomlMapper +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.relay.RelayInfo +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. + * + * Every section is optional; values not set fall back to sensible + * defaults (or, for fields also exposed on the CLI, the CLI value wins). + * + * Sections that are parsed but **not yet enforced** by the relay are + * marked below; they're accepted so configs remain forward-compatible + * once the matching policy is implemented (rate limits, NIP-05, etc.). + */ +data class RelayConfig( + val info: InfoSection = InfoSection(), + val network: NetworkSection = NetworkSection(), + val database: DatabaseSection = DatabaseSection(), + val options: OptionsSection = OptionsSection(), + /** Parsed but not yet enforced. */ + val limits: LimitsSection = LimitsSection(), + /** Parsed but not yet enforced. */ + val authorization: AuthorizationSection = AuthorizationSection(), +) { + /** + * 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 = + RelayInfo( + Nip11RelayInformation( + name = info.name ?: "quartz-relay", + description = info.description ?: "Embedded Nostr relay from the Amethyst quartz library.", + pubkey = info.pubkey, + contact = info.contact, + icon = info.icon, + software = + info.software + ?: "https://github.com/vitorpamplona/amethyst/tree/main/quartz-relay", + version = info.version ?: "1.08.0", + supported_nips = + info.supported_nips?.map(Int::toString) + ?: listOf("1", "9", "11", "40", "42", "45", "50", "62"), + privacy_policy = info.privacy_policy, + terms_of_service = info.terms_of_service, + relay_countries = info.relay_countries, + 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, + val name: String? = null, + val description: String? = null, + val pubkey: String? = null, + val contact: String? = null, + 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? = null, + val privacy_policy: String? = null, + val terms_of_service: String? = null, + val relay_countries: List? = null, + val language_tags: List? = null, + val tags: List? = null, + ) + + data class NetworkSection( + val host: String = "0.0.0.0", + val port: Int = 7447, + val path: String = "/", + /** + * When set, the relay reads the client IP from this header + * (typically `X-Forwarded-For` behind a reverse proxy). Required + * once IP-based rate limits land. + */ + val remote_ip_header: String? = 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. */ + val verify_signatures: Boolean = false, + ) + + data class LimitsSection( + val max_event_bytes: Int? = null, + val max_ws_message_bytes: Int? = null, + val max_ws_frame_bytes: Int? = null, + val messages_per_sec: Int? = null, + val subscriptions_per_min: Int? = null, + val max_subscriptions_per_session: Int? = null, + val max_filters_per_req: Int? = null, + ) + + data class AuthorizationSection( + val pubkey_whitelist: List = emptyList(), + val pubkey_blacklist: List = emptyList(), + val kind_whitelist: List = emptyList(), + val kind_blacklist: List = emptyList(), + ) + + companion object { + private val mapper = tomlMapper { } + + /** Parse a TOML string. */ + fun fromToml(toml: String): RelayConfig = mapper.decode(toml) + + /** Load a TOML config file. */ + fun fromFile(file: File): RelayConfig = mapper.decode(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`). + */ + fun advertisedUrl(config: RelayConfig): 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 + return "ws://$host:${net.port}${net.path}" + } + } +} diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfigTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfigTest.kt new file mode 100644 index 000000000..071f371af --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfigTest.kt @@ -0,0 +1,155 @@ +/* + * 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.quartz.relay.config + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class RelayConfigTest { + @Test + fun emptyTomlYieldsAllDefaults() { + val c = RelayConfig.fromToml("") + assertEquals("0.0.0.0", c.network.host) + assertEquals(7447, c.network.port) + assertEquals("/", c.network.path) + assertEquals(true, c.database.in_memory) + assertEquals(false, c.options.require_auth) + assertEquals(false, c.options.verify_signatures) + assertTrue(c.authorization.pubkey_whitelist.isEmpty()) + } + + @Test + fun parsesAllSectionsTogether() { + val toml = + """ + [info] + relay_url = "wss://relay.example.com/" + name = "Example" + contact = "ops@example.com" + supported_nips = [1, 9, 11, 42] + + [network] + host = "127.0.0.1" + port = 9988 + path = "/relay" + remote_ip_header = "X-Forwarded-For" + + [database] + in_memory = false + file = "/var/lib/quartz-relay/events.db" + + [options] + verify_signatures = true + require_auth = true + reject_future_seconds = 1800 + + [limits] + max_event_bytes = 131072 + messages_per_sec = 10 + max_filters_per_req = 12 + + [authorization] + pubkey_blacklist = ["aaaa", "bbbb"] + kind_blacklist = [4, 1059] + """.trimIndent() + + val c = RelayConfig.fromToml(toml) + + assertEquals("wss://relay.example.com/", c.info.relay_url) + assertEquals("Example", c.info.name) + assertEquals(listOf(1, 9, 11, 42), c.info.supported_nips) + + assertEquals("127.0.0.1", c.network.host) + assertEquals(9988, c.network.port) + assertEquals("/relay", c.network.path) + assertEquals("X-Forwarded-For", c.network.remote_ip_header) + + assertEquals(false, c.database.in_memory) + assertEquals("/var/lib/quartz-relay/events.db", c.database.file) + + assertEquals(true, c.options.verify_signatures) + assertEquals(true, c.options.require_auth) + assertEquals(1800, c.options.reject_future_seconds) + + assertEquals(131072, c.limits.max_event_bytes) + assertEquals(10, c.limits.messages_per_sec) + assertEquals(12, c.limits.max_filters_per_req) + + assertEquals(listOf("aaaa", "bbbb"), c.authorization.pubkey_blacklist) + assertEquals(listOf(4, 1059), c.authorization.kind_blacklist) + } + + @Test + fun supportedNipsRenderedAsStringsInNip11Doc() { + val c = + RelayConfig.fromToml( + """ + [info] + 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) + }, + ) + assertEquals(listOf("1", "11", "42"), info.document.supported_nips) + } + + @Test + fun loadsTheBundledExampleConfigCleanly() { + // The example file lives at the module root so operators have a + // canonical reference. Read it via a relative path resolved + // against the working directory (gradle runs tests from the + // module dir). + val candidates = + listOf( + File("config.example.toml"), + File("quartz-relay/config.example.toml"), + ) + val example = + candidates.firstOrNull { it.exists() } + ?: error( + "config.example.toml not found in any of: ${candidates.joinToString { it.absolutePath }}", + ) + + val c = RelayConfig.fromFile(example) + + assertEquals("wss://relay.example.com/", c.info.relay_url) + assertEquals(true, c.options.verify_signatures) + assertEquals(false, c.database.in_memory) + assertNotNull(c.database.file) + } + + @Test + fun missingSectionsAreOptional() { + val c = RelayConfig.fromToml("[info]\nname = \"only-info\"") + assertEquals("only-info", c.info.name) + // Defaults preserved for unspecified sections. + assertEquals(7447, c.network.port) + assertEquals(true, c.database.in_memory) + } +}