feat(relay): TOML config file (--config /path/to/relay.toml)

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)
This commit is contained in:
Claude
2026-05-07 01:21:58 +00:00
parent 58f6a0af6b
commit e214143def
6 changed files with 486 additions and 26 deletions
+2
View File
@@ -82,6 +82,7 @@ core = "1.7.0"
mavenPublish = "0.36.0" mavenPublish = "0.36.0"
sqlite = "2.6.2" sqlite = "2.6.2"
ktor = "3.4.1" ktor = "3.4.1"
fourkoma = "1.2.0"
[libraries] [libraries]
abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" } 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-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-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", 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-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-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" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
+5
View File
@@ -42,6 +42,11 @@ dependencies {
api(libs.ktor.server.cio) api(libs.ktor.server.cio)
api(libs.ktor.server.websockets) 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.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm) testImplementation(libs.secp256k1.kmp.jni.jvm)
+66
View File
@@ -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 = []
@@ -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.relay.server.policies.VerifyPolicy
import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.relay.config.RelayConfig
import java.io.File import java.io.File
/** /**
* Standalone entry point. Run with `./gradlew :quartz-relay:run` (when the * Standalone entry point.
* application plugin is configured) or `java -cp ... Main`.
* *
* Usage: * Run with:
* --host <addr> bind address (default 0.0.0.0) * ./gradlew :quartz-relay:run --args="--config /etc/quartz-relay.toml"
* --port <n> tcp port (default 7447, 0 to autobind) * or
* --path <p> ws path (default /) * java -cp ... com.vitorpamplona.quartz.relay.MainKt --port 7447 --verify
* --info <file> NIP-11 doc file (default: built-in) *
* --db <file> sqlite db path (default: in-memory) * Configuration precedence (highest to lowest):
* --auth require NIP-42 AUTH for REQ/EVENT/COUNT * 1. CLI flags (`--host`, `--port`, …)
* --verify verify event signatures (recommended for any * 2. TOML file passed via `--config <path>`
* relay accepting traffic from real clients) * 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 <file> TOML config (see config.example.toml)
* --host <addr> bind address (default from config or 0.0.0.0)
* --port <n> tcp port (default from config or 7447, 0 to autobind)
* --path <p> ws path (default from config or /)
* --info <file> NIP-11 doc file (overrides [info] section)
* --db <file> 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<String>) { fun main(args: Array<String>) {
val a = parseArgs(args) 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" val config: RelayConfig =
// For binding 0.0.0.0 we still want to scope the relay to a "public" url a
// shape for NIP-42 challenge validation; use the host the operator .opt("--config")
// exposes (--info usually carries the public URL). Fall back to ?.let { RelayConfig.fromFile(File(it)) }
// 127.0.0.1 so localhost smoke tests work. ?: RelayConfig()
val advertisedUrl = (if (host == "0.0.0.0") "ws://127.0.0.1:$port$path" else urlStr).normalizeRelayUrl()
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) val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl)
@@ -72,6 +97,8 @@ fun main(args: Array<String>) {
else -> { -> EmptyPolicy } else -> { -> EmptyPolicy }
} }
warnUnenforcedSections(config)
val relay = Relay(advertisedUrl, store, info, policyBuilder) val relay = Relay(advertisedUrl, store, info, policyBuilder)
val server = LocalRelayServer(relay, host = host, port = port, path = path).start() val server = LocalRelayServer(relay, host = host, port = port, path = path).start()
@@ -83,12 +110,43 @@ fun main(args: Array<String>) {
) )
println("quartz-relay listening on ${server.url}") 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. // Park the main thread; shutdown hook handles teardown.
Thread.currentThread().join() 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<String>()
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 class Args(
private val opts: Map<String, String>, private val opts: Map<String, String>,
private val flags: Set<String>, private val flags: Set<String>,
@@ -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<Int>? = null,
val privacy_policy: String? = null,
val terms_of_service: String? = null,
val relay_countries: List<String>? = null,
val language_tags: List<String>? = null,
val tags: List<String>? = 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<String> = emptyList(),
val pubkey_blacklist: List<String> = emptyList(),
val kind_whitelist: List<Int> = emptyList(),
val kind_blacklist: List<Int> = emptyList(),
)
companion object {
private val mapper = tomlMapper { }
/** Parse a TOML string. */
fun fromToml(toml: String): RelayConfig = mapper.decode<RelayConfig>(toml)
/** Load a TOML config file. */
fun fromFile(file: File): RelayConfig = mapper.decode<RelayConfig>(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}"
}
}
}
@@ -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)
}
}