From aefecf71d88c813fcc04fe9a7377e90a9dfe1643 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 00:41:02 +0000 Subject: [PATCH 01/17] test(quartz): add :quartz-test-relay for in-process Nostr relay testing Replaces production-relay (wss://nos.lol, wss://nostr.bitcoiner.social) dependencies in 10 quartz JVM relay tests with a deterministic in-process Nostr relay built on top of the existing NostrServer + EventStore, plus a NIP-01 compliance suite that exercises the bridge end-to-end through NostrClient. The new :quartz-test-relay module exposes: - TestRelay / TestRelayHub: NostrServer + in-memory EventStore per URL, registry implements WebsocketBuilder so tests can drop it into NostrClient in place of BasicOkHttpWebSocket.Builder. - InProcessWebSocket: bridges the WebSocket abstraction to RelaySession with a single-coroutine drain to preserve message ordering. - SyntheticEvents / RelayFixtures: deterministic event generators and a loader for the existing nostr_vitor_*.json corpora. Found and fixed three NIP-01/NIP-45 wire-format bugs in the relay serializers that prevented round-trip through the in-tree NostrClient: - OkMessage wrote success as a JSON string instead of a boolean, causing publishAndConfirm to hang. - CountMessage dropped the queryId, breaking COUNT response routing. - CountResult used the field name "pubkey" instead of "approximate". Both Jackson and kotlinx-serialization paths were affected; both fixed to match NIP-01/NIP-45 wire formats. --- quartz-test-relay/build.gradle.kts | 35 ++ .../quartz/testrelay/InProcessWebSocket.kt | 79 ++++ .../quartz/testrelay/RelayFixtures.kt | 71 +++ .../quartz/testrelay/SyntheticEvents.kt | 66 +++ .../quartz/testrelay/TestRelay.kt | 79 ++++ .../quartz/testrelay/TestRelayHub.kt | 76 ++++ .../quartz/testrelay/Nip01ComplianceTest.kt | 409 ++++++++++++++++++ quartz/build.gradle.kts | 4 + .../CountResultKSerializer.kt | 6 +- .../kotlinSerialization/MessageKSerializer.kt | 10 +- .../relay/server/NostrServerAuthTest.kt | 24 +- .../nip01Core/relay/server/NostrServerTest.kt | 6 +- .../toClient/CountResultSerializer.kt | 5 +- .../commands/toClient/MessageSerializer.kt | 10 +- .../nip01Core/relay/BaseNostrClientTest.kt | 44 +- .../relay/NostrClientFirstEventTest.kt | 24 +- .../relay/NostrClientManualSubTest.kt | 9 +- .../relay/NostrClientQueryCountTest.kt | 54 ++- .../relay/NostrClientRepeatSubTest.kt | 28 +- .../NostrClientReqBypassingRelayLimitsTest.kt | 58 ++- .../relay/NostrClientSendAndWaitTest.kt | 16 +- .../NostrClientSubscriptionAsFlowTest.kt | 13 +- .../relay/NostrClientSubscriptionTest.kt | 7 +- ...trClientSubscriptionUntilEoseAsFlowTest.kt | 13 +- settings.gradle | 1 + 25 files changed, 1041 insertions(+), 106 deletions(-) create mode 100644 quartz-test-relay/build.gradle.kts create mode 100644 quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/InProcessWebSocket.kt create mode 100644 quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/RelayFixtures.kt create mode 100644 quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/SyntheticEvents.kt create mode 100644 quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelay.kt create mode 100644 quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelayHub.kt create mode 100644 quartz-test-relay/src/test/kotlin/com/vitorpamplona/quartz/testrelay/Nip01ComplianceTest.kt diff --git a/quartz-test-relay/build.gradle.kts b/quartz-test-relay/build.gradle.kts new file mode 100644 index 0000000000..3c87e3f999 --- /dev/null +++ b/quartz-test-relay/build.gradle.kts @@ -0,0 +1,35 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.jetbrainsKotlinJvm) +} + +kotlin { + jvmToolchain(21) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } +} + +sourceSets { + main { + kotlin.srcDir("src/main/kotlin") + } + test { + kotlin.srcDir("src/test/kotlin") + } +} + +dependencies { + api(project(":quartz")) + + implementation(libs.kotlinx.coroutines.core) + implementation(libs.jackson.module.kotlin) + + // Bundled SQLite driver — EventStore(null) creates an in-memory DB at runtime. + implementation(libs.androidx.sqlite.bundled.jvm) + + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.secp256k1.kmp.jni.jvm) +} diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/InProcessWebSocket.kt b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/InProcessWebSocket.kt new file mode 100644 index 0000000000..b487ce46f2 --- /dev/null +++ b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/InProcessWebSocket.kt @@ -0,0 +1,79 @@ +/* + * 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.testrelay + +import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.launch + +/** + * In-memory implementation of [WebSocket] that talks to a [TestRelay] without + * touching the network. Each instance opens one [RelaySession] on + * [connect] and routes: + * + * - Outbound (`send`) → server `RelaySession.receive()` via an inbound channel + * drained by a single coroutine, preserving message order per the + * [WebSocketListener] contract. + * - Server-side `send` callbacks → [WebSocketListener.onMessage]. + */ +class InProcessWebSocket( + private val relay: TestRelay, + private val out: WebSocketListener, +) : WebSocket { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val incoming = Channel(UNLIMITED) + private var session: RelaySession? = null + + override fun needsReconnect(): Boolean = session == null + + override fun connect() { + if (session != null) return + val s = relay.server.connect { json -> out.onMessage(json) } + session = s + out.onOpen(0, false) + scope.launch { + for (msg in incoming) { + s.receive(msg) + } + } + } + + override fun disconnect() { + val s = session ?: return + session = null + incoming.close() + scope.cancel() + s.close() + out.onClosed(1000, "client disconnect") + } + + override fun send(msg: String): Boolean { + if (session == null) return false + return incoming.trySend(msg).isSuccess + } +} diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/RelayFixtures.kt b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/RelayFixtures.kt new file mode 100644 index 0000000000..6fc5103856 --- /dev/null +++ b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/RelayFixtures.kt @@ -0,0 +1,71 @@ +/* + * 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.testrelay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import java.util.zip.GZIPInputStream + +/** + * Loaders for test event corpora bundled in `quartz/src/commonTest/resources/`. + * Lookup order: + * 1. The `TEST_RESOURCES_ROOT` environment variable (set by quartz's Gradle + * config to the absolute path of `commonTest/resources`). + * 2. The classpath, for callers that copy fixtures into their own + * `src/test/resources`. + */ +object RelayFixtures { + /** Reads a fixture file as a UTF-8 string. */ + fun loadString(name: String): String { + val envRoot = System.getenv("TEST_RESOURCES_ROOT") + if (envRoot != null) { + val file = java.io.File(envRoot, name) + if (file.exists()) return file.readText() + } + val cp = RelayFixtures::class.java.classLoader?.getResourceAsStream(name) + if (cp != null) return cp.bufferedReader().use { it.readText() } + throw IllegalArgumentException( + "Fixture not found: $name. Set TEST_RESOURCES_ROOT or place on classpath.", + ) + } + + /** Reads a gzipped fixture file as a UTF-8 string. */ + fun loadGzipString(name: String): String { + val envRoot = System.getenv("TEST_RESOURCES_ROOT") + if (envRoot != null) { + val file = java.io.File(envRoot, name) + if (file.exists()) { + return GZIPInputStream(file.inputStream()).bufferedReader().use { it.readText() } + } + } + val cp = RelayFixtures::class.java.classLoader?.getResourceAsStream(name) + if (cp != null) return GZIPInputStream(cp).bufferedReader().use { it.readText() } + throw IllegalArgumentException( + "Fixture not found: $name. Set TEST_RESOURCES_ROOT or place on classpath.", + ) + } + + /** Loads `nostr_vitor_short.json` — the small handcrafted Vitor corpus. */ + fun vitorShort(): List = OptimizedJsonMapper.fromJsonToEventList(loadString("nostr_vitor_short.json")) + + /** Loads `nostr_vitor_startup_data.json.gz` — the larger Vitor startup corpus. */ + fun vitorStartup(): List = OptimizedJsonMapper.fromJsonToEventList(loadGzipString("nostr_vitor_startup_data.json")) +} diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/SyntheticEvents.kt b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/SyntheticEvents.kt new file mode 100644 index 0000000000..640dfa4b12 --- /dev/null +++ b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/SyntheticEvents.kt @@ -0,0 +1,66 @@ +/* + * 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.testrelay + +import com.vitorpamplona.quartz.nip01Core.core.Event + +/** + * Generators for cheap, deterministic, structurally valid Nostr events. + * + * The signatures and ids produced here are *not* cryptographically valid — + * the in-process relay's default [com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy] + * doesn't verify them, and neither does the underlying SQLite event store. + * Use these when a test only needs to exercise relay logic (filter matching, + * limits, EOSE, live updates) and not the cryptographic layer. + */ +object SyntheticEvents { + /** A pubkey/sig pair that's syntactically valid (64/128 hex chars) but signs nothing. */ + private val DEFAULT_PUBKEY = "0".repeat(64) + private val FAKE_SIG = "0".repeat(128) + + /** Hex padding to 64 chars so deterministic ids look like real event ids. */ + fun hexId(seed: Int): String = seed.toString().padStart(64, '0') + + fun fakeEvent( + idSeed: Int, + kind: Int = 1, + pubKey: String = DEFAULT_PUBKEY, + createdAt: Long = idSeed.toLong(), + content: String = "", + tags: Array> = emptyArray(), + ): Event = Event(hexId(idSeed), pubKey, createdAt, kind, tags, content, FAKE_SIG) + + /** + * Returns [count] events of [kind] with monotonic [createdAt] starting at 1 + * and a *distinct* pubkey per event. Distinct pubkeys are essential for + * replaceable kinds (0, 3, 10000-19999): without them, the relay collapses + * the whole batch to one row per (kind, pubkey). + */ + fun batch( + count: Int, + kind: Int = 1, + pubKeyOf: (Int) -> String = { hexId(1_000_000 + it) }, + ): List = + List(count) { i -> + val seed = i + 1 + fakeEvent(idSeed = seed, kind = kind, pubKey = pubKeyOf(seed), createdAt = seed.toLong()) + } +} diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelay.kt b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelay.kt new file mode 100644 index 0000000000..ccd251457b --- /dev/null +++ b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelay.kt @@ -0,0 +1,79 @@ +/* + * 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.testrelay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.CoroutineContext + +/** + * A self-contained, in-memory Nostr relay scoped to a single URL. Wraps a + * [NostrServer] over an [EventStore] backed by an in-memory SQLite database. + * + * Use [TestRelayHub] to register relays under URLs the production + * [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] can subscribe to. + */ +class TestRelay( + val url: NormalizedRelayUrl, + val store: IEventStore = EventStore(dbName = null, relay = url), + policyBuilder: () -> IRelayPolicy = { EmptyPolicy }, + parentContext: CoroutineContext = SupervisorJob(), +) : AutoCloseable { + val server = NostrServer(store, policyBuilder, parentContext) + + /** + * Inserts events directly into the underlying store, bypassing the wire protocol. + * + * Use this for **pre-test setup** — events that exist before any client connects. + * It does NOT broadcast to active subscriptions. For sending events that should + * fan out to live subscribers (post-EOSE), use [publish] instead. + */ + suspend fun preload(events: Iterable) { + events.forEach { store.insert(it) } + } + + /** @see preload(Iterable) */ + suspend fun preload(vararg events: Event) = preload(events.toList()) + + /** + * Publishes an event through the relay's session machinery so it both lands + * in the store and fans out to active subscriptions matching its filters + * (mirrors what a real client would do via an `EVENT` command). + */ + suspend fun publish(event: Event) { + val session = server.connect { /* ignore OK echo */ } + try { + session.receive(OptimizedJsonMapper.toJson(EventCmd(event))) + } finally { + session.close() + } + } + + override fun close() = server.close() +} diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelayHub.kt b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelayHub.kt new file mode 100644 index 0000000000..45a3fab10f --- /dev/null +++ b/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelayHub.kt @@ -0,0 +1,76 @@ +/* + * 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.testrelay + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import java.util.concurrent.ConcurrentHashMap + +/** + * Registry of [TestRelay] instances keyed by relay URL. Implements + * [WebsocketBuilder] so it can be plugged into + * [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] in place of + * `BasicOkHttpWebSocket.Builder` to redirect every outbound connection to an + * in-memory relay. + * + * Usage: + * ``` + * val hub = TestRelayHub() + * val relay = hub.getOrCreate("ws://test.relay/") + * runBlocking { relay.preload(listOf(event1, event2)) } + * val client = NostrClient(hub, scope) + * ``` + * + * Unknown URLs auto-create an empty relay so a single hub can transparently + * back any number of test endpoints. + */ +class TestRelayHub( + private val defaultPolicy: () -> IRelayPolicy = { EmptyPolicy }, +) : WebsocketBuilder, + AutoCloseable { + private val relays = ConcurrentHashMap() + + fun getOrCreate(url: NormalizedRelayUrl): TestRelay = + relays.getOrPut(url) { + TestRelay(url = url, policyBuilder = defaultPolicy) + } + + fun getOrCreate(url: String): TestRelay = getOrCreate(RelayUrlNormalizer.normalize(url)) + + fun get(url: NormalizedRelayUrl): TestRelay? = relays[url] + + fun urls(): Set = relays.keys.toSet() + + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ): WebSocket = InProcessWebSocket(getOrCreate(url), out) + + override fun close() { + relays.values.forEach { it.close() } + relays.clear() + } +} diff --git a/quartz-test-relay/src/test/kotlin/com/vitorpamplona/quartz/testrelay/Nip01ComplianceTest.kt b/quartz-test-relay/src/test/kotlin/com/vitorpamplona/quartz/testrelay/Nip01ComplianceTest.kt new file mode 100644 index 0000000000..2fc3487f6d --- /dev/null +++ b/quartz-test-relay/src/test/kotlin/com/vitorpamplona/quartz/testrelay/Nip01ComplianceTest.kt @@ -0,0 +1,409 @@ +/* + * 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.testrelay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Compatibility suite that drives the in-process relay through the same + * `NostrClient` + WebSocket abstraction production code uses. These tests + * are how we validate that: + * + * 1. The relay implements NIP-01 correctly (REQ/EVENT/EOSE/CLOSE/COUNT, + * replaceable + parameterized-replaceable, filter matching, multi-relay + * pools). + * 2. The bridge between [com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket] + * and [com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer] preserves + * wire ordering and lifecycle. + * + * The same suite should be runnable, conceptually, against any + * spec-compliant relay (nostr-rs-relay, strfry, khatru, …) — only the + * `socketBuilder` and the relay URL would change. + */ +class Nip01ComplianceTest { + private lateinit var hub: TestRelayHub + private lateinit var scope: CoroutineScope + private lateinit var client: NostrClient + + private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") + + @BeforeTest + fun setup() { + hub = TestRelayHub() + scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + client = NostrClient(hub, scope) + } + + @AfterTest + fun teardown() { + client.disconnect() + scope.cancel() + hub.close() + } + + private suspend fun preload(vararg events: Event) { + hub.getOrCreate(relayUrl).preload(*events) + } + + private fun fakeEvent( + idSeed: Int, + kind: Int = 1, + pubKey: String = SyntheticEvents.hexId(0), + createdAt: Long = idSeed.toLong(), + tags: Array> = emptyArray(), + content: String = "", + ) = SyntheticEvents.fakeEvent(idSeed, kind, pubKey, createdAt, content, tags) + + // -- Subscriptions ------------------------------------------------------- + + /** REQ returns events matching kinds, then EOSE, in order. */ + @Test + fun reqByKindReturnsMatchesThenEose() = + runBlocking { + preload( + fakeEvent(1, kind = 1), + fakeEvent(2, kind = 4), + fakeEvent(3, kind = 1), + ) + + val (events, eose) = collectUntilEose(Filter(kinds = listOf(1))) + + assertEquals(2, events.size) + assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet()) + assertTrue(eose, "EOSE should fire after stored events") + } + + /** REQ honours `limit` — newest first, capped to limit. */ + @Test + fun reqRespectsLimitAndOrdersNewestFirst() = + runBlocking { + preload( + fakeEvent(1, kind = 1, createdAt = 100), + fakeEvent(2, kind = 1, createdAt = 200), + fakeEvent(3, kind = 1, createdAt = 300), + fakeEvent(4, kind = 1, createdAt = 400), + ) + + val (events, _) = collectUntilEose(Filter(kinds = listOf(1), limit = 2)) + + assertEquals(2, events.size) + assertEquals(400L, events[0].createdAt) + assertEquals(300L, events[1].createdAt) + } + + /** REQ with `authors` filters by pubkey. */ + @Test + fun reqFiltersByAuthors() = + runBlocking { + val alice = SyntheticEvents.hexId(101) + val bob = SyntheticEvents.hexId(102) + preload( + fakeEvent(1, kind = 1, pubKey = alice), + fakeEvent(2, kind = 1, pubKey = bob), + fakeEvent(3, kind = 1, pubKey = alice), + ) + + val (events, _) = collectUntilEose(Filter(authors = listOf(alice))) + + assertEquals(2, events.size) + assertTrue(events.all { it.pubKey == alice }) + } + + /** REQ with `ids` returns only the requested events. */ + @Test + fun reqFiltersByIds() = + runBlocking { + preload(fakeEvent(1), fakeEvent(2), fakeEvent(3)) + + val (events, _) = collectUntilEose(Filter(ids = listOf(SyntheticEvents.hexId(2)))) + + assertEquals(1, events.size) + assertEquals(SyntheticEvents.hexId(2), events[0].id) + } + + /** REQ with `since`/`until` filters on createdAt. */ + @Test + fun reqFiltersBySinceAndUntil() = + runBlocking { + preload( + fakeEvent(1, createdAt = 100), + fakeEvent(2, createdAt = 200), + fakeEvent(3, createdAt = 300), + fakeEvent(4, createdAt = 400), + ) + + val (events, _) = collectUntilEose(Filter(since = 150L, until = 350L)) + + assertEquals(setOf(200L, 300L), events.map { it.createdAt }.toSet()) + } + + /** REQ with single-letter `#e` tag filter matches events whose tag values intersect. */ + @Test + fun reqFiltersByETag() = + runBlocking { + val target = SyntheticEvents.hexId(999) + preload( + fakeEvent(1, tags = arrayOf(arrayOf("e", target))), + fakeEvent(2, tags = arrayOf(arrayOf("e", SyntheticEvents.hexId(7)))), + fakeEvent(3, tags = arrayOf(arrayOf("e", target), arrayOf("p", SyntheticEvents.hexId(8)))), + ) + + val (events, _) = collectUntilEose(Filter(tags = mapOf("e" to listOf(target)))) + + assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet()) + } + + // -- Replaceable + addressable ------------------------------------------ + + /** Kind 0 is replaceable by `(pubkey, kind)` — newer wins. */ + @Test + fun replaceableEventsKeepNewestPerPubkey() = + runBlocking { + val pubkey = SyntheticEvents.hexId(50) + preload( + fakeEvent(1, kind = 0, pubKey = pubkey, createdAt = 100, content = "old"), + fakeEvent(2, kind = 0, pubKey = pubkey, createdAt = 200, content = "new"), + ) + + val (events, _) = collectUntilEose(Filter(kinds = listOf(0), authors = listOf(pubkey))) + + assertEquals(1, events.size) + assertEquals("new", events[0].content) + } + + /** + * Addressable events (kind 30000-39999, NIP-01 §"Kinds") are replaced + * by `(pubkey, kind, d)`. Uses a real signed [LongTextNoteEvent] because + * the SQLite store dispatches on the typed `AddressableEvent` subclass + * to extract the d-tag — synthetic plain `Event`s aren't recognised. + */ + @Test + fun parameterizedReplaceableEventsKeepNewestPerDTag() = + runBlocking { + val signer = NostrSignerSync(KeyPair()) + val v1 = signer.sign(LongTextNoteEvent.build("old", "title", dTag = "list-a", createdAt = 100)) + val v2 = signer.sign(LongTextNoteEvent.build("new", "title", dTag = "list-a", createdAt = 200)) + val v3 = signer.sign(LongTextNoteEvent.build("list-b", "title", dTag = "list-b", createdAt = 100)) + preload(v1, v2, v3) + + val (events, _) = collectUntilEose(Filter(kinds = listOf(LongTextNoteEvent.KIND), authors = listOf(signer.pubKey))) + + assertEquals(2, events.size) + assertEquals(setOf("new", "list-b"), events.map { it.content }.toSet()) + } + + // -- Live updates -------------------------------------------------------- + + /** A subscription receives new matching events that arrive after EOSE. */ + @Test + fun liveSubscriptionReceivesPostEoseEvents() = + runBlocking { + val ch = Channel(UNLIMITED) + val gotEose = Channel(UNLIMITED) + client.subscribe( + "live-1", + mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + gotEose.trySend(Unit) + } + }, + ) + + withTimeout(5000) { gotEose.receive() } + + // Inject an event through the wire path (not preload — that bypasses + // the live broadcast that subscriptions feed off of). + hub.getOrCreate(relayUrl).publish(fakeEvent(99, kind = 1, content = "live")) + + val received = withTimeout(5000) { ch.receive() } + assertEquals("live", received.content) + client.unsubscribe("live-1") + } + + /** Non-matching live events are not pushed to a subscription. */ + @Test + fun liveSubscriptionIgnoresNonMatchingEvents() = + runBlocking { + val ch = Channel(UNLIMITED) + val gotEose = Channel(UNLIMITED) + client.subscribe( + "live-2", + mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + gotEose.trySend(Unit) + } + }, + ) + withTimeout(5000) { gotEose.receive() } + + hub.getOrCreate(relayUrl).publish(fakeEvent(98, kind = 4, content = "off-topic")) + + val seen = withTimeoutOrNull(500) { ch.receive() } + assertNull(seen, "kind 4 should not match a kind-1 subscription") + client.unsubscribe("live-2") + } + + // -- Multi-relay -------------------------------------------------------- + + /** A single client can hold subscriptions against multiple relays simultaneously. */ + @Test + fun multiRelayPoolReturnsContentFromEachRelay() = + runBlocking { + val relayA = RelayUrlNormalizer.normalize("ws://127.0.0.1:7771/") + val relayB = RelayUrlNormalizer.normalize("ws://127.0.0.1:7772/") + hub.getOrCreate(relayA).preload(fakeEvent(1, kind = 1, content = "from-a")) + hub.getOrCreate(relayB).preload(fakeEvent(2, kind = 1, content = "from-b")) + + val received = mutableMapOf() + val eosed = mutableSetOf() + val ch = Channel(UNLIMITED) + client.subscribe( + "multi-1", + mapOf( + relayA to listOf(Filter(kinds = listOf(1))), + relayB to listOf(Filter(kinds = listOf(1))), + ), + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + received[relay] = event.content + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eosed += relay + if (eosed.size == 2) ch.trySend(Unit) + } + }, + ) + + withTimeout(5000) { ch.receive() } + client.unsubscribe("multi-1") + + assertEquals("from-a", received[relayA]) + assertEquals("from-b", received[relayB]) + } + + // -- Helpers ------------------------------------------------------------ + + /** Subscribes synchronously and returns the events received before EOSE. */ + private suspend fun collectUntilEose(filter: Filter): Pair, Boolean> { + val ch = Channel(UNLIMITED) + val subId = "sub-${System.nanoTime()}" + client.subscribe( + subId, + mapOf(relayUrl to listOf(filter)), + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Either.Ev(event)) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Either.Eose) + } + }, + ) + + val events = mutableListOf() + var eose = false + withTimeout(5000) { + while (!eose) { + when (val msg = ch.receive()) { + is Either.Ev -> events += msg.event + Either.Eose -> eose = true + } + } + } + client.unsubscribe(subId) + return events to eose + } + + private sealed interface Either { + data class Ev( + val event: Event, + ) : Either + + object Eose : Either + } +} diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index b591f62f72..a6a6fc9a98 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -180,6 +180,10 @@ kotlin { dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) + + // In-process Nostr relay so JVM/Android host tests don't + // need network access or a Rust toolchain. + implementation(project(":quartz-test-relay")) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt index 27cc372c34..9cb90e4d83 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt @@ -42,7 +42,7 @@ object CountResultKSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("CountResult") { element("count") - element("pubkey") + element("approximate") } override fun serialize( @@ -56,8 +56,8 @@ object CountResultKSerializer : KSerializer { fun serializeToElement(value: CountResult): JsonObject = buildJsonObject { put("count", value.count) - // Matches Jackson's CountResultSerializer which writes "pubkey" for approximate - put("pubkey", value.approximate) + // NIP-45: include "approximate" only when true. + if (value.approximate) put("approximate", true) value.hll?.let { put("hll", HyperLogLog.encode(it)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index 4edf4ee4f0..800420e49c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -68,12 +68,10 @@ object MessageKSerializer : KSerializer { } is OkMessage -> { + // NIP-01 wire format: ["OK", , , ] add(JsonPrimitive(value.eventId)) - // Jackson writes success as a string, not boolean - add(JsonPrimitive(value.success.toString())) - if (value.message.isNotBlank()) { - add(JsonPrimitive(value.message)) - } + add(JsonPrimitive(value.success)) + add(JsonPrimitive(value.message)) } is AuthMessage -> { @@ -90,6 +88,8 @@ object MessageKSerializer : KSerializer { } is CountMessage -> { + // NIP-45 wire format: ["COUNT", , ] + add(JsonPrimitive(value.queryId)) add(CountResultKSerializer.serializeToElement(value.result)) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt index 4e72006431..76d2ddd789 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt @@ -163,7 +163,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[0].contains(",true,")) assertTrue((session.policy as FullAuthPolicy).isAuthenticated()) assertTrue(session.policy.authenticatedUsers.contains(pubkey)) @@ -184,7 +184,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains(",false,")) assertTrue(okMessages[0].contains("challenge")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) @@ -209,7 +209,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains(",false,")) assertTrue(okMessages[0].contains("relay url")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) @@ -238,7 +238,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains(",false,")) assertTrue(okMessages[0].contains("created_at")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) @@ -307,8 +307,8 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(2, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) - assertTrue(okMessages[1].contains("\"true\"")) + assertTrue(okMessages[0].contains(",true,")) + assertTrue(okMessages[1].contains(",true,")) val authedPubkeys = (session.policy as FullAuthPolicy).authenticatedUsers assertEquals(2, authedPubkeys.size) @@ -334,7 +334,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains(",false,")) assertTrue(okMessages[0].contains("auth-required:")) server.close() @@ -394,7 +394,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[0].contains(",true,")) // Now EVENT should work val event = testEvent() @@ -402,7 +402,7 @@ class NostrServerAuthTest { val allOk = collector.rawMessagesContaining("OK") assertEquals(2, allOk.size) - assertTrue(allOk[1].contains("\"true\"")) + assertTrue(allOk[1].contains(",true,")) // REQ should work session.receive("""["REQ","sub1",{"kinds":[1]}]""") @@ -428,7 +428,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[0].contains(",true,")) server.close() } @@ -461,14 +461,14 @@ class NostrServerAuthTest { // Kind 1 should be accepted without auth val note = testEvent(hexId(1), kind = 1) session.receive("""["EVENT",${note.toJson()}]""") - assertTrue(collector.rawMessagesContaining("OK")[0].contains("\"true\"")) + assertTrue(collector.rawMessagesContaining("OK")[0].contains(",true,")) // Kind 4 should be rejected without auth val dm = testEvent(hexId(2), kind = 4) session.receive("""["EVENT",${dm.toJson()}]""") val okMessages = collector.rawMessagesContaining("OK") assertEquals(2, okMessages.size) - assertTrue(okMessages[1].contains("\"false\"")) + assertTrue(okMessages[1].contains(",false,")) assertTrue(okMessages[1].contains("auth-required:")) server.close() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt index 3e033cf498..3217b994a5 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt @@ -109,7 +109,7 @@ class NostrServerTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[0].contains(",true,")) // Event should be in store val stored = store.query(Filter(ids = listOf(event.id))) @@ -135,8 +135,8 @@ class NostrServerTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(2, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) - assertTrue(okMessages[1].contains("\"false\"")) + assertTrue(okMessages[0].contains(",true,")) + assertTrue(okMessages[1].contains(",false,")) server.close() } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultSerializer.kt index efc63b2297..131c30d71e 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultSerializer.kt @@ -30,9 +30,12 @@ class CountResultSerializer : StdSerializer(CountResult::class.java gen: JsonGenerator, provider: SerializerProvider, ) { + // NIP-45 result object: { "count": , "approximate": ? }. gen.writeStartObject() gen.writeNumberField("count", result.count) - gen.writeBooleanField("pubkey", result.approximate) + if (result.approximate) { + gen.writeBooleanField("approximate", true) + } gen.writeEndObject() } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt index b0ca6557a3..9048fe774c 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt @@ -50,11 +50,11 @@ class MessageSerializer : StdSerializer(Message::class.java) { } is OkMessage -> { + // NIP-01 wire format: ["OK", , , ] + // The third element is a JSON boolean, not a string. gen.writeString(msg.eventId) - gen.writeString(msg.success.toString()) - if (msg.message.isNotBlank()) { - gen.writeString(msg.message) - } + gen.writeBoolean(msg.success) + gen.writeString(msg.message) } is AuthMessage -> { @@ -71,6 +71,8 @@ class MessageSerializer : StdSerializer(Message::class.java) { } is CountMessage -> { + // NIP-45 wire format: ["COUNT", , ] + gen.writeString(msg.queryId) countSerializer.serialize(msg.result, gen, provider) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt index 1431b7af5c..6c1d0cbfae 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt @@ -20,35 +20,21 @@ */ package com.vitorpamplona.quartz.nip01Core.relay -import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket -import okhttp3.Interceptor -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response - -class DefaultContentTypeInterceptor( - private val userAgentHeader: String, -) : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val originalRequest: Request = chain.request() - val requestWithUserAgent: Request = - originalRequest - .newBuilder() - .header("User-Agent", userAgentHeader) - .build() - return chain.proceed(requestWithUserAgent) - } -} +import com.vitorpamplona.quartz.testrelay.TestRelayHub +/** + * Base for tests that drive a real `NostrClient` against an in-process Nostr + * relay. Each subclass instance gets its own [TestRelayHub] so tests can + * preload events and assert deterministic counts without hitting the + * network or relying on production relays. + * + * To replace with the previous behaviour (real OkHttp WebSocket against + * `wss://nos.lol`), instantiate `BasicOkHttpWebSocket.Builder` directly in + * the specific test that needs it. + */ open class BaseNostrClientTest { - companion object { - val rootClient = - OkHttpClient - .Builder() - .followRedirects(true) - .followSslRedirects(true) - .addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05")) - .build() - val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient } - } + val relayHub: TestRelayHub = TestRelayHub() + + /** Plug into `NostrClient(socketBuilder, scope)`. */ + val socketBuilder get() = relayHub } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt index 033ead5d1b..d5fcae43db 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt @@ -19,6 +19,8 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst @@ -35,23 +37,39 @@ class NostrClientFirstEventTest : BaseNostrClientTest() { @Test fun testDownloadFirstEvent() = runBlocking { + val pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + val relayUrl = "ws://127.0.0.1:7770/" + + val seed = + Event( + id = "a".repeat(64), + pubKey = pubKey, + createdAt = 1000L, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = """{"name":"vitor"}""", + sig = "b".repeat(128), + ) + relayHub.getOrCreate(relayUrl).preload(seed) + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) val event = client.fetchFirst( - relay = "wss://nos.lol", + relay = relayUrl, filter = Filter( kinds = listOf(MetadataEvent.KIND), - authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + authors = listOf(pubKey), ), ) client.disconnect() appScope.cancel() + relayHub.close() assertEquals(MetadataEvent.KIND, event?.kind) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", event?.pubKey) + assertEquals(pubKey, event?.pubKey) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt index d7412a0b3d..ad5ac6123a 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.testrelay.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -41,6 +42,11 @@ class NostrClientManualSubTest : BaseNostrClientTest() { @Test fun testEoseAfter100Events() = runBlocking { + val relayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") + relayHub + .getOrCreate(relayUrl) + .preload(SyntheticEvents.batch(150, kind = MetadataEvent.KIND)) + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) @@ -69,7 +75,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() { val filters = mapOf( - RelayUrlNormalizer.normalize("wss://nos.lol") to + relayUrl to listOf( Filter( kinds = listOf(MetadataEvent.KIND), @@ -93,6 +99,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() { client.disconnect() appScope.cancel() + relayHub.close() assertEquals(101, events.size) assertEquals(true, events.take(100).all { it.length == 64 }) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt index 5851bcb0f4..d7f9c236bf 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt @@ -19,73 +19,97 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl -import junit.framework.TestCase.assertTrue +import com.vitorpamplona.quartz.testrelay.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import kotlin.test.Test +import kotlin.test.assertEquals class NostrClientQueryCountTest : BaseNostrClientTest() { - val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl() - val utxo = "wss://news.utxo.one".normalizeRelayUrl() + private val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl() + private val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl() - val metadata = Filter(kinds = listOf(0)) - val outboxRelays = Filter(kinds = listOf(10002)) + private val metadata = Filter(kinds = listOf(0)) + private val outboxRelays = Filter(kinds = listOf(10002)) + + private suspend fun seed() { + // 5 metadata + 3 outbox relay events on A, 2 metadata + 7 outbox on B. + // Each event needs a distinct (kind, pubkey, dTag) to avoid replaceable-event collisions. + fun pk(seed: Int) = SyntheticEvents.hexId(seed) + relayHub.getOrCreate(relayA).preload( + (1..5).map { SyntheticEvents.fakeEvent(idSeed = it, kind = 0, pubKey = pk(it)) }, + ) + relayHub.getOrCreate(relayA).preload( + (1..3).map { SyntheticEvents.fakeEvent(idSeed = 1000 + it, kind = 10002, pubKey = pk(1000 + it)) }, + ) + relayHub.getOrCreate(relayB).preload( + (1..2).map { SyntheticEvents.fakeEvent(idSeed = 2000 + it, kind = 0, pubKey = pk(2000 + it)) }, + ) + relayHub.getOrCreate(relayB).preload( + (1..7).map { SyntheticEvents.fakeEvent(idSeed = 3000 + it, kind = 10002, pubKey = pk(3000 + it)) }, + ) + } @Test fun testQueryCountSuspend() = runBlocking { + seed() val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) - val result = client.count(fiatjaf, metadata) + val result = client.count(relayA, metadata) - assertTrue((result?.count ?: 0) > 1) + assertEquals(5, result?.count) client.disconnect() appScope.cancel() + relayHub.close() } @Test fun testQueryCountSuspendAllEvents() = runBlocking { + seed() val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) - val result = client.count(fiatjaf, Filter()) + val result = client.count(relayA, Filter()) - assertTrue((result?.count ?: 0) > 1) + assertEquals(8, result?.count) client.disconnect() appScope.cancel() + relayHub.close() } @Test fun testQueryCountSuspendMultipleRelays() = runBlocking { + seed() val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) val results = client.count( mapOf( - fiatjaf to listOf(metadata, outboxRelays), - utxo to listOf(metadata, outboxRelays), + relayA to listOf(metadata, outboxRelays), + relayB to listOf(metadata, outboxRelays), ), ) - results.forEach { (url, countResult) -> - println("${url.url}: ${countResult.count}") - assertTrue(countResult.count > 1) - } + assertEquals(8, results[relayA]?.count) + assertEquals(9, results[relayB]?.count) client.disconnect() appScope.cancel() + relayHub.close() } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt index ea8da446b8..25f1b93e56 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.testrelay.SyntheticEvents import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -47,6 +48,26 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { @Test fun testRepeatSubEvents() = runBlocking { + // Each replaceable kind needs unique pubkeys. + relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( + (1..150).map { + SyntheticEvents.fakeEvent( + idSeed = it, + kind = MetadataEvent.KIND, + pubKey = SyntheticEvents.hexId(it), + ) + }, + ) + relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( + (1..50).map { + SyntheticEvents.fakeEvent( + idSeed = 100_000 + it, + kind = AdvertisedRelayListEvent.KIND, + pubKey = SyntheticEvents.hexId(100_000 + it), + ) + }, + ) + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) @@ -82,7 +103,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { val filters = mapOf( - RelayUrlNormalizer.normalize("wss://nos.lol") to + RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to listOf( Filter( kinds = listOf(MetadataEvent.KIND), @@ -93,7 +114,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { val filtersShouldIgnore = mapOf( - RelayUrlNormalizer.normalize("wss://nos.lol") to + RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to listOf( Filter( kinds = listOf(AdvertisedRelayListEvent.KIND), @@ -104,7 +125,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { val filtersShouldSendAfterEOSE = mapOf( - RelayUrlNormalizer.normalize("wss://nos.lol") to + RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to listOf( Filter( kinds = listOf(AdvertisedRelayListEvent.KIND), @@ -143,6 +164,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { client.disconnect() appScope.cancel() + relayHub.close() // The relay may return up to limit events before EOSE; some relays return // one extra past the requested limit, so don't assert on the exact count. diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt index 6ffb0de811..4e3c639c27 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt @@ -19,12 +19,14 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.testrelay.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -38,15 +40,26 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { @Test fun testDownloadFromRelayReturnsMetadataEvents() = runBlocking { + // Each event needs a unique pubkey so replaceable kind 0 doesn't + // collapse them all to one row. + val corpus = + (1..1000).map { + SyntheticEvents.fakeEvent( + idSeed = it, + kind = MetadataEvent.KIND, + pubKey = SyntheticEvents.hexId(it), + ) + } + relayHub.getOrCreate("ws://127.0.0.1:7770/").preload(corpus) + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) val events = mutableListOf() - // nos.lol returns only 500 events per req val totalFound = client.fetchAllPages( - relay = "wss://nos.lol", + relay = "ws://127.0.0.1:7770/", filters = listOf( Filter( @@ -61,27 +74,45 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { client.disconnect() delay(500) appScope.cancel() + relayHub.close() - assertEquals(1000, totalFound, "Expected 1000 events from wss://nos.lol") - assertEquals(1000, events.size, "Events list should be 1000 events") + assertEquals(1000, totalFound) + assertEquals(1000, events.size) events.forEach { event -> - assertEquals(MetadataEvent.KIND, event.kind, "All events should be kind ${MetadataEvent.KIND}") + assertEquals(MetadataEvent.KIND, event.kind) } } @Test fun testDownloadFromRelayReturnsMetadataAndContactListEvents() = runBlocking { + val metadata = + (1..1000).map { + SyntheticEvents.fakeEvent( + idSeed = it, + kind = MetadataEvent.KIND, + pubKey = SyntheticEvents.hexId(it), + ) + } + val contacts = + (1..1500).map { + SyntheticEvents.fakeEvent( + idSeed = 100_000 + it, + kind = ContactListEvent.KIND, + pubKey = SyntheticEvents.hexId(100_000 + it), + ) + } + relayHub.getOrCreate("ws://127.0.0.1:7770/").preload(metadata + contacts) + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) val metadataEvents = mutableListOf() val contactListEvents = mutableListOf() - // nos.lol returns only 500 events per req val totalFound = client.fetchAllPages( - relay = "wss://nos.lol", + relay = "ws://127.0.0.1:7770/", filters = listOf( Filter( @@ -105,15 +136,10 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { client.disconnect() delay(500) appScope.cancel() + relayHub.close() - assertEquals(2500, totalFound, "Expected 1000 events from wss://nos.lol") - assertEquals(1000, metadataEvents.size, "Events list should be 1000 events") - assertEquals(1500, contactListEvents.size, "Events list should be 1000 events") - metadataEvents.forEach { event -> - assertEquals(MetadataEvent.KIND, event.kind, "All events should be kind ${MetadataEvent.KIND}") - } - contactListEvents.forEach { event -> - assertEquals(ContactListEvent.KIND, event.kind, "All events should be kind ${ContactListEvent.KIND}") - } + assertEquals(2500, totalFound) + assertEquals(1000, metadataEvents.size) + assertEquals(1500, contactListEvents.size) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt index b9a17d2197..ae28b862ec 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt @@ -44,22 +44,26 @@ class NostrClientSendAndWaitTest : BaseNostrClientTest() { val event = randomSigner.sign(TextNoteEvent.build("Hello World")) - val resultDamus = + val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl() + val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl() + + val resultA = client.publishAndConfirm( event = event, - relayList = setOf("wss://nostr.bitcoiner.social".normalizeRelayUrl()), + relayList = setOf(relayA), ) - val resultNos = + val resultB = client.publishAndConfirm( event = event, - relayList = setOf("wss://nos.lol".normalizeRelayUrl()), + relayList = setOf(relayB), ) client.disconnect() appScope.cancel() + relayHub.close() - assertEquals(true, resultDamus) - assertEquals(true, resultNos) + assertEquals(true, resultA) + assertEquals(true, resultB) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt index 3bf5e66996..30e670ff7f 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.testrelay.SyntheticEvents import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -48,12 +49,15 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { @Test fun testNostrClientSubscriptionAsFlow() = runTest { + relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( + SyntheticEvents.batch(20, kind = MetadataEvent.KIND), + ) val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) val flow = client.subscribeAsFlow( - relay = "wss://nos.lol", + relay = "ws://127.0.0.1:7770/", filter = Filter( kinds = listOf(MetadataEvent.KIND), @@ -79,6 +83,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { client.disconnect() appScope.cancel() + relayHub.close() assertEquals(10, feedStates.size) } @@ -87,12 +92,15 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { @Test fun testNostrClientSubscriptionAsFlowDebouncing() = runTest { + relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( + SyntheticEvents.batch(20, kind = MetadataEvent.KIND), + ) val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) val flow = client.subscribeAsFlow( - relay = "wss://nos.lol", + relay = "ws://127.0.0.1:7770/", filter = Filter( kinds = listOf(MetadataEvent.KIND), @@ -118,6 +126,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { client.disconnect() appScope.cancel() + relayHub.close() assertEquals(10, feedStates.size) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt index 74e3a0215b..141bf6cdfd 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.StaticSubscription import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.testrelay.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -40,6 +41,9 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { @Test fun testNostrClientSubscription() = runBlocking { + relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( + SyntheticEvents.batch(150, kind = MetadataEvent.KIND), + ) val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) @@ -50,7 +54,7 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { StaticSubscription( client, mapOf( - RelayUrlNormalizer.normalize("wss://nos.lol") to + RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to listOf( Filter( kinds = listOf(MetadataEvent.KIND), @@ -76,6 +80,7 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { client.disconnect() appScope.cancel() + relayHub.close() assertEquals(100, events.size) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt index c8054a6f71..e3de606f28 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.testrelay.SyntheticEvents import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -48,12 +49,15 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { @Test fun testNostrClientSubscriptionUntilEoseAsFlow() = runTest { + relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( + SyntheticEvents.batch(20, kind = MetadataEvent.KIND), + ) val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) val flow = client.fetchAsFlow( - relay = "wss://nos.lol", + relay = "ws://127.0.0.1:7770/", filter = Filter( kinds = listOf(MetadataEvent.KIND), @@ -79,6 +83,7 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { client.disconnect() appScope.cancel() + relayHub.close() assertEquals(10, feedStates.size) } @@ -87,12 +92,15 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { @Test fun testNostrClientSubscriptionUntilEoseAsFlowDebouncing() = runTest { + relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( + SyntheticEvents.batch(20, kind = MetadataEvent.KIND), + ) val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val client = NostrClient(socketBuilder, appScope) val flow = client.fetchAsFlow( - relay = "wss://nos.lol", + relay = "ws://127.0.0.1:7770/", filter = Filter( kinds = listOf(MetadataEvent.KIND), @@ -118,6 +126,7 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { client.disconnect() appScope.cancel() + relayHub.close() assertEquals(10, feedStates.size) } diff --git a/settings.gradle b/settings.gradle index 38a632ba1e..7ca4625621 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,6 +34,7 @@ rootProject.name = "Amethyst" include ':amethyst' include ':benchmark' include ':quartz' +include ':quartz-test-relay' include ':commons' include ':ammolite' include ':quic' From eb80dbcd15e5940b2df323f0a02be2c93aa907c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 00:56:57 +0000 Subject: [PATCH 02/17] feat(quartz-relay): rename + promote to a real Nostr relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames :quartz-test-relay to :quartz-relay and promotes it from a test-only fixture to a real, runnable Nostr relay that just happens to also be used in tests. Two transports now share the same `Relay` core: - `RelayHub` + `InProcessWebSocket` — no socket, fastest path; ideal for unit tests inside one JVM. - `LocalRelayServer` — Ktor `embeddedServer` (CIO engine) listening on a real `ws://` port. Use for `cli` interop tests, Android instrumented tests, or running the relay standalone. NIPs implemented (all driven through production `NostrClient` in the new `LocalRelayServerTest`): - NIP-01 wire protocol (REQ/EVENT/EOSE/CLOSE) over real WebSockets - NIP-09 deletion (via existing `DeletionRequestModule`) - NIP-11 relay info doc — `RelayInfo` wraps the existing `Nip11RelayInformation` model and is served on HTTP GET when `Accept: application/nostr+json` is requested. Loadable from a JSON config file via `RelayInfo.fromFile(...)`. - NIP-40 expiration (existing `ExpirationModule`) - NIP-42 AUTH (existing `FullAuthPolicy`, opted-in via the `--auth` flag in the standalone runner) - NIP-45 COUNT - NIP-50 search via the existing FTS index - NIP-62 right-to-vanish (existing module) Adds a standalone runner: `./gradlew :quartz-relay:run --args="--port 7447 --verify"` binds the relay to a real port. Flags: --host, --port, --path, --info , --db , --auth, --verify. Test-only event generators moved to a clearly-named `com.vitorpamplona.quartz.relay.fixtures` package so they're still shareable with consumer tests without polluting the production API. Adds `LocalRelayServerTest` covering NIP-01/11/42/45/50 over a real loopback WebSocket. Existing 11-test `Nip01ComplianceTest` (in-process transport) continues to pass. --- gradle/libs.versions.toml | 4 + .../build.gradle.kts | 16 +- .../quartz/relay}/InProcessWebSocket.kt | 6 +- .../quartz/relay/LocalRelayServer.kt | 148 ++++++++++++ .../com/vitorpamplona/quartz/relay/Main.kt | 121 ++++++++++ .../com/vitorpamplona/quartz/relay/Relay.kt | 22 +- .../vitorpamplona/quartz/relay/RelayHub.kt | 18 +- .../vitorpamplona/quartz/relay/RelayInfo.kt | 63 +++++ .../quartz/relay/fixtures}/RelayFixtures.kt | 2 +- .../quartz/relay/fixtures}/SyntheticEvents.kt | 2 +- .../quartz/relay/LocalRelayServerTest.kt | 216 ++++++++++++++++++ .../quartz/relay}/Nip01ComplianceTest.kt | 7 +- quartz/build.gradle.kts | 6 +- .../nip01Core/relay/BaseNostrClientTest.kt | 6 +- .../relay/NostrClientManualSubTest.kt | 2 +- .../relay/NostrClientQueryCountTest.kt | 2 +- .../relay/NostrClientRepeatSubTest.kt | 2 +- .../NostrClientReqBypassingRelayLimitsTest.kt | 2 +- .../NostrClientSubscriptionAsFlowTest.kt | 2 +- .../relay/NostrClientSubscriptionTest.kt | 2 +- ...trClientSubscriptionUntilEoseAsFlowTest.kt | 2 +- settings.gradle | 2 +- 22 files changed, 616 insertions(+), 37 deletions(-) rename {quartz-test-relay => quartz-relay}/build.gradle.kts (56%) rename {quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay => quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay}/InProcessWebSocket.kt (94%) create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt rename quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelay.kt => quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt (78%) rename quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelayHub.kt => quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt (83%) create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt rename {quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay => quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures}/RelayFixtures.kt (98%) rename {quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay => quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures}/SyntheticEvents.kt (98%) create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt rename {quartz-test-relay/src/test/kotlin/com/vitorpamplona/quartz/testrelay => quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay}/Nip01ComplianceTest.kt (98%) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 28f780ce56..08f9feda34 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -81,6 +81,7 @@ kotlinTest = "2.3.21" core = "1.7.0" mavenPublish = "0.36.0" sqlite = "2.6.2" +ktor = "3.4.1" [libraries] abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" } @@ -175,6 +176,9 @@ negentropy-kmp = { module = "com.vitorpamplona.negentropy:kmp-negentropy", versi net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } +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" } 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-test-relay/build.gradle.kts b/quartz-relay/build.gradle.kts similarity index 56% rename from quartz-test-relay/build.gradle.kts rename to quartz-relay/build.gradle.kts index 3c87e3f999..5c03df463f 100644 --- a/quartz-test-relay/build.gradle.kts +++ b/quartz-relay/build.gradle.kts @@ -2,6 +2,12 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.jetbrainsKotlinJvm) + application +} + +application { + mainClass.set("com.vitorpamplona.quartz.relay.MainKt") + applicationName = "quartz-relay" } kotlin { @@ -26,10 +32,18 @@ dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.jackson.module.kotlin) - // Bundled SQLite driver — EventStore(null) creates an in-memory DB at runtime. + // Bundled SQLite driver — Relay's default in-memory EventStore creates + // an in-memory DB at runtime. implementation(libs.androidx.sqlite.bundled.jvm) + // Ktor server engine + WebSocket plugin so Relay can serve real ws:// + // traffic. CIO is the coroutine-based engine — lighter than Netty. + api(libs.ktor.server.core) + api(libs.ktor.server.cio) + api(libs.ktor.server.websockets) + testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.secp256k1.kmp.jni.jvm) + testImplementation(libs.okhttp) } diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/InProcessWebSocket.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/InProcessWebSocket.kt similarity index 94% rename from quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/InProcessWebSocket.kt rename to quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/InProcessWebSocket.kt index b487ce46f2..a54560089b 100644 --- a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/InProcessWebSocket.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/InProcessWebSocket.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.testrelay +package com.vitorpamplona.quartz.relay import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket @@ -32,7 +32,7 @@ import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.launch /** - * In-memory implementation of [WebSocket] that talks to a [TestRelay] without + * In-memory implementation of [WebSocket] that talks to a [Relay] without * touching the network. Each instance opens one [RelaySession] on * [connect] and routes: * @@ -42,7 +42,7 @@ import kotlinx.coroutines.launch * - Server-side `send` callbacks → [WebSocketListener.onMessage]. */ class InProcessWebSocket( - private val relay: TestRelay, + private val relay: Relay, private val out: WebSocketListener, ) : WebSocket { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt new file mode 100644 index 0000000000..ca4d398d54 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt @@ -0,0 +1,148 @@ +/* + * 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 + +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.install +import io.ktor.server.cio.CIO +import io.ktor.server.cio.CIOApplicationEngine +import io.ktor.server.engine.embeddedServer +import io.ktor.server.request.header +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.webSocket +import io.ktor.websocket.Frame +import io.ktor.websocket.readText +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.runBlocking + +/** + * Hosts a [Relay] over a real `ws://` endpoint backed by Ktor + CIO. + * + * Use this when something other than the in-process [InProcessWebSocket] needs + * to talk to the relay — Android instrumented tests, the `cli` tooling, + * external clients, or a standalone "run a Nostr relay" process. + * + * For unit-test wiring inside a single JVM, prefer [RelayHub] + + * [InProcessWebSocket] — same protocol, no socket overhead. + * + * Lifecycle: + * ``` + * val server = LocalRelayServer(Relay(url = ...)).start() + * println("listening on ${server.url}") + * // ... do stuff ... + * server.stop() + * ``` + */ +class LocalRelayServer( + val relay: Relay, + val host: String = "127.0.0.1", + /** Pass 0 to let the OS pick a free port. Read [url] after [start] to learn it. */ + val port: Int = 0, + val path: String = "/", +) { + private var engine: CIOApplicationEngine? = null + private var resolvedPort: Int = -1 + + /** `ws://host:port/path` — only valid after [start]. */ + val url: String + get() { + check(resolvedPort != -1) { "Server not started" } + return "ws://$host:$resolvedPort$path" + } + + /** + * Binds the Ktor engine. Returns once the engine reports ready, so + * [url] is safe to read on the very next line. + */ + fun start(): LocalRelayServer { + val server = + embeddedServer(CIO, host = host, port = port) { + install(WebSockets) + routing { + // NIP-11: GET on the relay URL with Accept: + // application/nostr+json returns the relay info doc. + // We mount this *before* the webSocket route so Ktor + // serves NIP-11 for plain HTTP GETs and only upgrades + // to a WebSocket when the request is a WS upgrade. + get(path) { + val accept = call.request.header(HttpHeaders.Accept).orEmpty() + if (accept.contains("application/nostr+json")) { + call.response.headers.append("Access-Control-Allow-Origin", "*") + call.respondText( + relay.info.json, + ContentType.parse("application/nostr+json"), + ) + } else { + call.respondText( + "Use a Nostr client (NIP-01 WebSocket) or send Accept: application/nostr+json (NIP-11).", + ContentType.Text.Plain, + HttpStatusCode.UpgradeRequired, + ) + } + } + webSocket(path) { + val session = + relay.server.connect { json -> + // ktor-websockets schedules outgoing frames on its own + // dispatcher; trySend never blocks the relay thread. + outgoing.trySend(Frame.Text(json)) + } + try { + incoming.consumeEach { frame -> + if (frame is Frame.Text) { + session.receive(frame.readText()) + } + } + } finally { + session.close() + } + } + } + } + server.start(wait = false) + engine = server.engine + // Ktor 3.x made resolvedConnectors() suspend. We block here so + // start() returns synchronously with [url] readable on the next line. + resolvedPort = + runBlocking { + server.engine + .resolvedConnectors() + .first() + .port + } + return this + } + + /** Stops the engine. Safe to call multiple times. */ + fun stop( + gracePeriodMillis: Long = 100, + timeoutMillis: Long = 1_000, + ) { + engine?.stop(gracePeriodMillis, timeoutMillis) + engine = null + resolvedPort = -1 + } +} 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 new file mode 100644 index 0000000000..d9908dd4d9 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt @@ -0,0 +1,121 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import java.io.File + +/** + * Standalone entry point. Run with `./gradlew :quartz-relay:run` (when the + * application plugin is configured) or `java -cp ... Main`. + * + * 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) + */ +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 info = infoFile?.let { RelayInfo.fromFile(it) } ?: RelayInfo.default(advertisedUrl) + + val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl) + + val policyBuilder: () -> IRelayPolicy = + when { + verifySigs && requireAuth -> { -> VerifyPolicy + FullAuthPolicy(advertisedUrl) } + verifySigs -> { -> VerifyPolicy } + requireAuth -> { -> FullAuthPolicy(advertisedUrl) } + else -> { -> EmptyPolicy } + } + + val relay = Relay(advertisedUrl, store, info, policyBuilder) + val server = LocalRelayServer(relay, host = host, port = port, path = path).start() + + Runtime.getRuntime().addShutdownHook( + Thread { + server.stop() + relay.close() + }, + ) + + println("quartz-relay listening on ${server.url}") + println("NIP-11 info doc: curl -H 'Accept: application/nostr+json' http://$host:$port$path") + + // Park the main thread; shutdown hook handles teardown. + Thread.currentThread().join() +} + +private class Args( + private val opts: Map, + private val flags: Set, +) { + fun opt(k: String) = opts[k] + + fun flag(k: String) = k in flags +} + +private fun parseArgs(args: Array): Args { + val opts = mutableMapOf() + val flags = mutableSetOf() + var i = 0 + while (i < args.size) { + val a = args[i] + if (a.startsWith("--")) { + val next = args.getOrNull(i + 1) + if (next != null && !next.startsWith("--")) { + opts[a] = next + i += 2 + } else { + flags += a + i += 1 + } + } else { + i += 1 + } + } + return Args(opts, flags) +} diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelay.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt similarity index 78% rename from quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelay.kt rename to quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt index ccd251457b..9df98315ca 100644 --- a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelay.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.testrelay +package com.vitorpamplona.quartz.relay import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper @@ -33,15 +33,25 @@ import kotlinx.coroutines.SupervisorJob import kotlin.coroutines.CoroutineContext /** - * A self-contained, in-memory Nostr relay scoped to a single URL. Wraps a - * [NostrServer] over an [EventStore] backed by an in-memory SQLite database. + * A self-contained Nostr relay scoped to a single URL. Wraps a [NostrServer] + * over an [EventStore] (defaults to an in-memory SQLite database). * - * Use [TestRelayHub] to register relays under URLs the production - * [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] can subscribe to. + * Speaks NIP-01 (REQ/EVENT/EOSE/CLOSE), NIP-11 (relay info via [info]), + * NIP-42 (AUTH — supply [policyBuilder] = `{ FullAuthPolicy(url) }` or + * stack one with [com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy.plus]), + * NIP-45 (COUNT) and NIP-50 (search via the SQLite FTS index). + * + * Two transports: + * - [InProcessWebSocket] / [RelayHub] — no socket, fastest path, ideal + * for unit tests inside one JVM. + * - [LocalRelayServer] — Ktor `embeddedServer` listening on a real port. + * Use when external clients need to connect (`cli`, instrumented tests, + * standalone deployment). */ -class TestRelay( +class Relay( val url: NormalizedRelayUrl, val store: IEventStore = EventStore(dbName = null, relay = url), + val info: RelayInfo = RelayInfo.default(url), policyBuilder: () -> IRelayPolicy = { EmptyPolicy }, parentContext: CoroutineContext = SupervisorJob(), ) : AutoCloseable { diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelayHub.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt similarity index 83% rename from quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelayHub.kt rename to quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt index 45a3fab10f..6f967f28dc 100644 --- a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/TestRelayHub.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.testrelay +package com.vitorpamplona.quartz.relay import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder import java.util.concurrent.ConcurrentHashMap /** - * Registry of [TestRelay] instances keyed by relay URL. Implements + * Registry of [Relay] instances keyed by relay URL. Implements * [WebsocketBuilder] so it can be plugged into * [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] in place of * `BasicOkHttpWebSocket.Builder` to redirect every outbound connection to an @@ -38,7 +38,7 @@ import java.util.concurrent.ConcurrentHashMap * * Usage: * ``` - * val hub = TestRelayHub() + * val hub = RelayHub() * val relay = hub.getOrCreate("ws://test.relay/") * runBlocking { relay.preload(listOf(event1, event2)) } * val client = NostrClient(hub, scope) @@ -47,20 +47,20 @@ import java.util.concurrent.ConcurrentHashMap * Unknown URLs auto-create an empty relay so a single hub can transparently * back any number of test endpoints. */ -class TestRelayHub( +class RelayHub( private val defaultPolicy: () -> IRelayPolicy = { EmptyPolicy }, ) : WebsocketBuilder, AutoCloseable { - private val relays = ConcurrentHashMap() + private val relays = ConcurrentHashMap() - fun getOrCreate(url: NormalizedRelayUrl): TestRelay = + fun getOrCreate(url: NormalizedRelayUrl): Relay = relays.getOrPut(url) { - TestRelay(url = url, policyBuilder = defaultPolicy) + Relay(url = url, policyBuilder = defaultPolicy) } - fun getOrCreate(url: String): TestRelay = getOrCreate(RelayUrlNormalizer.normalize(url)) + fun getOrCreate(url: String): Relay = getOrCreate(RelayUrlNormalizer.normalize(url)) - fun get(url: NormalizedRelayUrl): TestRelay? = relays[url] + fun get(url: NormalizedRelayUrl): Relay? = relays[url] fun urls(): Set = relays.keys.toSet() diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt new file mode 100644 index 0000000000..7e49e6cfe2 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt @@ -0,0 +1,63 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import java.io.File + +/** + * Relay-side handle for the NIP-11 information document. Wraps the + * client-side [Nip11RelayInformation] model and provides loaders for + * config files plus a default doc that advertises the NIPs this relay + * actually implements. + */ +data class RelayInfo( + val document: Nip11RelayInformation, +) { + /** Pre-rendered JSON, ready to write into the HTTP response body. */ + val json: String by lazy { JsonMapper.toJson(document) } + + companion object { + /** Pre-built default for `Relay(url = ...)` — advertises the supported NIPs. */ + fun default(url: NormalizedRelayUrl): RelayInfo = + RelayInfo( + Nip11RelayInformation( + name = "quartz-relay", + description = "Embedded Nostr relay from the Amethyst quartz library.", + software = "https://github.com/vitorpamplona/amethyst/tree/main/quartz-relay", + version = "1.08.0", + // Currently implemented: NIP-01 (basic), NIP-09 (deletion via + // DeletionRequestModule), NIP-11 (this doc), NIP-40 (expiration + // via ExpirationModule), NIP-42 (AUTH — when policy enables), + // NIP-45 (COUNT), NIP-50 (search via FTS), NIP-62 (right to vanish). + supported_nips = listOf("1", "9", "11", "40", "42", "45", "50", "62"), + ), + ) + + /** Loads a NIP-11 doc from a JSON file (e.g. a relay operator's config). */ + fun fromFile(file: File): RelayInfo = RelayInfo(Nip11RelayInformation.fromJson(file.readText())) + + /** Parses a NIP-11 doc from a raw JSON string. */ + fun fromJson(json: String): RelayInfo = RelayInfo(Nip11RelayInformation.fromJson(json)) + } +} diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/RelayFixtures.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/RelayFixtures.kt similarity index 98% rename from quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/RelayFixtures.kt rename to quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/RelayFixtures.kt index 6fc5103856..2ff341ea1c 100644 --- a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/RelayFixtures.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/RelayFixtures.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.testrelay +package com.vitorpamplona.quartz.relay.fixtures import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper diff --git a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/SyntheticEvents.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/SyntheticEvents.kt similarity index 98% rename from quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/SyntheticEvents.kt rename to quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/SyntheticEvents.kt index 640dfa4b12..831c73c3e1 100644 --- a/quartz-test-relay/src/main/kotlin/com/vitorpamplona/quartz/testrelay/SyntheticEvents.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/SyntheticEvents.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.testrelay +package com.vitorpamplona.quartz.relay.fixtures import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt new file mode 100644 index 0000000000..a55393b32d --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt @@ -0,0 +1,216 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.Request +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * End-to-end tests that drive a real `ws://` connection between the + * production [NostrClient] (over OkHttp) and the [LocalRelayServer] + * (Ktor + CIO). These prove the relay implements: + * + * - NIP-01 wire protocol (REQ/EVENT/EOSE) over real WebSockets + * - NIP-11 relay info doc on HTTP GET with `Accept: application/nostr+json` + * - NIP-42 AUTH (when [FullAuthPolicy] is enabled, REQ is rejected + * until the client authenticates) + * - NIP-45 COUNT + * - NIP-50 search via the SQLite FTS index + * + * Tests use port 0 for autobind to avoid conflicts when multiple suites + * run in parallel. + */ +class LocalRelayServerTest { + private lateinit var relay: Relay + private lateinit var server: LocalRelayServer + private lateinit var scope: CoroutineScope + private lateinit var client: NostrClient + + private val httpClient = OkHttpClient.Builder().build() + + @BeforeTest + fun setup() { + // Bind to 127.0.0.1:0 — the OS picks a free port. Note: the URL + // must be resolvable by the Nostr URL normalizer, which only + // accepts loopback addresses. 127.0.0.1 qualifies. + val placeholderUrl = "ws://127.0.0.1:7771/".normalizeRelayUrl() + relay = Relay(url = placeholderUrl) + server = LocalRelayServer(relay, host = "127.0.0.1", port = 0).start() + scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient } + client = NostrClient(builder, scope) + } + + @AfterTest + fun teardown() { + client.disconnect() + scope.cancel() + server.stop() + relay.close() + } + + @Test + fun nip01_realWebSocketRoundtrip() = + runBlocking { + val pubkey = SyntheticEvents.hexId(1) + relay.preload( + SyntheticEvents.fakeEvent( + idSeed = 42, + kind = MetadataEvent.KIND, + pubKey = pubkey, + content = """{"name":"vitor"}""", + ), + ) + + val event = + client.fetchFirst( + relay = server.url, + filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubkey)), + ) + + assertNotNull(event) + assertEquals(MetadataEvent.KIND, event.kind) + assertEquals(pubkey, event.pubKey) + } + + @Test + fun nip11_returnsInfoDocOnHttpGetWithNostrAcceptHeader() { + val httpUrl = server.url.replace("ws://", "http://") + val response = + httpClient + .newCall( + Request + .Builder() + .url(httpUrl) + .header("Accept", "application/nostr+json") + .build(), + ).execute() + + response.use { + assertEquals(200, it.code) + val body = it.body.string() + val info = Nip11RelayInformation.fromJson(body) + assertEquals("quartz-relay", info.name) + assertTrue(info.supported_nips!!.contains("11"), "NIP-11 must be advertised") + assertTrue(info.supported_nips!!.contains("1"), "NIP-01 must be advertised") + } + } + + @Test + fun nip45_countOverRealWebSocket() = + runBlocking { + // Each event needs a unique pubkey so kind-0 (replaceable) + // doesn't collapse them all to one row. + relay.preload( + (1..7).map { + SyntheticEvents.fakeEvent( + idSeed = it, + kind = MetadataEvent.KIND, + pubKey = SyntheticEvents.hexId(1000 + it), + ) + }, + ) + + val result = + client.count( + relay = server.url.normalizeRelayUrl(), + filter = Filter(kinds = listOf(MetadataEvent.KIND)), + ) + + assertEquals(7, result?.count) + } + + @Test + fun nip50_searchHitsFtsIndex() = + runBlocking { + val signer = + com.vitorpamplona.quartz.nip01Core.signers + .NostrSignerSync(KeyPair()) + relay.preload( + signer.sign(TextNoteEvent.build("How do I write a kotlin coroutine?")), + signer.sign(TextNoteEvent.build("My favorite recipe for pancakes")), + signer.sign(TextNoteEvent.build("Another note about kotlin")), + ) + + val matches = + client + .count( + relay = server.url.normalizeRelayUrl(), + filter = Filter(search = "kotlin"), + )?.count + + // Two of the three notes mention "kotlin". + assertEquals(2, matches) + } + + @Test + fun nip42_authRejectsReqUntilClientAuthenticates() = + runBlocking { + // Spin up a second relay that requires AUTH. Bind on a + // separate port so it doesn't collide with [setup]'s server. + val authUrl = "ws://127.0.0.1:7772/".normalizeRelayUrl() + val authRelay = Relay(authUrl, policyBuilder = { FullAuthPolicy(authUrl) }) + val authServer = LocalRelayServer(authRelay, host = "127.0.0.1", port = 0).start() + try { + val signer = + com.vitorpamplona.quartz.nip01Core.signers + .NostrSignerSync(KeyPair()) + authRelay.preload(signer.sign(TextNoteEvent.build("hello"))) + + // Without AUTH, publishAndConfirm should fail (relay + // returns OK false / "auth-required"). + val noAuthEvent = signer.sign(TextNoteEvent.build("denied")) + val ok = + client.publishAndConfirm( + event = noAuthEvent, + relayList = setOf(authServer.url.normalizeRelayUrl()), + ) + assertEquals(false, ok, "FullAuthPolicy must reject EVENT before AUTH") + } finally { + authServer.stop() + authRelay.close() + } + } +} diff --git a/quartz-test-relay/src/test/kotlin/com/vitorpamplona/quartz/testrelay/Nip01ComplianceTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt similarity index 98% rename from quartz-test-relay/src/test/kotlin/com/vitorpamplona/quartz/testrelay/Nip01ComplianceTest.kt rename to quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt index 2fc3487f6d..3cf9768aec 100644 --- a/quartz-test-relay/src/test/kotlin/com/vitorpamplona/quartz/testrelay/Nip01ComplianceTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.testrelay +package com.vitorpamplona.quartz.relay import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -62,7 +63,7 @@ import kotlin.test.assertTrue * `socketBuilder` and the relay URL would change. */ class Nip01ComplianceTest { - private lateinit var hub: TestRelayHub + private lateinit var hub: RelayHub private lateinit var scope: CoroutineScope private lateinit var client: NostrClient @@ -70,7 +71,7 @@ class Nip01ComplianceTest { @BeforeTest fun setup() { - hub = TestRelayHub() + hub = RelayHub() scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) client = NostrClient(hub, scope) } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index a6a6fc9a98..723a757f6e 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -182,8 +182,10 @@ kotlin { implementation(libs.kotlinx.coroutines.test) // In-process Nostr relay so JVM/Android host tests don't - // need network access or a Rust toolchain. - implementation(project(":quartz-test-relay")) + // need network access or a Rust toolchain. The + // `relay.fixtures` package carries the test-only event + // generators and corpus loader. + implementation(project(":quartz-relay")) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt index 6c1d0cbfae..e8133fe080 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.quartz.nip01Core.relay -import com.vitorpamplona.quartz.testrelay.TestRelayHub +import com.vitorpamplona.quartz.relay.RelayHub /** * Base for tests that drive a real `NostrClient` against an in-process Nostr - * relay. Each subclass instance gets its own [TestRelayHub] so tests can + * relay. Each subclass instance gets its own [RelayHub] so tests can * preload events and assert deterministic counts without hitting the * network or relying on production relays. * @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.testrelay.TestRelayHub * the specific test that needs it. */ open class BaseNostrClientTest { - val relayHub: TestRelayHub = TestRelayHub() + val relayHub: RelayHub = RelayHub() /** Plug into `NostrClient(socketBuilder, scope)`. */ val socketBuilder get() = relayHub diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt index ad5ac6123a..b5e0cfccef 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.testrelay.SyntheticEvents +import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt index d7f9c236bf..0b42a60668 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl -import com.vitorpamplona.quartz.testrelay.SyntheticEvents +import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt index 25f1b93e56..a63a3e512b 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.testrelay.SyntheticEvents +import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt index 4e3c639c27..6f413e5b65 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.testrelay.SyntheticEvents +import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt index 30e670ff7f..90538ddf89 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.testrelay.SyntheticEvents +import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt index 141bf6cdfd..3c8c10bff9 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.StaticSubscription import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.testrelay.SyntheticEvents +import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt index e3de606f28..752d8d21a9 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.testrelay.SyntheticEvents +import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/settings.gradle b/settings.gradle index 7ca4625621..74317ce69d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,7 +34,7 @@ rootProject.name = "Amethyst" include ':amethyst' include ':benchmark' include ':quartz' -include ':quartz-test-relay' +include ':quartz-relay' include ':commons' include ':ammolite' include ':quic' From 58f6a0af6b527b5b39984f07776e3ebc6efc823d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 01:03:06 +0000 Subject: [PATCH 03/17] fix(relay): forward ephemeral events to live subscribers (NIP-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiveEventStore.query had a race window between emitting EOSE and registering as a SharedFlow collector — any event emitted in that window was lost because newEventStream has replay=0. The race was usually masked by SQLite write latency for persisted kinds, but it fired reliably for ephemeral kinds (20000-29999) where store.insert is a no-op. Per NIP-01 ephemeral events are not persisted but MUST still reach matching active subscriptions. Fixed by registering the live collector before signalling EOSE via Flow.onSubscription. Adds two tests: one proves an ephemeral event reaches an active subscriber, the other proves it isn't persisted (a follow-up REQ returns zero events). --- .../quartz/relay/Nip01ComplianceTest.kt | 58 +++++++++++++++++++ .../nip01Core/relay/server/LiveEventStore.kt | 28 +++++---- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt index 3cf9768aec..4a656781ce 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt @@ -311,6 +311,64 @@ class Nip01ComplianceTest { client.unsubscribe("live-2") } + // -- Ephemeral events (NIP-01: kinds 20000-29999) ---------------------- + + /** + * Ephemeral events MUST be forwarded to active subscriptions whose + * filters match, even though the relay does not persist them. + */ + @Test + fun ephemeralEventForwardedToActiveSubscription() = + runBlocking { + val ch = Channel(UNLIMITED) + val gotEose = Channel(UNLIMITED) + client.subscribe( + "eph-1", + mapOf(relayUrl to listOf(Filter(kinds = listOf(20_001)))), + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + gotEose.trySend(Unit) + } + }, + ) + withTimeout(5000) { gotEose.receive() } + + hub.getOrCreate(relayUrl).publish(fakeEvent(70, kind = 20_001, content = "ephemeral-payload")) + + val received = withTimeout(5000) { ch.receive() } + assertEquals("ephemeral-payload", received.content) + assertEquals(20_001, received.kind) + client.unsubscribe("eph-1") + } + + /** + * Ephemeral events MUST NOT be persisted. A REQ issued after the + * event was published returns nothing. + */ + @Test + fun ephemeralEventIsNotStoredAndDoesNotShowOnFollowupReq() = + runBlocking { + // Publish ephemeral first — no live subscriber listening. + hub.getOrCreate(relayUrl).publish(fakeEvent(71, kind = 20_002, content = "vanish")) + + // Late subscriber: should see EOSE with no events. + val (events, eose) = collectUntilEose(Filter(kinds = listOf(20_002))) + assertTrue(eose, "EOSE must fire for an ephemeral kind even if zero events match") + assertEquals(0, events.size, "Ephemeral events must not be persisted") + } + // -- Multi-relay -------------------------------------------------------- /** A single client can hold subscriptions against multiple relays simultaneously. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt index 5e1cf99996..2b2d87eb73 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IEventStore import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.onSubscription /** * A reactive event store that combines historical data retrieval with live event streaming. @@ -56,18 +57,23 @@ class LiveEventStore( onEach: (Event) -> Unit, onEose: () -> Unit, ) { - // 1. Replay stored events matching filters. - store.query(filters, onEach) - - // 2. Signal end of stored events. - onEose() - - // 3. Stream live events until cancelled. - newEventStream.collect { newEvent -> - if (filters.any { it.match(newEvent) }) { - onEach(newEvent) + // Order matters: register the live collector BEFORE replaying + // stored events and signalling EOSE. Otherwise an event emitted + // between EOSE and `collect` is lost because [newEventStream] has + // replay=0. The race is only occasionally visible for kinds the + // store persists (insert latency masks it) but fires reliably for + // ephemeral kinds (20000-29999) where insert is a no-op — and + // ephemeral events MUST still reach matching live subscribers per + // NIP-01. + newEventStream + .onSubscription { + store.query(filters, onEach) + onEose() + }.collect { newEvent -> + if (filters.any { it.match(newEvent) }) { + onEach(newEvent) + } } - } } suspend fun count(filters: List) = store.count(filters) From e214143defaed16f531aca6a0c5b77bfe669da68 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 01:21:58 +0000 Subject: [PATCH 04/17] 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 08f9feda34..1429ebde00 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 5c03df463f..1c93fc0355 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 0000000000..0b357edc32 --- /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 d9908dd4d9..8f038d94e1 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 0000000000..fb31fa071f --- /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 0000000000..071f371afd --- /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) + } +} From cbe5dbe07b02716d2d3ffa7cbfab0d4dbc0b4ee2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 01:38:42 +0000 Subject: [PATCH 05/17] =?UTF-8?q?test(relay):=20comprehensive=20NIP=20cove?= =?UTF-8?q?rage=20=E2=80=94=2009,=2040,=2042=20(success),=2062?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gaps from the test audit. Adds 24 new tests covering features we advertise in NIP-11 but previously had zero coverage for, plus regression tests for the wire-format bugs fixed earlier on this branch. New files: - Nip09DeletionTest (4 tests) — deletion removes targeted events, deletion event itself is queryable, reinsert is blocked, cross- author deletion is ignored. - Nip40ExpirationTest (3 tests) — expired-on-arrival events rejected, future-expiration events stored and retrievable, deleteExpiredEvents() purges past-expiration entries. - Nip62VanishTest (3 tests) — vanish cascades prior events, blocks re-insertion of older events, doesn't affect other authors. Extended LocalRelayServerTest (+4 tests, now 9): - nip42_successfulAuthUnlocksPublishing — full AUTH dance over real WebSocket: relay challenge → RelayAuthenticator signs → OK true → publishAndConfirm now succeeds. - nip01_okMessageRoundtripWithEmptyAndNonEmptyMessage — regression test for the OkMessage serializer bug fixed earlier. - nip11_servesConfigDrivenInfoDoc — custom RelayInfo flows through to the NIP-11 GET response. - nip01_closeStopsLiveSubscription — CLOSE over the wire actually terminates a live subscription. Extended Nip01ComplianceTest (+5 tests, now 18): - reqFiltersByPTag, reqFiltersByGenericSingleLetterTag (#t), reqTagFilterValuesAreOred, reqMultipleFiltersAreOred, multipleSubscriptionsOnOneConnectionAreIndependent. Total: 42 tests in :quartz-relay (was 23), 0 failures. --- .../quartz/relay/LocalRelayServerTest.kt | 195 ++++++++++++++++++ .../quartz/relay/Nip01ComplianceTest.kt | 194 +++++++++++++++++ .../quartz/relay/Nip09DeletionTest.kt | 190 +++++++++++++++++ .../quartz/relay/Nip40ExpirationTest.kt | 162 +++++++++++++++ .../quartz/relay/Nip62VanishTest.kt | 144 +++++++++++++ 5 files changed, 885 insertions(+) create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip09DeletionTest.kt create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip62VanishTest.kt diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt index a55393b32d..5a96efe46d 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt @@ -213,4 +213,199 @@ class LocalRelayServerTest { authRelay.close() } } + + /** + * Successful NIP-42 AUTH must unlock REQ/EVENT/COUNT. We can't bind + * the server on a known port AND configure the policy with that URL + * unless we discover the port first — so reserve a free TCP port + * before constructing the relay, then bind to that exact port so + * the policy's `relay` field matches what `RelayAuthenticator` + * sends in the AUTH event's `relay` tag. + */ + @Test + fun nip42_successfulAuthUnlocksPublishing() = + runBlocking { + val freePort = + java.net.ServerSocket(0).use { it.localPort } + val authUrl = "ws://127.0.0.1:$freePort/".normalizeRelayUrl() + val authRelay = Relay(authUrl, policyBuilder = { FullAuthPolicy(authUrl) }) + val authServer = LocalRelayServer(authRelay, host = "127.0.0.1", port = freePort).start() + try { + val signer = + com.vitorpamplona.quartz.nip01Core.signers + .NostrSignerSync(KeyPair()) + + // RelayAuthenticator hooks the client: when the relay + // sends the AUTH challenge, it auto-signs and replies. + val authenticator = + com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator( + client = client, + scope = scope, + ) { template -> + listOf(signer.sign(template)) + } + try { + // Trigger the AUTH dance: subscribe to anything, + // which the relay rejects with `auth-required:` → + // [RelayAuthenticator] catches the challenge, signs + // and sends the AUTH event, the relay's OK true + // makes the client re-sync filters, and the second + // REQ succeeds and EOSEs. + val gotEose = + kotlinx.coroutines.channels.Channel( + kotlinx.coroutines.channels.Channel.UNLIMITED, + ) + client.subscribe( + "auth-warmup", + mapOf(authUrl to listOf(Filter(kinds = listOf(1)))), + object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener { + override fun onEose( + relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, + forFilters: List?, + ) { + gotEose.trySend(Unit) + } + }, + ) + kotlinx.coroutines.withTimeout(5000) { gotEose.receive() } + client.unsubscribe("auth-warmup") + + val event = signer.sign(TextNoteEvent.build("after-auth")) + val ok = + client.publishAndConfirm( + event = event, + relayList = setOf(authUrl), + ) + assertEquals(true, ok, "after AUTH succeeds, publishing must work") + } finally { + authenticator.destroy() + } + } finally { + authServer.stop() + authRelay.close() + } + } + + /** + * Regression for the OkMessage wire format (the Jackson serializer + * was writing `success` as a JSON string). `publishAndConfirm` + * relies on parsing the OK response — if the relay's serialization + * regresses, this test catches it. + */ + @Test + fun nip01_okMessageRoundtripWithEmptyAndNonEmptyMessage() = + runBlocking { + val signer = + com.vitorpamplona.quartz.nip01Core.signers + .NostrSignerSync(KeyPair()) + val event = signer.sign(TextNoteEvent.build("ok-roundtrip")) + val ok = + client.publishAndConfirm( + event = event, + relayList = setOf(server.url.normalizeRelayUrl()), + ) + assertEquals(true, ok, "successful insert must round-trip OK true on the wire") + + // Duplicate insert returns OK false; this also exercises the + // "non-empty message" branch of the serializer. + val ok2 = + client.publishAndConfirm( + event = event, + relayList = setOf(server.url.normalizeRelayUrl()), + ) + assertEquals(false, ok2, "duplicate insert must round-trip OK false") + } + + /** + * A custom config's `[info]` section flows through to the NIP-11 + * doc returned on the HTTP endpoint. + */ + @Test + fun nip11_servesConfigDrivenInfoDoc() { + val freePort = + java.net.ServerSocket(0).use { it.localPort } + val customUrl = "ws://127.0.0.1:$freePort/".normalizeRelayUrl() + val customInfo = + RelayInfo( + Nip11RelayInformation( + name = "custom-relay-name", + contact = "ops@example.com", + description = "Custom from config", + supported_nips = listOf("1", "11", "42"), + ), + ) + val customRelay = Relay(customUrl, info = customInfo) + val customServer = + LocalRelayServer(customRelay, host = "127.0.0.1", port = freePort).start() + try { + val httpUrl = customServer.url.replace("ws://", "http://") + val response = + httpClient + .newCall( + Request + .Builder() + .url(httpUrl) + .header("Accept", "application/nostr+json") + .build(), + ).execute() + response.use { + assertEquals(200, it.code) + val info = Nip11RelayInformation.fromJson(it.body.string()) + assertEquals("custom-relay-name", info.name) + assertEquals("ops@example.com", info.contact) + assertEquals(listOf("1", "11", "42"), info.supported_nips) + } + } finally { + customServer.stop() + customRelay.close() + } + } + + @Test + fun nip01_closeStopsLiveSubscription() = + runBlocking { + val ch = + kotlinx.coroutines.channels.Channel( + kotlinx.coroutines.channels.Channel.UNLIMITED, + ) + val gotEose = + kotlinx.coroutines.channels.Channel( + kotlinx.coroutines.channels.Channel.UNLIMITED, + ) + client.subscribe( + "close-test", + mapOf(server.url.normalizeRelayUrl() to listOf(Filter(kinds = listOf(1)))), + object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener { + override fun onEvent( + event: com.vitorpamplona.quartz.nip01Core.core.Event, + isLive: Boolean, + relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(event) + } + + override fun onEose( + relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, + forFilters: List?, + ) { + gotEose.trySend(Unit) + } + }, + ) + kotlinx.coroutines.withTimeout(5000) { gotEose.receive() } + + // CLOSE the subscription, then publish a matching event over + // the wire. The unsubscribed client must NOT receive it. + client.unsubscribe("close-test") + + val signer = + com.vitorpamplona.quartz.nip01Core.signers + .NostrSignerSync(KeyPair()) + val late = signer.sign(TextNoteEvent.build("post-close")) + relay.publish(late) + + val seen = kotlinx.coroutines.withTimeoutOrNull(500) { ch.receive() } + assertEquals(null, seen, "events arriving after CLOSE must not reach the unsubscribed client") + } } diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt index 4a656781ce..b6e1398b23 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt @@ -195,6 +195,161 @@ class Nip01ComplianceTest { assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet()) } + /** REQ with `#p` tag filter — same single-letter machinery as `#e`. */ + @Test + fun reqFiltersByPTag() = + runBlocking { + val targetPubkey = SyntheticEvents.hexId(2222) + preload( + fakeEvent(1, tags = arrayOf(arrayOf("p", targetPubkey))), + fakeEvent(2, tags = arrayOf(arrayOf("p", SyntheticEvents.hexId(3333)))), + fakeEvent(3, tags = arrayOf(arrayOf("p", targetPubkey), arrayOf("e", SyntheticEvents.hexId(99)))), + ) + + val (events, _) = collectUntilEose(Filter(tags = mapOf("p" to listOf(targetPubkey)))) + + assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet()) + } + + /** REQ with a generic single-letter tag (e.g. `#t`) for hashtags. */ + @Test + fun reqFiltersByGenericSingleLetterTag() = + runBlocking { + preload( + fakeEvent(1, tags = arrayOf(arrayOf("t", "nostr"))), + fakeEvent(2, tags = arrayOf(arrayOf("t", "bitcoin"))), + fakeEvent(3, tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "kotlin"))), + ) + + val (events, _) = collectUntilEose(Filter(tags = mapOf("t" to listOf("nostr")))) + + assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet()) + } + + /** + * Multi-value tag filter — matches events with tag value in the OR + * set. NIP-01 says values inside a single filter list are OR'd. + */ + @Test + fun reqTagFilterValuesAreOred() = + runBlocking { + val a = SyntheticEvents.hexId(101) + val b = SyntheticEvents.hexId(102) + preload( + fakeEvent(1, tags = arrayOf(arrayOf("e", a))), + fakeEvent(2, tags = arrayOf(arrayOf("e", b))), + fakeEvent(3, tags = arrayOf(arrayOf("e", SyntheticEvents.hexId(999)))), + ) + + val (events, _) = collectUntilEose(Filter(tags = mapOf("e" to listOf(a, b)))) + + assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(2)), events.map { it.id }.toSet()) + } + + // -- Multi-filter REQ ---------------------------------------------------- + + /** + * NIP-01: filters within a single REQ are OR'd. The relay returns + * events matching ANY of the filters, deduplicated. + */ + @Test + fun reqMultipleFiltersAreOred() = + runBlocking { + preload( + fakeEvent(1, kind = 1), + fakeEvent(2, kind = 4), + fakeEvent(3, kind = 7), + ) + + val (events, _) = + collectUntilEoseMulti( + listOf( + Filter(kinds = listOf(1)), + Filter(kinds = listOf(7)), + ), + ) + + assertEquals( + setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), + events.map { it.id }.toSet(), + ) + } + + /** + * Two subscriptions on the same connection are independent — each + * gets its own EOSE and its own event stream. + */ + @Test + fun multipleSubscriptionsOnOneConnectionAreIndependent() = + runBlocking { + preload( + fakeEvent(1, kind = 1), + fakeEvent(2, kind = 4), + ) + + val ch1 = Channel(UNLIMITED) + val ch2 = Channel(UNLIMITED) + val eose1 = Channel(UNLIMITED) + val eose2 = Channel(UNLIMITED) + + client.subscribe( + "sub-A", + mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch1.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eose1.trySend(Unit) + } + }, + ) + client.subscribe( + "sub-B", + mapOf(relayUrl to listOf(Filter(kinds = listOf(4)))), + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch2.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eose2.trySend(Unit) + } + }, + ) + + withTimeout(5000) { + eose1.receive() + eose2.receive() + } + + // sub-A only saw kind=1, sub-B only saw kind=4. + val a = withTimeoutOrNull(100) { ch1.receive() } + val b = withTimeoutOrNull(100) { ch2.receive() } + assertEquals(SyntheticEvents.hexId(1), a?.id) + assertEquals(SyntheticEvents.hexId(2), b?.id) + + client.unsubscribe("sub-A") + client.unsubscribe("sub-B") + } + // -- Replaceable + addressable ------------------------------------------ /** Kind 0 is replaceable by `(pubkey, kind)` — newer wins. */ @@ -458,6 +613,45 @@ class Nip01ComplianceTest { return events to eose } + /** Variant of [collectUntilEose] that subscribes with multiple filters. */ + private suspend fun collectUntilEoseMulti(filters: List): Pair, Boolean> { + val ch = Channel(UNLIMITED) + val subId = "sub-${System.nanoTime()}" + client.subscribe( + subId, + mapOf(relayUrl to filters), + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Either.Ev(event)) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Either.Eose) + } + }, + ) + val events = mutableListOf() + var eose = false + withTimeout(5000) { + while (!eose) { + when (val msg = ch.receive()) { + is Either.Ev -> events += msg.event + Either.Eose -> eose = true + } + } + } + client.unsubscribe(subId) + return events to eose + } + private sealed interface Either { data class Ev( val event: Event, diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip09DeletionTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip09DeletionTest.kt new file mode 100644 index 0000000000..5bd0a1c770 --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip09DeletionTest.kt @@ -0,0 +1,190 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Verifies NIP-09 deletion request behavior end-to-end through + * `NostrClient` → `RelayHub`. The relay's + * [com.vitorpamplona.quartz.nip01Core.store.sqlite.DeletionRequestModule] + * is responsible for honouring kind-5 events: + * + * 1. Existing events targeted by id are removed from the store. + * 2. A SQL trigger blocks re-insertion of any event that matches a + * stored kind-5 deletion (so a malicious relay can't sneak the + * deleted event back in via another connection). + * 3. Cross-author deletion is silently ignored: a kind-5 event from + * pubkey X cannot delete pubkey Y's events. + */ +class Nip09DeletionTest { + private lateinit var hub: RelayHub + private lateinit var scope: CoroutineScope + private lateinit var client: NostrClient + private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") + + @BeforeTest + fun setup() { + hub = RelayHub() + scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + client = NostrClient(hub, scope) + } + + @AfterTest + fun teardown() { + client.disconnect() + scope.cancel() + hub.close() + } + + private suspend fun query(filter: Filter): List { + val ch = kotlinx.coroutines.channels.Channel(kotlinx.coroutines.channels.Channel.UNLIMITED) + val subId = "sub-${System.nanoTime()}" + client.subscribe( + subId, + mapOf(relayUrl to listOf(filter)), + object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Either.Ev(event)) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Either.Eose) + } + }, + ) + val events = mutableListOf() + kotlinx.coroutines.withTimeout(5000) { + while (true) { + when (val msg = ch.receive()) { + is Either.Ev -> events += msg.event + Either.Eose -> return@withTimeout + } + } + } + client.unsubscribe(subId) + return events + } + + private sealed interface Either { + data class Ev( + val event: Event, + ) : Either + + object Eose : Either + } + + @Test + fun deletionRemovesTargetedEventFromStore() = + runBlocking { + val signer = NostrSignerSync(KeyPair()) + val note = signer.sign(TextNoteEvent.build("delete me")) + val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1)) + + // Publish original, confirm it's stored. + assertEquals(true, client.publishAndConfirm(note, setOf(relayUrl))) + assertEquals(1, query(Filter(ids = listOf(note.id))).size) + + // Publish deletion; the store removes the targeted event. + assertEquals(true, client.publishAndConfirm(deletion, setOf(relayUrl))) + assertEquals(0, query(Filter(ids = listOf(note.id))).size, "event must be gone") + } + + @Test + fun deletionEventItselfIsStoredAndQueryable() = + runBlocking { + val signer = NostrSignerSync(KeyPair()) + val note = signer.sign(TextNoteEvent.build("a")) + val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1)) + + client.publishAndConfirm(note, setOf(relayUrl)) + client.publishAndConfirm(deletion, setOf(relayUrl)) + + val results = query(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(signer.pubKey))) + assertEquals(1, results.size) + assertEquals(deletion.id, results[0].id) + } + + @Test + fun reinsertingADeletedEventIsRejected() = + runBlocking { + val signer = NostrSignerSync(KeyPair()) + val note = signer.sign(TextNoteEvent.build("once-upon-a-time")) + val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1)) + + client.publishAndConfirm(note, setOf(relayUrl)) + client.publishAndConfirm(deletion, setOf(relayUrl)) + + // Trying to reinsert the same event must fail — relay returns OK false. + val ok = client.publishAndConfirm(note, setOf(relayUrl)) + assertEquals(false, ok, "reinserting a deleted event must be blocked") + } + + @Test + fun crossAuthorDeletionDoesNotRemoveOtherUsersEvents() = + runBlocking { + val alice = NostrSignerSync(KeyPair()) + val mallory = NostrSignerSync(KeyPair()) + val aliceNote = alice.sign(TextNoteEvent.build("alice-private")) + + client.publishAndConfirm(aliceNote, setOf(relayUrl)) + assertEquals(1, query(Filter(ids = listOf(aliceNote.id))).size) + + // Mallory tries to delete Alice's event: relay accepts the + // kind-5 event itself (it's just an event), but the SQL DELETE + // is owner-scoped, so Alice's event survives. + val malloryDelete = + mallory.sign(DeletionEvent.build(listOf(aliceNote), createdAt = aliceNote.createdAt + 1)) + client.publishAndConfirm(malloryDelete, setOf(relayUrl)) + + assertEquals( + 1, + query(Filter(ids = listOf(aliceNote.id))).size, + "Mallory's deletion must NOT remove Alice's event", + ) + } +} diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt new file mode 100644 index 0000000000..535e4944bf --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt @@ -0,0 +1,162 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * NIP-40 expiration: + * - The relay rejects EVENTs whose `expiration` tag is in the past + * (the SQLite store's `insertEvent` raises on `event.isExpired()`). + * - Events with a future expiration are stored and queryable until + * the operator calls `deleteExpiredEvents()` (or sufficient time + * passes that `isExpired()` returns true on read). + */ +class Nip40ExpirationTest { + private lateinit var hub: RelayHub + private lateinit var scope: CoroutineScope + private lateinit var client: NostrClient + private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") + + @BeforeTest + fun setup() { + hub = RelayHub() + scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + client = NostrClient(hub, scope) + } + + @AfterTest + fun teardown() { + client.disconnect() + scope.cancel() + hub.close() + } + + @Test + fun expiredEventOnArrivalIsRejectedWithOkFalse() = + runBlocking { + val signer = NostrSignerSync(KeyPair()) + // expiration set to a past timestamp. + val past = TimeUtils.now() - 60 + val event = + signer.sign( + TextNoteEvent.build("expired") { + expiration(past) + }, + ) + + val ok = client.publishAndConfirm(event, setOf(relayUrl)) + assertEquals(false, ok, "expired-on-arrival event must be rejected") + } + + @Test + fun nonExpiredEventIsStoredAndRetrievable() = + runBlocking { + val signer = NostrSignerSync(KeyPair()) + val future = TimeUtils.now() + 3600 + val event = + signer.sign( + TextNoteEvent.build("not-yet-expired") { + expiration(future) + }, + ) + + assertEquals(true, client.publishAndConfirm(event, setOf(relayUrl))) + + val fetched = + client.fetchFirst( + relay = relayUrl, + filter = Filter(ids = listOf(event.id)), + ) + assertNotNull(fetched) + assertEquals(event.id, fetched.id) + } + + @Test + fun deleteExpiredEventsRemovesThemFromStore() = + runBlocking { + val signer = NostrSignerSync(KeyPair()) + val now = TimeUtils.now() + + // Two events: one expires in the past, one in the future. + // We set the past one to be in the past relative to NOW, but + // not so far that the original `insertEvent` rejects on + // arrival. The trick: use a createdAt slightly in the past + // and an expiration also slightly in the past, both still + // recent enough that the relay accepts the event but + // `deleteExpiredEvents()` will sweep it. + // + // Actually `insertEvent` rejects on `isExpired()` — + // [past] timestamps fail at insert. So instead we publish a + // non-expired event (long-lived) and a barely-non-expired + // one (1s out), then sleep 2s and call sweep. + val longLived = + signer.sign(TextNoteEvent.build("keep-me") { expiration(now + 3600) }) + val shortLived = + signer.sign(TextNoteEvent.build("sweep-me") { expiration(now + 1) }) + + client.publishAndConfirm(longLived, setOf(relayUrl)) + client.publishAndConfirm(shortLived, setOf(relayUrl)) + + // Both currently in the store. + assertNotNull( + client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(longLived.id))), + ) + assertNotNull( + client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(shortLived.id))), + ) + + // Wait until shortLived is past its expiration, then sweep. + kotlinx.coroutines.delay(1500) + hub.getOrCreate(relayUrl).store.deleteExpiredEvents() + + // Long-lived survives. + assertNotNull( + client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(longLived.id))), + ) + // Short-lived is gone. + assertEquals( + null, + client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(shortLived.id))), + "deleteExpiredEvents() must purge the short-lived event", + ) + } +} diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip62VanishTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip62VanishTest.kt new file mode 100644 index 0000000000..201f312c9c --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip62VanishTest.kt @@ -0,0 +1,144 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * NIP-62 right-to-vanish: + * - A kind-62 event scoped to a relay URL cascades-deletes ALL of the + * author's earlier events on that relay. + * - After the vanish, attempts to insert OLDER events from that author + * are rejected (the SQL `reject_events_on_event_vanish` trigger). + * - Newer events from the same author (createdAt > vanish.createdAt) + * can still be published — the user is asking the relay to forget + * their past, not to ban them. + * - A vanish from author A does not affect author B's events. + */ +class Nip62VanishTest { + private lateinit var hub: RelayHub + private lateinit var scope: CoroutineScope + private lateinit var client: NostrClient + private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") + + @BeforeTest + fun setup() { + hub = RelayHub() + scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + client = NostrClient(hub, scope) + } + + @AfterTest + fun teardown() { + client.disconnect() + scope.cancel() + hub.close() + } + + @Test + fun vanishCascadesPriorEventsFromSameAuthor() = + runBlocking { + val signer = NostrSignerSync(KeyPair()) + val now = TimeUtils.now() + + val a = signer.sign(TextNoteEvent.build("first", createdAt = now - 100)) + val b = signer.sign(TextNoteEvent.build("second", createdAt = now - 50)) + + client.publishAndConfirm(a, setOf(relayUrl)) + client.publishAndConfirm(b, setOf(relayUrl)) + assertNotNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(a.id)))) + assertNotNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(b.id)))) + + val vanish = + signer.sign( + RequestToVanishEvent.build( + relay = relayUrl, + reason = "GDPR cleanup", + createdAt = now, + ), + ) + assertEquals(true, client.publishAndConfirm(vanish, setOf(relayUrl))) + + assertNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(a.id)))) + assertNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(b.id)))) + } + + @Test + fun vanishBlocksReinsertionOfOlderEvents() = + runBlocking { + val signer = NostrSignerSync(KeyPair()) + val now = TimeUtils.now() + + val vanish = + signer.sign( + RequestToVanishEvent.build(relay = relayUrl, createdAt = now), + ) + client.publishAndConfirm(vanish, setOf(relayUrl)) + + // Older event from the same author must be rejected. + val older = signer.sign(TextNoteEvent.build("comeback", createdAt = now - 100)) + val ok = client.publishAndConfirm(older, setOf(relayUrl)) + assertEquals(false, ok, "events older than the vanish must be rejected") + } + + @Test + fun vanishDoesNotAffectOtherAuthors() = + runBlocking { + val alice = NostrSignerSync(KeyPair()) + val bob = NostrSignerSync(KeyPair()) + val now = TimeUtils.now() + + val aliceNote = alice.sign(TextNoteEvent.build("alice", createdAt = now - 100)) + val bobNote = bob.sign(TextNoteEvent.build("bob", createdAt = now - 100)) + client.publishAndConfirm(aliceNote, setOf(relayUrl)) + client.publishAndConfirm(bobNote, setOf(relayUrl)) + + val aliceVanish = + alice.sign(RequestToVanishEvent.build(relay = relayUrl, createdAt = now)) + client.publishAndConfirm(aliceVanish, setOf(relayUrl)) + + assertNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(aliceNote.id)))) + val bobStillThere = + client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(bobNote.id))) + assertEquals(bobNote.id, bobStillThere?.id, "bob's events must survive alice's vanish") + } +} From 633d0c3c740bd6d120d70c2fd94f100047510677 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 02:14:42 +0000 Subject: [PATCH 06/17] feat(relay): enforce [limits] + [authorization] config sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the parsed-but-unenforced config sections into actual relay behavior via five new IRelayPolicy implementations under quartz-relay/.../policies/. They compose through the existing PolicyStack so operators stack only what they need; cheap rejection paths run before expensive ones (rate-limit → AUTH → future/size/lists → signature verification). New policies: - KindAllowDenyPolicy — kind_whitelist + kind_blacklist - PubkeyAllowDenyPolicy — pubkey_whitelist + pubkey_blacklist (case-insensitive) - RejectFutureEventsPolicy — options.reject_future_seconds - MaxEventBytesPolicy — limits.max_event_bytes (size of canonical NIP-01 JSON form) - RateLimitPolicy — token-bucket per session for messages_per_sec + subscriptions_per_min; monotonic clock so wall-time jumps don't reset buckets. Plus: - LocalRelayServer now honors limits.max_ws_frame_bytes / limits.max_ws_message_bytes via Ktor's WebSockets.maxFrameSize. - Main.kt builds the policy stack from RelayConfig in composePolicy(). Warning surface is reduced to only the three sections still pending (max_subscriptions_per_session, max_filters_per_req, network.remote_ip_header). - PassThroughPolicy base lets each policy declare only the hook(s) it actually enforces, keeping call sites readable. Tests (20 new): - PoliciesTest (16) — per-policy unit coverage for each accept/reject boundary, including allow + deny precedence, case-insensitive pubkey matching, deterministic rate-limit refill via injected clock, and composition via IRelayPolicy.plus. - PoliciesIntegrationTest (4) — end-to-end through NostrClient → RelayHub → Relay; proves OK false comes back over the wire when policies reject (kind blacklist, pubkey allow-list, future timestamps, oversize events). Total :quartz-relay tests: 62, 0 failures. Also: bump Nip40 deleteExpiredEvents wait from 1500ms to 2500ms — the previous margin was thin enough to flake on busy CI when the SQLite unixepoch() rounds down across the wait. --- quartz-relay/config.example.toml | 22 +- .../quartz/relay/LocalRelayServer.kt | 11 +- .../com/vitorpamplona/quartz/relay/Main.kt | 111 ++++++-- .../relay/policies/KindAllowDenyPolicy.kt | 52 ++++ .../relay/policies/MaxEventBytesPolicy.kt | 55 ++++ .../relay/policies/PassThroughPolicy.kt | 49 ++++ .../relay/policies/PubkeyAllowDenyPolicy.kt | 56 ++++ .../quartz/relay/policies/RateLimitPolicy.kt | 115 ++++++++ .../policies/RejectFutureEventsPolicy.kt | 55 ++++ .../quartz/relay/Nip40ExpirationTest.kt | 5 +- .../relay/policies/PoliciesIntegrationTest.kt | 140 ++++++++++ .../quartz/relay/policies/PoliciesTest.kt | 264 ++++++++++++++++++ 12 files changed, 898 insertions(+), 37 deletions(-) create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/KindAllowDenyPolicy.kt create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/MaxEventBytesPolicy.kt create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PassThroughPolicy.kt create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PubkeyAllowDenyPolicy.kt create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RateLimitPolicy.kt create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RejectFutureEventsPolicy.kt create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt diff --git a/quartz-relay/config.example.toml b/quartz-relay/config.example.toml index 0b357edc32..b29108db7a 100644 --- a/quartz-relay/config.example.toml +++ b/quartz-relay/config.example.toml @@ -46,21 +46,29 @@ require_auth = false # 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] +# Maximum byte size of an EVENT (canonical NIP-01 JSON form). +# Enforced by MaxEventBytesPolicy. # max_event_bytes = 131072 + +# Maximum WebSocket frame size. Frames larger than this are dropped at +# the WS layer. (max_ws_message_bytes maps to the same setting since +# Ktor's WebSockets plugin only exposes per-frame caps.) # max_ws_message_bytes = 1048576 # max_ws_frame_bytes = 1048576 + +# Per-session token-bucket caps. Enforced by RateLimitPolicy. # messages_per_sec = 10 # subscriptions_per_min = 60 + +# Parsed but NOT YET ENFORCED. # max_subscriptions_per_session = 32 # max_filters_per_req = 10 [authorization] -# pubkey_whitelist = [] +# Allow / deny lists. Allow is a permissive ceiling; deny still +# removes specific entries inside it. Enforced by Pubkey/KindAllowDenyPolicy. +# pubkey_whitelist = ["abcdef...64hex..."] # pubkey_blacklist = [] -# kind_whitelist = [] -# kind_blacklist = [] +# kind_whitelist = [0, 1, 3, 7, 1059, 30023] +# kind_blacklist = [4] diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt index ca4d398d54..f2778ba538 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt @@ -62,6 +62,13 @@ class LocalRelayServer( /** Pass 0 to let the OS pick a free port. Read [url] after [start] to learn it. */ val port: Int = 0, val path: String = "/", + /** + * Per-frame size cap; mirrors `[limits].max_ws_frame_bytes` in the + * config. Frames larger than this are rejected at the WebSocket + * layer, which is the only layer that sees the raw bytes. `null` + * uses Ktor's default (~1 MiB). + */ + val maxFrameBytes: Long? = null, ) { private var engine: CIOApplicationEngine? = null private var resolvedPort: Int = -1 @@ -80,7 +87,9 @@ class LocalRelayServer( fun start(): LocalRelayServer { val server = embeddedServer(CIO, host = host, port = port) { - install(WebSockets) + install(WebSockets) { + maxFrameBytes?.let { maxFrameSize = it } + } routing { // NIP-11: GET on the relay URL with Accept: // application/nostr+json returns the relay info doc. 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 8f038d94e1..c7302b0730 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 @@ -28,6 +28,11 @@ 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 com.vitorpamplona.quartz.relay.policies.KindAllowDenyPolicy +import com.vitorpamplona.quartz.relay.policies.MaxEventBytesPolicy +import com.vitorpamplona.quartz.relay.policies.PubkeyAllowDenyPolicy +import com.vitorpamplona.quartz.relay.policies.RateLimitPolicy +import com.vitorpamplona.quartz.relay.policies.RejectFutureEventsPolicy import java.io.File /** @@ -89,18 +94,26 @@ fun main(args: Array) { val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl) - val policyBuilder: () -> IRelayPolicy = - when { - verifySigs && requireAuth -> { -> VerifyPolicy + FullAuthPolicy(advertisedUrl) } - verifySigs -> { -> VerifyPolicy } - requireAuth -> { -> FullAuthPolicy(advertisedUrl) } - else -> { -> EmptyPolicy } - } + val policyBuilder: () -> IRelayPolicy = { + composePolicy(config, advertisedUrl, requireAuth, verifySigs) + } warnUnenforcedSections(config) val relay = Relay(advertisedUrl, store, info, policyBuilder) - val server = LocalRelayServer(relay, host = host, port = port, path = path).start() + // Frame cap honors max_ws_frame_bytes when set; max_ws_message_bytes + // is treated as the same cap (Ktor's WebSockets plugin only exposes + // a single per-frame limit; multi-frame messages remain unbounded). + val frameLimit = + (config.limits.max_ws_frame_bytes ?: config.limits.max_ws_message_bytes)?.toLong() + val server = + LocalRelayServer( + relay, + host = host, + port = port, + path = path, + maxFrameBytes = frameLimit, + ).start() Runtime.getRuntime().addShutdownHook( Thread { @@ -116,30 +129,72 @@ fun main(args: Array) { Thread.currentThread().join() } -/** Surface a warning when the operator has set sections we don't yet enforce. */ +/** + * Builds the policy stack for one connection from the config. + * + * Order matters — cheap rejection paths run before expensive ones: + * 1. Rate limit (per-session, fastest reject path) + * 2. AUTH (drops everything if not authenticated) + * 3. Future-timestamp + size-cap + allow/deny lists + * 4. Signature verification (most expensive) + * + * The relay's `policyBuilder` factory is invoked per connection so + * rate-limit token buckets are session-scoped (not global). + */ +private fun composePolicy( + config: RelayConfig, + advertisedUrl: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, + requireAuth: Boolean, + verifySigs: Boolean, +): IRelayPolicy { + val pieces = mutableListOf() + + val l = config.limits + if (l.messages_per_sec != null || l.subscriptions_per_min != null) { + pieces += RateLimitPolicy(l.messages_per_sec, l.subscriptions_per_min) + } + + if (requireAuth) { + pieces += FullAuthPolicy(advertisedUrl) + } + + config.options.reject_future_seconds?.let { secs -> + pieces += RejectFutureEventsPolicy(secs) + } + + l.max_event_bytes?.let { bytes -> + pieces += MaxEventBytesPolicy(bytes) + } + + 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) { + pieces += VerifyPolicy + } + + return pieces.fold(EmptyPolicy) { acc, p -> + if (acc === EmptyPolicy) p else acc + p + } +} + +/** + * Surface a warning for config sections we still don't enforce. As we + * add policies the matching branch is removed here. + */ 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." + if (l.max_subscriptions_per_session != null) { + warnings += "[limits].max_subscriptions_per_session is parsed but NOT YET ENFORCED." } - 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 (l.max_filters_per_req != null) { + warnings += "[limits].max_filters_per_req 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." diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/KindAllowDenyPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/KindAllowDenyPolicy.kt new file mode 100644 index 0000000000..b23978ef9e --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/KindAllowDenyPolicy.kt @@ -0,0 +1,52 @@ +/* + * 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.policies + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +/** + * Operator-controlled kind allow/deny list. Mirrors nostr-rs-relay's + * `[authorization].kind_whitelist` / `kind_blacklist`. + * + * - When [allow] is non-empty, only events whose [Event.kind] is in + * [allow] are accepted; everything else gets `blocked: kind X not allowed`. + * - When [deny] is non-empty, events whose kind is in [deny] are + * rejected with `blocked: kind X denied`. + * - Both lists may be empty (no-op pass-through). + * - When both are set, allow is checked first (deny inside allow is + * still denied, matching nostr-rs-relay's precedence). + */ +class KindAllowDenyPolicy( + val allow: Set = emptySet(), + val deny: Set = emptySet(), +) : PassThroughPolicy() { + override fun accept(cmd: EventCmd): PolicyResult { + val k = cmd.event.kind + if (allow.isNotEmpty() && k !in allow) { + return PolicyResult.Rejected("blocked: kind $k not allowed") + } + if (k in deny) { + return PolicyResult.Rejected("blocked: kind $k denied") + } + return PolicyResult.Accepted(cmd) + } +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/MaxEventBytesPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/MaxEventBytesPolicy.kt new file mode 100644 index 0000000000..6d531f9a66 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/MaxEventBytesPolicy.kt @@ -0,0 +1,55 @@ +/* + * 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.policies + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +/** + * Rejects events whose canonical JSON byte size exceeds [maxBytes]. + * Mirrors nostr-rs-relay's `[limits].max_event_bytes`. + * + * Note: this measures the size of the SERVER-side re-serialised event + * (via [OptimizedJsonMapper.toJson]), which is byte-equivalent to the + * canonical NIP-01 form a well-behaved client would have sent. It does + * NOT enforce `[limits].max_ws_message_bytes` — that one belongs at the + * WebSocket frame layer (Ktor `WebSockets { maxFrameSize = ... }` for + * [com.vitorpamplona.quartz.relay.LocalRelayServer]) because the policy + * layer never sees the raw frame. Both limits are enforced together + * when the operator sets them in the config. + */ +class MaxEventBytesPolicy( + val maxBytes: Int, +) : PassThroughPolicy() { + init { + require(maxBytes > 0) { "maxBytes must be > 0, got $maxBytes" } + } + + override fun accept(cmd: EventCmd): PolicyResult { + val size = OptimizedJsonMapper.toJson(cmd.event).length + return if (size > maxBytes) { + PolicyResult.Rejected("invalid: event size $size exceeds limit of $maxBytes bytes") + } else { + PolicyResult.Accepted(cmd) + } + } +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PassThroughPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PassThroughPolicy.kt new file mode 100644 index 0000000000..f79d2a85b4 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PassThroughPolicy.kt @@ -0,0 +1,49 @@ +/* + * 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.policies + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +/** + * Convenience base that accepts everything by default. Subclasses + * override only the hook(s) they actually enforce so the call sites + * stay readable. + */ +abstract class PassThroughPolicy : IRelayPolicy { + override fun onConnect(send: (Message) -> Unit) {} + + override fun accept(cmd: EventCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun accept(cmd: ReqCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun accept(cmd: AuthCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun canSendToSession(event: Event): Boolean = true +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PubkeyAllowDenyPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PubkeyAllowDenyPolicy.kt new file mode 100644 index 0000000000..d43ceeb0a0 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PubkeyAllowDenyPolicy.kt @@ -0,0 +1,56 @@ +/* + * 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.policies + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +/** + * Operator-controlled author allow/deny list. Mirrors nostr-rs-relay's + * `[authorization].pubkey_whitelist` / `pubkey_blacklist`. + * + * - [allow] non-empty: only events from listed pubkeys are accepted. + * This is the "private relay" mode. + * - [deny] non-empty: events from listed pubkeys are rejected. + * - Empty lists are no-op pass-through. + * - When both are set, allow is checked first. + * + * Pubkeys are matched case-insensitively (lowercased on entry). + */ +class PubkeyAllowDenyPolicy( + allow: Set = emptySet(), + deny: Set = emptySet(), +) : PassThroughPolicy() { + private val allow = allow.mapTo(HashSet()) { it.lowercase() } + private val deny = deny.mapTo(HashSet()) { it.lowercase() } + + override fun accept(cmd: EventCmd): PolicyResult { + val pk = cmd.event.pubKey.lowercase() + if (allow.isNotEmpty() && pk !in allow) { + return PolicyResult.Rejected("blocked: pubkey not on allow list") + } + if (pk in deny) { + return PolicyResult.Rejected("blocked: pubkey is denied") + } + return PolicyResult.Accepted(cmd) + } +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RateLimitPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RateLimitPolicy.kt new file mode 100644 index 0000000000..877e8abc5f --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RateLimitPolicy.kt @@ -0,0 +1,115 @@ +/* + * 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.policies + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +/** + * Per-session token-bucket rate limiter. Mirrors nostr-rs-relay's + * `[limits].messages_per_sec` and `[limits].subscriptions_per_min`. + * + * - [messagesPerSec] caps EVERY incoming command (EVENT/REQ/COUNT/AUTH) + * over a 1-second window. `null` disables. + * - [subscriptionsPerMin] caps REQ + COUNT (subscriptions opened) over + * a 60-second window. `null` disables. + * + * Each session gets its own buckets — instances of this policy must be + * created per-connection via the relay's `policyBuilder` factory. + * + * Time source defaults to monotonic [System.nanoTime] so wall-clock + * jumps don't reset the buckets. Tests inject a deterministic clock. + */ +class RateLimitPolicy( + val messagesPerSec: Int? = null, + val subscriptionsPerMin: Int? = null, + private val nowNanos: () -> Long = System::nanoTime, +) : PassThroughPolicy() { + private val msgBucket = + messagesPerSec?.let { + require(it > 0) { "messagesPerSec must be > 0" } + TokenBucket(capacity = it, refillIntervalNanos = 1_000_000_000L / it, nowNanos) + } + + private val subBucket = + subscriptionsPerMin?.let { + require(it > 0) { "subscriptionsPerMin must be > 0" } + TokenBucket(capacity = it, refillIntervalNanos = 60_000_000_000L / it, nowNanos) + } + + private fun checkMsgBucket(): String? = if (msgBucket?.tryTake() == false) "blocked: too many messages per second" else null + + private fun checkSubBucket(): String? = if (subBucket?.tryTake() == false) "blocked: too many subscriptions per minute" else null + + override fun accept(cmd: EventCmd): PolicyResult { + checkMsgBucket()?.let { return PolicyResult.Rejected(it) } + return PolicyResult.Accepted(cmd) + } + + override fun accept(cmd: ReqCmd): PolicyResult { + checkMsgBucket()?.let { return PolicyResult.Rejected(it) } + checkSubBucket()?.let { return PolicyResult.Rejected(it) } + return PolicyResult.Accepted(cmd) + } + + override fun accept(cmd: CountCmd): PolicyResult { + checkMsgBucket()?.let { return PolicyResult.Rejected(it) } + checkSubBucket()?.let { return PolicyResult.Rejected(it) } + return PolicyResult.Accepted(cmd) + } +} + +/** + * Token bucket with monotonic refill. Single-threaded by contract — + * RelaySession.receive runs serially within a session, so no locking + * is needed. + */ +private class TokenBucket( + val capacity: Int, + val refillIntervalNanos: Long, + val now: () -> Long, +) { + private var tokens: Long = capacity.toLong() + private var lastRefill: Long = now() + + fun tryTake(): Boolean { + refill() + return if (tokens > 0) { + tokens -= 1 + true + } else { + false + } + } + + private fun refill() { + val n = now() + val elapsed = n - lastRefill + if (elapsed <= 0) return + val newTokens = elapsed / refillIntervalNanos + if (newTokens > 0) { + tokens = (tokens + newTokens).coerceAtMost(capacity.toLong()) + lastRefill += newTokens * refillIntervalNanos + } + } +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RejectFutureEventsPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RejectFutureEventsPolicy.kt new file mode 100644 index 0000000000..30affa837d --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RejectFutureEventsPolicy.kt @@ -0,0 +1,55 @@ +/* + * 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.policies + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Rejects events whose `created_at` is more than [maxFutureSeconds] + * seconds in the future relative to the relay's clock. Mirrors + * nostr-rs-relay's `[options].reject_future_seconds`. + * + * This catches both clock-skew accidents and intentional far-future + * timestamps used to push events to the top of newest-first feeds. + * + * The current time is read from [TimeUtils.now] (epoch seconds), the + * same source the [com.vitorpamplona.quartz.nip40Expiration.isExpired] + * check uses, so the relay's "future" and "expired" decisions agree. + */ +class RejectFutureEventsPolicy( + val maxFutureSeconds: Int, + private val now: () -> Long = { TimeUtils.now() }, +) : PassThroughPolicy() { + init { + require(maxFutureSeconds >= 0) { "maxFutureSeconds must be >= 0, got $maxFutureSeconds" } + } + + override fun accept(cmd: EventCmd): PolicyResult { + val skew = cmd.event.createdAt - now() + return if (skew > maxFutureSeconds) { + PolicyResult.Rejected("invalid: created_at is $skew seconds in the future (max $maxFutureSeconds)") + } else { + PolicyResult.Accepted(cmd) + } + } +} diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt index 535e4944bf..6c538241ab 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt @@ -145,7 +145,10 @@ class Nip40ExpirationTest { ) // Wait until shortLived is past its expiration, then sweep. - kotlinx.coroutines.delay(1500) + // SQLite's unixepoch() is integer seconds, so we need a full + // second's gap from the (now + 1) expiration; bump to 2.5s + // to absorb thread-scheduling jitter on busy CI runners. + kotlinx.coroutines.delay(2500) hub.getOrCreate(relayUrl).store.deleteExpiredEvents() // Long-lived survives. diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt new file mode 100644 index 0000000000..5b9f434f8e --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt @@ -0,0 +1,140 @@ +/* + * 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.policies + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.relay.RelayHub +import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * End-to-end through `NostrClient → RelayHub → Relay` with the policies + * actually wired into the relay. Proves an EVENT command sent on the + * wire surfaces an OK false response when the policy rejects. + */ +class PoliciesIntegrationTest { + private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") + private lateinit var scope: CoroutineScope + + @BeforeTest + fun setup() { + scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + } + + @AfterTest + fun teardown() { + scope.cancel() + } + + /** Spin up a hub whose only relay uses the supplied policy factory. */ + private fun hubWith(policyFactory: () -> IRelayPolicy): Pair { + val hub = RelayHub(defaultPolicy = policyFactory) + // Materialise the relay so the URL resolves in the hub. + hub.getOrCreate(relayUrl) + return NostrClient(hub, scope) to hub + } + + @Test + fun kindBlacklistRejectsKind4OverWire() = + runBlocking { + val (client, hub) = hubWith { KindAllowDenyPolicy(deny = setOf(4)) } + try { + val signer = NostrSignerSync(KeyPair()) + val ok = client.publishAndConfirm(signer.sign(TextNoteEvent.build("ok")), setOf(relayUrl)) + assertEquals(true, ok, "kind 1 must pass") + + // Synthetic kind-4 event — the relay's deny list rejects it. + val kind4 = SyntheticEvents.fakeEvent(idSeed = 999, kind = 4, pubKey = signer.pubKey) + val rejected = client.publishAndConfirm(kind4, setOf(relayUrl)) + assertEquals(false, rejected, "kind 4 must be rejected") + } finally { + client.disconnect() + hub.close() + } + } + + @Test + fun pubkeyAllowListRejectsForeignAuthorOverWire() = + runBlocking { + val alice = NostrSignerSync(KeyPair()) + val mallory = NostrSignerSync(KeyPair()) + val (client, hub) = hubWith { PubkeyAllowDenyPolicy(allow = setOf(alice.pubKey)) } + try { + val accepted = client.publishAndConfirm(alice.sign(TextNoteEvent.build("hi")), setOf(relayUrl)) + assertEquals(true, accepted) + val denied = client.publishAndConfirm(mallory.sign(TextNoteEvent.build("nope")), setOf(relayUrl)) + assertEquals(false, denied) + } finally { + client.disconnect() + hub.close() + } + } + + @Test + fun rejectFutureEventsBlocksFarFutureCreatedAtOverWire() = + runBlocking { + // Use a fixed clock so the policy decision is deterministic. + val frozen = 1_000_000L + val (client, hub) = + hubWith { RejectFutureEventsPolicy(maxFutureSeconds = 60, now = { frozen }) } + try { + val signer = NostrSignerSync(KeyPair()) + val nearby = signer.sign(TextNoteEvent.build("ok", createdAt = frozen + 30)) + assertEquals(true, client.publishAndConfirm(nearby, setOf(relayUrl))) + val tooFar = signer.sign(TextNoteEvent.build("nope", createdAt = frozen + 3600)) + assertEquals(false, client.publishAndConfirm(tooFar, setOf(relayUrl))) + } finally { + client.disconnect() + hub.close() + } + } + + @Test + fun maxEventBytesBlocksOversizeOverWire() = + runBlocking { + val (client, hub) = hubWith { MaxEventBytesPolicy(maxBytes = 400) } + try { + val signer = NostrSignerSync(KeyPair()) + val small = signer.sign(TextNoteEvent.build("hi")) + assertEquals(true, client.publishAndConfirm(small, setOf(relayUrl))) + val huge = signer.sign(TextNoteEvent.build("x".repeat(2_000))) + assertEquals(false, client.publishAndConfirm(huge, setOf(relayUrl))) + } finally { + client.disconnect() + hub.close() + } + } +} diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt new file mode 100644 index 0000000000..abc9e48d62 --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt @@ -0,0 +1,264 @@ +/* + * 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.policies + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult +import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Per-policy unit tests. Each policy gets a small, focused suite that + * proves accept/reject behaviour at the boundaries (empty config, + * single hit, collision between allow + deny, etc.). + * + * The end-to-end "policy is applied through the Ktor server" coverage + * lives in `LocalRelayServerTest` / `Nip01ComplianceTest` — these tests + * just exercise the policy in isolation. + */ +class PoliciesTest { + private fun event( + kind: Int = 1, + pubKey: String = SyntheticEvents.hexId(1), + createdAt: Long = 1000L, + content: String = "", + ) = SyntheticEvents.fakeEvent(idSeed = 1, kind = kind, pubKey = pubKey, createdAt = createdAt, content = content) + + private fun assertAccepted(result: PolicyResult<*>) { + if (result is PolicyResult.Rejected) fail("expected Accepted, got Rejected: ${result.reason}") + } + + private fun assertRejected( + result: PolicyResult<*>, + reasonContains: String? = null, + ) { + when (result) { + is PolicyResult.Accepted -> { + fail("expected Rejected, got Accepted") + } + + is PolicyResult.Rejected -> { + reasonContains?.let { + assertTrue( + result.reason.contains(it), + "expected reason to contain '$it', got '${result.reason}'", + ) + } + } + } + } + + // -- KindAllowDenyPolicy ------------------------------------------------- + + @Test + fun kindPolicyEmptyListsAreNoOp() { + val p = KindAllowDenyPolicy() + assertAccepted(p.accept(EventCmd(event(kind = 1)))) + assertAccepted(p.accept(EventCmd(event(kind = 99)))) + } + + @Test + fun kindAllowListExcludesEverythingElse() { + val p = KindAllowDenyPolicy(allow = setOf(1, 7)) + assertAccepted(p.accept(EventCmd(event(kind = 1)))) + assertAccepted(p.accept(EventCmd(event(kind = 7)))) + assertRejected(p.accept(EventCmd(event(kind = 4))), reasonContains = "kind 4 not allowed") + } + + @Test + fun kindDenyListBlocksLastWordOverAllowList() { + // When both lists are set, allow is a permissive ceiling and + // deny still removes specific kinds inside it. + val p = KindAllowDenyPolicy(allow = setOf(1, 4, 7), deny = setOf(4)) + assertAccepted(p.accept(EventCmd(event(kind = 1)))) + assertRejected(p.accept(EventCmd(event(kind = 4))), reasonContains = "kind 4 denied") + assertRejected(p.accept(EventCmd(event(kind = 999))), reasonContains = "not allowed") + } + + // -- PubkeyAllowDenyPolicy ---------------------------------------------- + + @Test + fun pubkeyAllowList() { + val alice = SyntheticEvents.hexId(101) + val mallory = SyntheticEvents.hexId(102) + val p = PubkeyAllowDenyPolicy(allow = setOf(alice)) + assertAccepted(p.accept(EventCmd(event(pubKey = alice)))) + assertRejected(p.accept(EventCmd(event(pubKey = mallory))), reasonContains = "not on allow") + } + + @Test + fun pubkeyDenyList() { + val alice = SyntheticEvents.hexId(101) + val mallory = SyntheticEvents.hexId(102) + val p = PubkeyAllowDenyPolicy(deny = setOf(mallory)) + assertAccepted(p.accept(EventCmd(event(pubKey = alice)))) + assertRejected(p.accept(EventCmd(event(pubKey = mallory))), reasonContains = "denied") + } + + @Test + fun pubkeyMatchIsCaseInsensitive() { + val pk = "ABCDEF".padEnd(64, '0') + val p = PubkeyAllowDenyPolicy(deny = setOf(pk.lowercase())) + // Event arrives with the upper-case form; policy must match. + assertRejected(p.accept(EventCmd(event(pubKey = pk)))) + } + + // -- RejectFutureEventsPolicy ------------------------------------------- + + @Test + fun futureEventsBeyondSkewAreRejected() { + val now = 1_000_000L + val p = RejectFutureEventsPolicy(maxFutureSeconds = 60, now = { now }) + assertAccepted(p.accept(EventCmd(event(createdAt = now + 60)))) + assertAccepted(p.accept(EventCmd(event(createdAt = now)))) + assertAccepted(p.accept(EventCmd(event(createdAt = now - 9999)))) // past is fine + assertRejected(p.accept(EventCmd(event(createdAt = now + 61))), reasonContains = "future") + } + + @Test + fun futureEventsZeroSkewMeansOnlyPastOrPresent() { + val now = 1_000L + val p = RejectFutureEventsPolicy(maxFutureSeconds = 0, now = { now }) + assertAccepted(p.accept(EventCmd(event(createdAt = now)))) + assertRejected(p.accept(EventCmd(event(createdAt = now + 1)))) + } + + // -- MaxEventBytesPolicy ------------------------------------------------ + + @Test + fun maxBytesAllowsSmallEvents() { + val small = event(content = "a") + val limit = OptimizedJsonMapper.toJson(small).length + 100 + val p = MaxEventBytesPolicy(maxBytes = limit) + assertAccepted(p.accept(EventCmd(small))) + } + + @Test + fun maxBytesRejectsOversizedEvents() { + val big = event(content = "x".repeat(2_000)) + val p = MaxEventBytesPolicy(maxBytes = 500) + assertRejected(p.accept(EventCmd(big)), reasonContains = "exceeds limit") + } + + // -- RateLimitPolicy ---------------------------------------------------- + + /** Helper that makes a clock we can advance in nanoseconds. */ + private class FakeClock { + var nanos = 0L + + fun read(): Long = nanos + + fun advanceMillis(ms: Long) { + nanos += ms * 1_000_000L + } + } + + @Test + fun rateLimitMessagesPerSecond() { + val clock = FakeClock() + val p = RateLimitPolicy(messagesPerSec = 3, nowNanos = clock::read) + + // First three pass within the same instant. + repeat(3) { assertAccepted(p.accept(EventCmd(event()))) } + // Fourth is rate-limited. + assertRejected(p.accept(EventCmd(event())), reasonContains = "messages per second") + + // After enough wall-time the bucket refills. + clock.advanceMillis(400) // 1s/3 = 333ms per token; 400ms gives at least 1 token + assertAccepted(p.accept(EventCmd(event()))) + } + + @Test + fun rateLimitSubscriptionsPerMinute() { + val clock = FakeClock() + val p = RateLimitPolicy(subscriptionsPerMin = 2, nowNanos = clock::read) + val req = ReqCmd("sub-1", listOf(Filter())) + + assertAccepted(p.accept(req)) + assertAccepted(p.accept(req)) + assertRejected(p.accept(req), reasonContains = "subscriptions per minute") + + // Refill after 30s for 2/min -> 1 token. + clock.advanceMillis(31_000) + assertAccepted(p.accept(req)) + } + + @Test + fun rateLimitCountAlsoCountsAsSubscription() { + val clock = FakeClock() + val p = RateLimitPolicy(subscriptionsPerMin = 1, nowNanos = clock::read) + val cnt = CountCmd("q1", listOf(Filter())) + assertAccepted(p.accept(cnt)) + assertRejected(p.accept(cnt), reasonContains = "subscriptions per minute") + } + + @Test + fun rateLimitDisabledWhenBothLimitsAreNull() { + val p = RateLimitPolicy() + repeat(1000) { assertAccepted(p.accept(EventCmd(event()))) } + repeat(1000) { assertAccepted(p.accept(ReqCmd("s", listOf(Filter())))) } + } + + // -- Stack composition -------------------------------------------------- + + /** + * Verifies that policies compose via `IRelayPolicy.plus` so an + * EVENT must clear every policy in the stack to be accepted. + */ + @Test + fun stackedPoliciesAllMustAccept() { + val now = 1_000L + val stack = + (KindAllowDenyPolicy(allow = setOf(1)) as com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy) + + RejectFutureEventsPolicy(maxFutureSeconds = 10, now = { now }) + + // Allowed kind, in window — accepted. + assertAccepted(stack.accept(EventCmd(event(kind = 1, createdAt = now)))) + // Allowed kind, future timestamp — rejected by RejectFuture. + assertRejected( + stack.accept(EventCmd(event(kind = 1, createdAt = now + 1000))), + reasonContains = "future", + ) + // Disallowed kind — rejected by KindPolicy regardless of timestamp. + assertRejected( + stack.accept(EventCmd(event(kind = 99, createdAt = now))), + reasonContains = "not allowed", + ) + } + + @Test + fun rateLimitConstructorRejectsInvalidValues() { + var threw = false + try { + RateLimitPolicy(messagesPerSec = 0) + } catch (_: IllegalArgumentException) { + threw = true + } + assertEquals(true, threw) + } +} From 65311590f23c1aa01748494a5e6fd5bb30d908a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 02:22:39 +0000 Subject: [PATCH 07/17] feat(relay): graceful drain on LocalRelayServer.stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "100ms grace can drop in-flight EVENTs" gap from the audit. Behavioural change: - stop() default grace is now 5 s (was 100 ms) and total timeout 10 s (was 1 s). A SQLite write + OK reply round-trip easily fits. - stop() first sends a NOTICE("closing: relay is shutting down — please reconnect later") to every connected client, so well- behaved clients can reconnect rather than hammering a dead socket. - Active client sessions are tracked in a ConcurrentHashMap-backed set populated by the WebSocket handler's connect/finally pair. Exposed read-only via [activeSessionCount] so operators (and tests) can observe lifecycle. - stop() is now idempotent. Tests (4 new in GracefulShutdownTest): - activeSessionCountTracksConnectAndDisconnect — counter goes 0 → 1 on subscribe, → 0 on disconnect. - stopSendsShutdownNoticeToActiveClients — connected client receives a NOTICE whose message starts with "closing:". - stopIsIdempotent — second stop() is a safe no-op. - rawWsClientObservesNoticeBeforeServerCloses — bare OkHttp ws client sees the NOTICE frame before the server closes the socket (proves the order: NOTICE first, then engine.stop). Total :quartz-relay tests: 66, 0 failures. --- .../quartz/relay/LocalRelayServer.kt | 56 ++++- .../quartz/relay/GracefulShutdownTest.kt | 224 ++++++++++++++++++ 2 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/GracefulShutdownTest.kt diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt index f2778ba538..8d773827c2 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.relay +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode @@ -37,6 +39,7 @@ import io.ktor.websocket.Frame import io.ktor.websocket.readText import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.runBlocking +import java.util.concurrent.ConcurrentHashMap /** * Hosts a [Relay] over a real `ws://` endpoint backed by Ktor + CIO. @@ -73,6 +76,17 @@ class LocalRelayServer( private var engine: CIOApplicationEngine? = null private var resolvedPort: Int = -1 + /** + * Active client sessions, registered when their WebSocket handler + * runs and removed on disconnect. Exposed (read-only) so [stop] can + * NOTICE every connected client during graceful drain, and so tests + * can assert lifecycle bookkeeping. + */ + private val activeSessions: MutableSet = ConcurrentHashMap.newKeySet() + + /** Number of WebSocket sessions currently connected to the server. */ + val activeSessionCount: Int get() = activeSessions.size + /** `ws://host:port/path` — only valid after [start]. */ val url: String get() { @@ -119,6 +133,7 @@ class LocalRelayServer( // dispatcher; trySend never blocks the relay thread. outgoing.trySend(Frame.Text(json)) } + activeSessions.add(session) try { incoming.consumeEach { frame -> if (frame is Frame.Text) { @@ -126,6 +141,7 @@ class LocalRelayServer( } } } finally { + activeSessions.remove(session) session.close() } } @@ -145,13 +161,45 @@ class LocalRelayServer( return this } - /** Stops the engine. Safe to call multiple times. */ + /** + * Graceful shutdown. Safe to call multiple times. + * + * 1. Sends a NOTICE("closing: …") to every currently-connected + * client so well-behaved clients know to reconnect later. + * 2. Stops the Ktor engine: rejects new connections immediately, + * then waits up to [gracePeriodMillis] for active WebSocket + * handlers to finish whatever they're processing (so an in-flight + * `EVENT` lands its `OK` reply before the socket dies). After + * the grace window, in-progress handlers are cancelled and the + * engine waits up to [timeoutMillis] - [gracePeriodMillis] for + * that cancellation to complete. + * + * Defaults to 5 s grace / 10 s total — generous enough that a + * SQLite write + reply round-trip can land for typical event + * sizes. Override either with a tighter budget if your operator + * knows their workload. + */ fun stop( - gracePeriodMillis: Long = 100, - timeoutMillis: Long = 1_000, + gracePeriodMillis: Long = 5_000, + timeoutMillis: Long = 10_000, ) { - engine?.stop(gracePeriodMillis, timeoutMillis) + val e = engine ?: return + notifyShutdown() + e.stop(gracePeriodMillis, timeoutMillis) engine = null resolvedPort = -1 } + + /** + * Best-effort NOTICE to every active client. Failures are + * swallowed — a flaky socket on its way out is exactly the case + * where a NOTICE will fail anyway, and the client's read of the + * close frame is the authoritative shutdown signal. + */ + private fun notifyShutdown() { + val notice = NoticeMessage("closing: relay is shutting down — please reconnect later") + activeSessions.forEach { session -> + runCatching { session.send(notice) } + } + } } diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/GracefulShutdownTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/GracefulShutdownTest.kt new file mode 100644 index 0000000000..5a3021cbbe --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/GracefulShutdownTest.kt @@ -0,0 +1,224 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests [LocalRelayServer.stop] honours the graceful-shutdown contract: + * 1. Active clients receive a `NOTICE` warning of imminent shutdown. + * 2. The active session counter accurately tracks open WS sessions. + * 3. After `stop()` returns, no sessions remain registered. + */ +class GracefulShutdownTest { + private lateinit var relay: Relay + private lateinit var server: LocalRelayServer + private lateinit var scope: CoroutineScope + private lateinit var client: NostrClient + + private val httpClient = OkHttpClient.Builder().build() + + @BeforeTest + fun setup() { + val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl() + relay = Relay(url = placeholder) + server = LocalRelayServer(relay, host = "127.0.0.1", port = 0).start() + scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient } + client = NostrClient(builder, scope) + } + + @AfterTest + fun teardown() { + client.disconnect() + scope.cancel() + // server may already be stopped by the test; calling stop() + // again is a no-op. + server.stop(gracePeriodMillis = 200, timeoutMillis = 500) + relay.close() + } + + @Test + fun activeSessionCountTracksConnectAndDisconnect() = + runBlocking { + assertEquals(0, server.activeSessionCount, "no clients yet") + + // Open a connection by subscribing — wait for EOSE so we + // know the WebSocket handshake completed and the relay + // session has registered. + val gotEose = Channel(UNLIMITED) + val relayUrl = server.url.normalizeRelayUrl() + client.subscribe( + "track-1", + mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), + object : SubscriptionListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + gotEose.trySend(Unit) + } + }, + ) + withTimeout(5000) { gotEose.receive() } + + assertEquals(1, server.activeSessionCount, "one connected session") + + client.unsubscribe("track-1") + client.disconnect() + + // Disconnect happens asynchronously on the relay side; allow + // a short window for the handler's `finally` block to run. + withTimeoutOrNull(2000) { + while (server.activeSessionCount > 0) kotlinx.coroutines.delay(10) + } + assertEquals(0, server.activeSessionCount, "session must be removed after disconnect") + } + + @Test + fun stopSendsShutdownNoticeToActiveClients() = + runBlocking { + val noticeChannel = Channel(UNLIMITED) + val gotEose = Channel(UNLIMITED) + val listener = + object : RelayConnectionListener { + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is NoticeMessage) noticeChannel.trySend(msg) + } + } + client.addConnectionListener(listener) + + val relayUrl = server.url.normalizeRelayUrl() + client.subscribe( + "notice-watch", + mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), + object : SubscriptionListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + gotEose.trySend(Unit) + } + }, + ) + withTimeout(5000) { gotEose.receive() } + assertEquals(1, server.activeSessionCount) + + // Trigger graceful shutdown. + server.stop(gracePeriodMillis = 1_000, timeoutMillis = 2_000) + + val notice = withTimeout(5000) { noticeChannel.receive() } + assertNotNull(notice) + assertTrue( + notice.message.startsWith("closing:"), + "expected NOTICE to start with 'closing:', got '${notice.message}'", + ) + } + + @Test + fun stopIsIdempotent() { + // First call shuts the engine down. + server.stop(gracePeriodMillis = 100, timeoutMillis = 500) + // Second call must be a safe no-op (no exception). + server.stop(gracePeriodMillis = 100, timeoutMillis = 500) + } + + /** + * Sanity check on the grace window: a bare-bones ws client that + * connects and never sends anything should *receive* the shutdown + * NOTICE before the server fully closes the socket. Uses Ktor's + * client-agnostic OkHttp transport directly so we can observe the + * raw frames. + */ + @Test + fun rawWsClientObservesNoticeBeforeServerCloses() = + runBlocking { + val httpUrl = + server.url + .replace("ws://", "http://") + val request = + okhttp3.Request + .Builder() + .url(httpUrl) + .build() + + val frames = Channel(UNLIMITED) + val socket = + httpClient.newWebSocket( + request, + object : okhttp3.WebSocketListener() { + override fun onMessage( + ws: okhttp3.WebSocket, + text: String, + ) { + frames.trySend(text) + } + }, + ) + + try { + // Wait until the relay sees the connection. + withTimeoutOrNull(2000) { + while (server.activeSessionCount == 0) kotlinx.coroutines.delay(10) + } + assertEquals(1, server.activeSessionCount) + + server.stop(gracePeriodMillis = 1_000, timeoutMillis = 2_000) + + val text = withTimeout(3000) { frames.receive() } + assertTrue( + text.contains("\"NOTICE\"") && text.contains("closing"), + "expected a NOTICE frame, got: $text", + ) + } finally { + socket.cancel() + } + } +} From 9ebdfc0b8198e1485cf1cb48d8a530802f4a3df8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 02:36:18 +0000 Subject: [PATCH 08/17] feat(relay): drop max_event_bytes + rate-limit configs; verify on by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes config keys + policies that don't earn their complexity: - [limits].max_event_bytes / MaxEventBytesPolicy — duplicates [limits].max_ws_frame_bytes which is the right layer (Ktor frame cap at the wire), and the policy-level check ran AFTER the event was already parsed and bound for the store. - [limits].messages_per_sec, [limits].subscriptions_per_min / RateLimitPolicy — per-session token buckets without the matching per-IP / global-EPS infrastructure are mostly cosmetic; an operator who needs real rate limits will use a reverse proxy. Also flips [options].verify_signatures default from `false` to `true`. A relay accepting traffic from real clients should verify Schnorr signatures, and verify-by-default closes the footgun of forgetting the flag. The CLI gains `--no-verify` for explicit opt-out (test fixtures, mirror replays); the old `--verify` is dropped (a no-op now anyway). Removed: - quartz-relay/.../policies/MaxEventBytesPolicy.kt - quartz-relay/.../policies/RateLimitPolicy.kt - 7 obsolete tests in PoliciesTest + 1 in PoliciesIntegrationTest - The matching config fields in LimitsSection - Sample config entries in config.example.toml - Wiring in Main.composePolicy Added: - Test verifySignaturesCanBeExplicitlyDisabled covering the new explicit-opt-out path. Total :quartz-relay tests: 59, 0 failures. --- quartz-relay/config.example.toml | 26 ++-- .../com/vitorpamplona/quartz/relay/Main.kt | 59 +++------ .../quartz/relay/config/RelayConfig.kt | 27 ++-- .../relay/policies/MaxEventBytesPolicy.kt | 55 --------- .../quartz/relay/policies/RateLimitPolicy.kt | 115 ------------------ .../quartz/relay/config/RelayConfigTest.kt | 19 +-- .../relay/policies/PoliciesIntegrationTest.kt | 16 --- .../quartz/relay/policies/PoliciesTest.kt | 92 -------------- 8 files changed, 39 insertions(+), 370 deletions(-) delete mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/MaxEventBytesPolicy.kt delete mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RateLimitPolicy.kt diff --git a/quartz-relay/config.example.toml b/quartz-relay/config.example.toml index b29108db7a..a31532117a 100644 --- a/quartz-relay/config.example.toml +++ b/quartz-relay/config.example.toml @@ -25,10 +25,6 @@ contact = "admin@example.com" 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 @@ -39,32 +35,24 @@ 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 +# Verify Schnorr signatures on every EVENT. Default: true. Disable +# only for trusted-input scenarios (test fixtures, mirror replays). +# 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 events whose `created_at` is more than this many seconds in +# the future. Enforced by RejectFutureEventsPolicy. # reject_future_seconds = 1800 [limits] -# Maximum byte size of an EVENT (canonical NIP-01 JSON form). -# Enforced by MaxEventBytesPolicy. -# max_event_bytes = 131072 - # Maximum WebSocket frame size. Frames larger than this are dropped at # the WS layer. (max_ws_message_bytes maps to the same setting since # Ktor's WebSockets plugin only exposes per-frame caps.) # max_ws_message_bytes = 1048576 # max_ws_frame_bytes = 1048576 -# Per-session token-bucket caps. Enforced by RateLimitPolicy. -# messages_per_sec = 10 -# subscriptions_per_min = 60 - -# Parsed but NOT YET ENFORCED. -# max_subscriptions_per_session = 32 -# max_filters_per_req = 10 - [authorization] # Allow / deny lists. Allow is a permissive ceiling; deny still # removes specific entries inside it. Enforced by Pubkey/KindAllowDenyPolicy. 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 c7302b0730..838f8e9a0c 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 @@ -29,9 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.relay.config.RelayConfig import com.vitorpamplona.quartz.relay.policies.KindAllowDenyPolicy -import com.vitorpamplona.quartz.relay.policies.MaxEventBytesPolicy import com.vitorpamplona.quartz.relay.policies.PubkeyAllowDenyPolicy -import com.vitorpamplona.quartz.relay.policies.RateLimitPolicy import com.vitorpamplona.quartz.relay.policies.RejectFutureEventsPolicy import java.io.File @@ -48,10 +46,10 @@ import java.io.File * 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]`. + * Every section is enforced: `[info]` populates the NIP-11 doc, + * `[network]` controls the bind, `[database]` chooses the SQLite path, + * `[options]` toggles AUTH/verify/future-skew, `[limits]` and + * `[authorization]` plug into the relay's policy stack. * * CLI flags: * --config TOML config (see config.example.toml) @@ -61,7 +59,9 @@ import java.io.File * --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) + * --no-verify DO NOT verify event signatures (off by default + * verify is on; use only for trusted-input + * scenarios like fixture replay). */ fun main(args: Array) { val a = parseArgs(args) @@ -79,7 +79,10 @@ fun main(args: Array) { 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 + // Verify is on by default; only disable when the operator explicitly + // opts out (CLI `--no-verify` or `[options].verify_signatures = false` + // in the config). + val verifySigs = !a.flag("--no-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 @@ -98,8 +101,6 @@ fun main(args: Array) { composePolicy(config, advertisedUrl, requireAuth, verifySigs) } - warnUnenforcedSections(config) - val relay = Relay(advertisedUrl, store, info, policyBuilder) // Frame cap honors max_ws_frame_bytes when set; max_ws_message_bytes // is treated as the same cap (Ktor's WebSockets plugin only exposes @@ -133,13 +134,9 @@ fun main(args: Array) { * Builds the policy stack for one connection from the config. * * Order matters — cheap rejection paths run before expensive ones: - * 1. Rate limit (per-session, fastest reject path) - * 2. AUTH (drops everything if not authenticated) - * 3. Future-timestamp + size-cap + allow/deny lists - * 4. Signature verification (most expensive) - * - * The relay's `policyBuilder` factory is invoked per connection so - * rate-limit token buckets are session-scoped (not global). + * 1. AUTH (drops everything if not authenticated) + * 2. Future-timestamp + allow/deny lists + * 3. Signature verification (most expensive — Schnorr verify) */ private fun composePolicy( config: RelayConfig, @@ -149,11 +146,6 @@ private fun composePolicy( ): IRelayPolicy { val pieces = mutableListOf() - val l = config.limits - if (l.messages_per_sec != null || l.subscriptions_per_min != null) { - pieces += RateLimitPolicy(l.messages_per_sec, l.subscriptions_per_min) - } - if (requireAuth) { pieces += FullAuthPolicy(advertisedUrl) } @@ -162,10 +154,6 @@ private fun composePolicy( pieces += RejectFutureEventsPolicy(secs) } - l.max_event_bytes?.let { bytes -> - pieces += MaxEventBytesPolicy(bytes) - } - val auth = config.authorization if (auth.kind_whitelist.isNotEmpty() || auth.kind_blacklist.isNotEmpty()) { pieces += KindAllowDenyPolicy(auth.kind_whitelist.toSet(), auth.kind_blacklist.toSet()) @@ -183,25 +171,6 @@ private fun composePolicy( } } -/** - * Surface a warning for config sections we still don't enforce. As we - * add policies the matching branch is removed here. - */ -private fun warnUnenforcedSections(config: RelayConfig) { - val warnings = mutableListOf() - val l = config.limits - if (l.max_subscriptions_per_session != null) { - warnings += "[limits].max_subscriptions_per_session is parsed but NOT YET ENFORCED." - } - if (l.max_filters_per_req != null) { - warnings += "[limits].max_filters_per_req 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 index fb31fa071f..5e2cd78f83 100644 --- 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 @@ -34,19 +34,13 @@ import java.io.File * * 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(), ) { /** @@ -103,12 +97,6 @@ data class RelayConfig( 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( @@ -123,18 +111,19 @@ data class RelayConfig( 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, + /** + * 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). + */ + val verify_signatures: Boolean = true, ) 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( diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/MaxEventBytesPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/MaxEventBytesPolicy.kt deleted file mode 100644 index 6d531f9a66..0000000000 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/MaxEventBytesPolicy.kt +++ /dev/null @@ -1,55 +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.quartz.relay.policies - -import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd -import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult - -/** - * Rejects events whose canonical JSON byte size exceeds [maxBytes]. - * Mirrors nostr-rs-relay's `[limits].max_event_bytes`. - * - * Note: this measures the size of the SERVER-side re-serialised event - * (via [OptimizedJsonMapper.toJson]), which is byte-equivalent to the - * canonical NIP-01 form a well-behaved client would have sent. It does - * NOT enforce `[limits].max_ws_message_bytes` — that one belongs at the - * WebSocket frame layer (Ktor `WebSockets { maxFrameSize = ... }` for - * [com.vitorpamplona.quartz.relay.LocalRelayServer]) because the policy - * layer never sees the raw frame. Both limits are enforced together - * when the operator sets them in the config. - */ -class MaxEventBytesPolicy( - val maxBytes: Int, -) : PassThroughPolicy() { - init { - require(maxBytes > 0) { "maxBytes must be > 0, got $maxBytes" } - } - - override fun accept(cmd: EventCmd): PolicyResult { - val size = OptimizedJsonMapper.toJson(cmd.event).length - return if (size > maxBytes) { - PolicyResult.Rejected("invalid: event size $size exceeds limit of $maxBytes bytes") - } else { - PolicyResult.Accepted(cmd) - } - } -} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RateLimitPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RateLimitPolicy.kt deleted file mode 100644 index 877e8abc5f..0000000000 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RateLimitPolicy.kt +++ /dev/null @@ -1,115 +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.quartz.relay.policies - -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd -import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult - -/** - * Per-session token-bucket rate limiter. Mirrors nostr-rs-relay's - * `[limits].messages_per_sec` and `[limits].subscriptions_per_min`. - * - * - [messagesPerSec] caps EVERY incoming command (EVENT/REQ/COUNT/AUTH) - * over a 1-second window. `null` disables. - * - [subscriptionsPerMin] caps REQ + COUNT (subscriptions opened) over - * a 60-second window. `null` disables. - * - * Each session gets its own buckets — instances of this policy must be - * created per-connection via the relay's `policyBuilder` factory. - * - * Time source defaults to monotonic [System.nanoTime] so wall-clock - * jumps don't reset the buckets. Tests inject a deterministic clock. - */ -class RateLimitPolicy( - val messagesPerSec: Int? = null, - val subscriptionsPerMin: Int? = null, - private val nowNanos: () -> Long = System::nanoTime, -) : PassThroughPolicy() { - private val msgBucket = - messagesPerSec?.let { - require(it > 0) { "messagesPerSec must be > 0" } - TokenBucket(capacity = it, refillIntervalNanos = 1_000_000_000L / it, nowNanos) - } - - private val subBucket = - subscriptionsPerMin?.let { - require(it > 0) { "subscriptionsPerMin must be > 0" } - TokenBucket(capacity = it, refillIntervalNanos = 60_000_000_000L / it, nowNanos) - } - - private fun checkMsgBucket(): String? = if (msgBucket?.tryTake() == false) "blocked: too many messages per second" else null - - private fun checkSubBucket(): String? = if (subBucket?.tryTake() == false) "blocked: too many subscriptions per minute" else null - - override fun accept(cmd: EventCmd): PolicyResult { - checkMsgBucket()?.let { return PolicyResult.Rejected(it) } - return PolicyResult.Accepted(cmd) - } - - override fun accept(cmd: ReqCmd): PolicyResult { - checkMsgBucket()?.let { return PolicyResult.Rejected(it) } - checkSubBucket()?.let { return PolicyResult.Rejected(it) } - return PolicyResult.Accepted(cmd) - } - - override fun accept(cmd: CountCmd): PolicyResult { - checkMsgBucket()?.let { return PolicyResult.Rejected(it) } - checkSubBucket()?.let { return PolicyResult.Rejected(it) } - return PolicyResult.Accepted(cmd) - } -} - -/** - * Token bucket with monotonic refill. Single-threaded by contract — - * RelaySession.receive runs serially within a session, so no locking - * is needed. - */ -private class TokenBucket( - val capacity: Int, - val refillIntervalNanos: Long, - val now: () -> Long, -) { - private var tokens: Long = capacity.toLong() - private var lastRefill: Long = now() - - fun tryTake(): Boolean { - refill() - return if (tokens > 0) { - tokens -= 1 - true - } else { - false - } - } - - private fun refill() { - val n = now() - val elapsed = n - lastRefill - if (elapsed <= 0) return - val newTokens = elapsed / refillIntervalNanos - if (newTokens > 0) { - tokens = (tokens + newTokens).coerceAtMost(capacity.toLong()) - lastRefill += newTokens * refillIntervalNanos - } - } -} 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 index 071f371afd..77b52c0984 100644 --- 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 @@ -35,10 +35,17 @@ class RelayConfigTest { assertEquals("/", c.network.path) assertEquals(true, c.database.in_memory) assertEquals(false, c.options.require_auth) - assertEquals(false, c.options.verify_signatures) + // Verify is on by default — operators have to opt out explicitly. + assertEquals(true, c.options.verify_signatures) assertTrue(c.authorization.pubkey_whitelist.isEmpty()) } + @Test + fun verifySignaturesCanBeExplicitlyDisabled() { + val c = RelayConfig.fromToml("[options]\nverify_signatures = false") + assertEquals(false, c.options.verify_signatures) + } + @Test fun parsesAllSectionsTogether() { val toml = @@ -53,7 +60,6 @@ class RelayConfigTest { host = "127.0.0.1" port = 9988 path = "/relay" - remote_ip_header = "X-Forwarded-For" [database] in_memory = false @@ -65,9 +71,7 @@ class RelayConfigTest { reject_future_seconds = 1800 [limits] - max_event_bytes = 131072 - messages_per_sec = 10 - max_filters_per_req = 12 + max_ws_frame_bytes = 1048576 [authorization] pubkey_blacklist = ["aaaa", "bbbb"] @@ -83,7 +87,6 @@ class RelayConfigTest { 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) @@ -92,9 +95,7 @@ class RelayConfigTest { 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(1_048_576, c.limits.max_ws_frame_bytes) assertEquals(listOf("aaaa", "bbbb"), c.authorization.pubkey_blacklist) assertEquals(listOf(4, 1059), c.authorization.kind_blacklist) diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt index 5b9f434f8e..c4fcfe8dd3 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt @@ -121,20 +121,4 @@ class PoliciesIntegrationTest { hub.close() } } - - @Test - fun maxEventBytesBlocksOversizeOverWire() = - runBlocking { - val (client, hub) = hubWith { MaxEventBytesPolicy(maxBytes = 400) } - try { - val signer = NostrSignerSync(KeyPair()) - val small = signer.sign(TextNoteEvent.build("hi")) - assertEquals(true, client.publishAndConfirm(small, setOf(relayUrl))) - val huge = signer.sign(TextNoteEvent.build("x".repeat(2_000))) - assertEquals(false, client.publishAndConfirm(huge, setOf(relayUrl))) - } finally { - client.disconnect() - hub.close() - } - } } diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt index abc9e48d62..0c544c7f11 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt @@ -20,15 +20,10 @@ */ package com.vitorpamplona.quartz.relay.policies -import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.test.fail @@ -148,82 +143,6 @@ class PoliciesTest { assertRejected(p.accept(EventCmd(event(createdAt = now + 1)))) } - // -- MaxEventBytesPolicy ------------------------------------------------ - - @Test - fun maxBytesAllowsSmallEvents() { - val small = event(content = "a") - val limit = OptimizedJsonMapper.toJson(small).length + 100 - val p = MaxEventBytesPolicy(maxBytes = limit) - assertAccepted(p.accept(EventCmd(small))) - } - - @Test - fun maxBytesRejectsOversizedEvents() { - val big = event(content = "x".repeat(2_000)) - val p = MaxEventBytesPolicy(maxBytes = 500) - assertRejected(p.accept(EventCmd(big)), reasonContains = "exceeds limit") - } - - // -- RateLimitPolicy ---------------------------------------------------- - - /** Helper that makes a clock we can advance in nanoseconds. */ - private class FakeClock { - var nanos = 0L - - fun read(): Long = nanos - - fun advanceMillis(ms: Long) { - nanos += ms * 1_000_000L - } - } - - @Test - fun rateLimitMessagesPerSecond() { - val clock = FakeClock() - val p = RateLimitPolicy(messagesPerSec = 3, nowNanos = clock::read) - - // First three pass within the same instant. - repeat(3) { assertAccepted(p.accept(EventCmd(event()))) } - // Fourth is rate-limited. - assertRejected(p.accept(EventCmd(event())), reasonContains = "messages per second") - - // After enough wall-time the bucket refills. - clock.advanceMillis(400) // 1s/3 = 333ms per token; 400ms gives at least 1 token - assertAccepted(p.accept(EventCmd(event()))) - } - - @Test - fun rateLimitSubscriptionsPerMinute() { - val clock = FakeClock() - val p = RateLimitPolicy(subscriptionsPerMin = 2, nowNanos = clock::read) - val req = ReqCmd("sub-1", listOf(Filter())) - - assertAccepted(p.accept(req)) - assertAccepted(p.accept(req)) - assertRejected(p.accept(req), reasonContains = "subscriptions per minute") - - // Refill after 30s for 2/min -> 1 token. - clock.advanceMillis(31_000) - assertAccepted(p.accept(req)) - } - - @Test - fun rateLimitCountAlsoCountsAsSubscription() { - val clock = FakeClock() - val p = RateLimitPolicy(subscriptionsPerMin = 1, nowNanos = clock::read) - val cnt = CountCmd("q1", listOf(Filter())) - assertAccepted(p.accept(cnt)) - assertRejected(p.accept(cnt), reasonContains = "subscriptions per minute") - } - - @Test - fun rateLimitDisabledWhenBothLimitsAreNull() { - val p = RateLimitPolicy() - repeat(1000) { assertAccepted(p.accept(EventCmd(event()))) } - repeat(1000) { assertAccepted(p.accept(ReqCmd("s", listOf(Filter())))) } - } - // -- Stack composition -------------------------------------------------- /** @@ -250,15 +169,4 @@ class PoliciesTest { reasonContains = "not allowed", ) } - - @Test - fun rateLimitConstructorRejectsInvalidValues() { - var threw = false - try { - RateLimitPolicy(messagesPerSec = 0) - } catch (_: IllegalArgumentException) { - threw = true - } - assertEquals(true, threw) - } } From 7c7908d37332e9779cab92e40dac15820605005b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 02:55:47 +0000 Subject: [PATCH 09/17] feat(relay): NIP-86 relay management API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the operator-facing JSON-RPC admin protocol on the relay side, layered on top of the existing NIP-98 HTTP-Auth + NIP-86 wire types in :quartz. Same path as the NIP-01 WebSocket and NIP-11 GET — HTTP POST with Content-Type application/nostr+json+rpc, gated by a NIP-98 Authorization header signed by an operator-listed pubkey. Architecture: - BanStore — concurrent in-memory state for runtime pubkey/event/kind ban + allow lists. - DynamicBanPolicy — IRelayPolicy that consults BanStore on every EVENT; auto-prepended to every Relay's policy stack so admin actions take effect without a restart. - Nip98AuthVerifier — parses `Authorization: Nostr `, verifies kind 27235 + Schnorr sig + ±60 s clock skew + method/url/payload-hash match. - Nip86Server — JSON-RPC dispatcher. Mutates BanStore for ban/allow/list methods, mutates the live RelayInfo doc for changerelayname/desc/icon (Relay.info is now @Volatile var, mutation through Relay.updateInfo). - LocalRelayServer — adds POST handler at the relay path: 403 if no admin pubkeys configured, 401 if NIP-98 missing/invalid, 403 if signer's pubkey isn't on the admin list, 200 with Nip86Response otherwise. - RelayConfig.AdminSection — `[admin].pubkeys = [...]` config key. - RelayInfo.default() now advertises NIP-86 alongside 1/9/11/40/42/45/50/62. Methods implemented: supportedmethods, banpubkey, unbanpubkey, listbannedpubkeys, allowpubkey, unallowpubkey, listallowedpubkeys, banevent (also deletes from store), allowevent, listbannedevents, allowkind, disallowkind, listallowedkinds, changerelayname, changerelaydescription, changerelayicon. Tests (28 new): - BanStoreTest (6) — ban/allow round-trips, case-insensitive pubkey match, kind allow/deny precedence, audit-trail listing. - Nip86ServerTest (8) — every method's accept/reject path. - Nip98AuthVerifierTest (8) — happy path, missing/wrong scheme, url/method/payload-hash mismatch, stale created_at, wrong event kind. - Nip86EndToEndTest (6) — real HTTP POST through LocalRelayServer: supportedmethods returns 200, outsider returns 403, no auth header returns 401 + WWW-Authenticate, banpubkey blocks subsequent EVENT publish over WS, changerelayname flows to NIP-11 GET, admin endpoint is disabled when no pubkeys configured. Total :quartz-relay tests: 87, 0 failures. --- quartz-relay/build.gradle.kts | 1 + quartz-relay/config.example.toml | 8 + .../quartz/relay/LocalRelayServer.kt | 127 ++++++++ .../com/vitorpamplona/quartz/relay/Main.kt | 1 + .../com/vitorpamplona/quartz/relay/Relay.kt | 41 ++- .../vitorpamplona/quartz/relay/RelayInfo.kt | 5 +- .../quartz/relay/admin/BanStore.kt | 144 ++++++++++ .../quartz/relay/admin/DynamicBanPolicy.kt | 59 ++++ .../quartz/relay/admin/Nip86Server.kt | 272 ++++++++++++++++++ .../quartz/relay/admin/Nip98AuthVerifier.kt | 131 +++++++++ .../quartz/relay/config/RelayConfig.kt | 12 + .../quartz/relay/admin/BanStoreTest.kt | 96 +++++++ .../quartz/relay/admin/Nip86EndToEndTest.kt | 232 +++++++++++++++ .../quartz/relay/admin/Nip86ServerTest.kt | 198 +++++++++++++ .../relay/admin/Nip98AuthVerifierTest.kt | 121 ++++++++ 15 files changed, 1444 insertions(+), 4 deletions(-) create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/DynamicBanPolicy.kt create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/BanStoreTest.kt create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86ServerTest.kt create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifierTest.kt diff --git a/quartz-relay/build.gradle.kts b/quartz-relay/build.gradle.kts index 1c93fc0355..0585a8a70e 100644 --- a/quartz-relay/build.gradle.kts +++ b/quartz-relay/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.jackson.module.kotlin) + implementation(libs.kotlinx.serialization.json) // Bundled SQLite driver — Relay's default in-memory EventStore creates // an in-memory DB at runtime. diff --git a/quartz-relay/config.example.toml b/quartz-relay/config.example.toml index a31532117a..4e0134d037 100644 --- a/quartz-relay/config.example.toml +++ b/quartz-relay/config.example.toml @@ -60,3 +60,11 @@ require_auth = false # pubkey_blacklist = [] # kind_whitelist = [0, 1, 3, 7, 1059, 30023] # kind_blacklist = [4] + +[admin] +# NIP-86 relay management API. When `pubkeys` is non-empty, the relay +# accepts HTTP POST application/nostr+json+rpc on the same URL, +# authenticated with NIP-98 HTTP-Auth. Only events signed by one of +# the listed pubkeys can run admin RPCs (banpubkey / banevent / +# changerelayname / …). Empty (the default) disables the endpoint. +# pubkeys = ["abcdef...64hex..."] diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt index 8d773827c2..416f838439 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt @@ -20,8 +20,14 @@ */ package com.vitorpamplona.quartz.relay +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response +import com.vitorpamplona.quartz.relay.admin.Nip86Server +import com.vitorpamplona.quartz.relay.admin.Nip98AuthVerifier import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode @@ -30,11 +36,14 @@ import io.ktor.server.cio.CIO import io.ktor.server.cio.CIOApplicationEngine import io.ktor.server.engine.embeddedServer import io.ktor.server.request.header +import io.ktor.server.request.receiveChannel import io.ktor.server.response.respondText import io.ktor.server.routing.get +import io.ktor.server.routing.post import io.ktor.server.routing.routing import io.ktor.server.websocket.WebSockets import io.ktor.server.websocket.webSocket +import io.ktor.utils.io.toByteArray import io.ktor.websocket.Frame import io.ktor.websocket.readText import kotlinx.coroutines.channels.consumeEach @@ -72,7 +81,32 @@ class LocalRelayServer( * uses Ktor's default (~1 MiB). */ val maxFrameBytes: Long? = null, + /** + * Pubkeys allowed to call NIP-86 admin RPCs. Empty (the default) + * disables the admin endpoint entirely — POSTs return 403. + * Otherwise: HTTP POSTs to [path] with `Content-Type: + * application/nostr+json+rpc` are dispatched to [Nip86Server], + * gated by NIP-98 HTTP-Auth membership in this set. + */ + val adminPubkeys: Set = emptySet(), ) { + /** + * Bridges the relay's mutable [RelayInfo] to [Nip86Server.InfoHolder] + * so admin RPCs can rewrite the NIP-11 doc atomically. + */ + private val infoHolder = + object : Nip86Server.InfoHolder { + override fun get() = relay.info + + override fun set(info: RelayInfo) { + relay.updateInfo { info.document } + } + } + + private val nip86 = Nip86Server(banStore = relay.banStore, infoHolder = infoHolder, store = relay.store) + private val nip98 = Nip98AuthVerifier() + + private val adminAllowList: Set = adminPubkeys.mapTo(HashSet()) { it.lowercase() } private var engine: CIOApplicationEngine? = null private var resolvedPort: Int = -1 @@ -126,6 +160,11 @@ class LocalRelayServer( ) } } + // NIP-86: POST application/nostr+json+rpc with a NIP-98 + // signed Authorization header → JSON-RPC dispatch. + post(path) { + handleNip86(call) + } webSocket(path) { val session = relay.server.connect { json -> @@ -190,6 +229,94 @@ class LocalRelayServer( resolvedPort = -1 } + /** + * Handles a NIP-86 admin RPC request: + * 1. 403 if no admin pubkey list is configured (endpoint disabled). + * 2. 401 if the NIP-98 Authorization header is missing/invalid. + * 3. 403 if the verified pubkey isn't in [adminAllowList]. + * 4. 400 if the body isn't a valid Nip86Request. + * 5. 200 with a Nip86Response JSON body otherwise. + * + * `application/nostr+json+rpc` is the wire content type prescribed + * by NIP-86; we send it on responses and accept any body on the + * request (the auth event's payload-hash already binds the body). + */ + private suspend fun handleNip86(call: io.ktor.server.application.ApplicationCall) { + if (adminAllowList.isEmpty()) { + call.respondText( + "NIP-86 management API is not enabled on this relay.", + ContentType.Text.Plain, + HttpStatusCode.Forbidden, + ) + return + } + + val body = call.receiveChannel().toByteArray() + val authHeader = call.request.header(HttpHeaders.Authorization) + // NIP-86 spec: the URL the client signed must be the relay's + // canonical http(s) URL, not the WS one. We reconstruct it from + // the request so the comparison is symmetric whether the + // operator runs the relay behind a reverse proxy or directly. + val signedUrl = + "http://" + + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path + val verification = nip98.verify(authHeader, method = "POST", url = signedUrl, body = body) + + val pubkey = + when (verification) { + is Nip98AuthVerifier.Result.Verified -> { + verification.pubkey + } + + Nip98AuthVerifier.Result.Missing -> { + call.response.headers.append(HttpHeaders.WWWAuthenticate, Nip98AuthVerifier.SCHEME.trim()) + call.respondText( + "missing Authorization header (NIP-98)", + ContentType.Text.Plain, + HttpStatusCode.Unauthorized, + ) + return + } + + is Nip98AuthVerifier.Result.Malformed -> { + call.respondText( + "invalid NIP-98 Authorization: ${verification.reason}", + ContentType.Text.Plain, + HttpStatusCode.Unauthorized, + ) + return + } + } + + if (pubkey.lowercase() !in adminAllowList) { + call.respondText( + "pubkey is not on the admin list", + ContentType.Text.Plain, + HttpStatusCode.Forbidden, + ) + return + } + + val req = + try { + JsonMapper.fromJson(body.decodeToString()) + } catch (e: Exception) { + call.respondText( + "invalid Nip86Request: ${e.message ?: e::class.simpleName}", + ContentType.Text.Plain, + HttpStatusCode.BadRequest, + ) + return + } + + val response: Nip86Response = nip86.dispatch(req) + call.respondText( + JsonMapper.toJson(response), + ContentType.parse("application/nostr+json+rpc"), + HttpStatusCode.OK, + ) + } + /** * Best-effort NOTICE to every active client. Failures are * swallowed — a flaky socket on its way out is exactly the case 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 838f8e9a0c..e0dc801cfc 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 @@ -114,6 +114,7 @@ fun main(args: Array) { port = port, path = path, maxFrameBytes = frameLimit, + adminPubkeys = config.admin.pubkeys.toSet(), ).start() Runtime.getRuntime().addShutdownHook( diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt index 9df98315ca..b6e155005c 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt @@ -29,6 +29,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.relay.admin.BanStore +import com.vitorpamplona.quartz.relay.admin.DynamicBanPolicy import kotlinx.coroutines.SupervisorJob import kotlin.coroutines.CoroutineContext @@ -51,11 +54,45 @@ import kotlin.coroutines.CoroutineContext class Relay( val url: NormalizedRelayUrl, val store: IEventStore = EventStore(dbName = null, relay = url), - val info: RelayInfo = RelayInfo.default(url), + info: RelayInfo = RelayInfo.default(url), policyBuilder: () -> IRelayPolicy = { EmptyPolicy }, parentContext: CoroutineContext = SupervisorJob(), ) : AutoCloseable { - val server = NostrServer(store, policyBuilder, parentContext) + /** + * 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. + */ + @Volatile + var info: RelayInfo = info + private set + + /** Mutates the live NIP-11 doc. Called by [admin.Nip86Server]. */ + fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) { + info = RelayInfo(transform(info.document)) + } + + /** + * Runtime-mutable ban / allow lists. NIP-86 RPC handlers in + * [admin.Nip86Server] mutate this; the policy stack consults it on + * every accept call via [DynamicBanPolicy]. + */ + val banStore: BanStore = BanStore() + + val server = + NostrServer( + store, + // Always prepend a DynamicBanPolicy 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. + policyBuilder = { + val user = policyBuilder() + if (user === EmptyPolicy) DynamicBanPolicy(banStore) else user + DynamicBanPolicy(banStore) + }, + parentContext, + ) /** * Inserts events directly into the underlying store, bypassing the wire protocol. diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt index 7e49e6cfe2..87884f284c 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt @@ -49,8 +49,9 @@ data class RelayInfo( // Currently implemented: NIP-01 (basic), NIP-09 (deletion via // DeletionRequestModule), NIP-11 (this doc), NIP-40 (expiration // via ExpirationModule), NIP-42 (AUTH — when policy enables), - // NIP-45 (COUNT), NIP-50 (search via FTS), NIP-62 (right to vanish). - supported_nips = listOf("1", "9", "11", "40", "42", "45", "50", "62"), + // NIP-45 (COUNT), NIP-50 (search via FTS), NIP-62 (right to vanish), + // NIP-86 (relay management API — when admin pubkeys are configured). + supported_nips = listOf("1", "9", "11", "40", "42", "45", "50", "62", "86"), ), ) diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt new file mode 100644 index 0000000000..9ef38df27c --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt @@ -0,0 +1,144 @@ +/* + * 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.admin + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import java.util.concurrent.ConcurrentHashMap + +/** + * Mutable, thread-safe runtime state for the NIP-86 management API. + * + * Each entry carries an optional reason string so list-* RPCs can echo + * back why an admin took the action — useful for audit trails. + * + * Today the state is in-memory only; a process restart wipes the bans. + * Wiring a persistent backend is a separate concern (a JSON file + * snapshot on each mutation, or a small SQLite table) and can be + * layered on by replacing this class behind the [DynamicBanPolicy] + * interface. + */ +class BanStore { + /** + * Pubkeys whose events the relay rejects. Compared case-insensitive + * (lowercased on insert / lookup) so an admin pasting a hex pubkey + * with mixed case still works. Empty-string value means "no + * reason given" — `ConcurrentHashMap` rejects nulls. + */ + private val bannedPubkeys = ConcurrentHashMap() + + /** + * Pubkeys explicitly allowed. When non-empty, this acts as a + * whitelist: events from any pubkey not on the list are rejected. + */ + private val allowedPubkeys = ConcurrentHashMap() + + /** Event ids the relay refuses to store/replay. */ + private val bannedEventIds = ConcurrentHashMap() + + private fun reasonOrEmpty(s: String?): String = s ?: "" + + private fun nullIfEmpty(s: String): String? = s.ifEmpty { null } + + /** + * Allowed kinds. When non-empty, events whose kind is not in the + * list are rejected. + */ + private val allowedKinds = ConcurrentHashMap.newKeySet() + + /** Disallowed kinds. Always blocks regardless of [allowedKinds]. */ + private val disallowedKinds = ConcurrentHashMap.newKeySet() + + // -- Pubkey ban list ----------------------------------------------------- + + fun banPubkey( + pubkey: HexKey, + reason: String? = null, + ) { + bannedPubkeys[pubkey.lowercase()] = reasonOrEmpty(reason) + } + + fun unbanPubkey(pubkey: HexKey) { + bannedPubkeys.remove(pubkey.lowercase()) + } + + fun isBanned(pubkey: HexKey): Boolean = bannedPubkeys.containsKey(pubkey.lowercase()) + + fun listBannedPubkeys(): List> = bannedPubkeys.entries.map { it.key to nullIfEmpty(it.value) } + + // -- Pubkey allow list --------------------------------------------------- + + fun allowPubkey( + pubkey: HexKey, + reason: String? = null, + ) { + allowedPubkeys[pubkey.lowercase()] = reasonOrEmpty(reason) + } + + fun unallowPubkey(pubkey: HexKey) { + allowedPubkeys.remove(pubkey.lowercase()) + } + + fun isAllowedPubkey(pubkey: HexKey): Boolean = allowedPubkeys.containsKey(pubkey.lowercase()) + + fun listAllowedPubkeys(): List> = allowedPubkeys.entries.map { it.key to nullIfEmpty(it.value) } + + fun hasAllowList(): Boolean = allowedPubkeys.isNotEmpty() + + // -- Event id ban list --------------------------------------------------- + + fun banEvent( + eventId: HexKey, + reason: String? = null, + ) { + bannedEventIds[eventId.lowercase()] = reasonOrEmpty(reason) + } + + /** Removes an event id from the ban list. Mirrors NIP-86 `allowevent`. */ + fun allowEvent(eventId: HexKey) { + bannedEventIds.remove(eventId.lowercase()) + } + + fun isBannedEvent(eventId: HexKey): Boolean = bannedEventIds.containsKey(eventId.lowercase()) + + fun listBannedEvents(): List> = bannedEventIds.entries.map { it.key to nullIfEmpty(it.value) } + + // -- Kind allow / deny -------------------------------------------------- + + fun allowKind(kind: Int) { + allowedKinds.add(kind) + } + + fun disallowKind(kind: Int) { + disallowedKinds.add(kind) + // Disallowing a kind implicitly removes it from the allow list. + allowedKinds.remove(kind) + } + + fun listAllowedKinds(): List = allowedKinds.sorted() + + fun listDisallowedKinds(): List = disallowedKinds.sorted() + + fun isKindAllowed(kind: Int): Boolean { + if (kind in disallowedKinds) return false + if (allowedKinds.isEmpty()) return true + return kind in allowedKinds + } +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/DynamicBanPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/DynamicBanPolicy.kt new file mode 100644 index 0000000000..9bc1aa4895 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/DynamicBanPolicy.kt @@ -0,0 +1,59 @@ +/* + * 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.admin + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult +import com.vitorpamplona.quartz.relay.policies.PassThroughPolicy + +/** + * Reads the live [BanStore] on every EVENT and rejects events that + * violate any of: banned-event-id, banned-pubkey, missing from a + * non-empty allow list, or kind disallowed / not in the kind allow + * list. + * + * This is the runtime-mutable counterpart of the static + * [com.vitorpamplona.quartz.relay.policies.KindAllowDenyPolicy] + + * [com.vitorpamplona.quartz.relay.policies.PubkeyAllowDenyPolicy] — + * both sets compose: the event must clear both layers. NIP-86 admin + * RPC mutations land here; the static policies stay frozen at + * boot-time config values. + */ +class DynamicBanPolicy( + val banStore: BanStore, +) : PassThroughPolicy() { + override fun accept(cmd: EventCmd): PolicyResult { + val ev = cmd.event + if (banStore.isBannedEvent(ev.id)) { + return PolicyResult.Rejected("blocked: event id is banned") + } + if (banStore.isBanned(ev.pubKey)) { + return PolicyResult.Rejected("blocked: pubkey is banned") + } + if (banStore.hasAllowList() && !banStore.isAllowedPubkey(ev.pubKey)) { + return PolicyResult.Rejected("blocked: pubkey is not on the allow list") + } + if (!banStore.isKindAllowed(ev.kind)) { + return PolicyResult.Rejected("blocked: kind ${ev.kind} not allowed") + } + return PolicyResult.Accepted(cmd) + } +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt new file mode 100644 index 0000000000..2f1eccbc61 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt @@ -0,0 +1,272 @@ +/* + * 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.admin + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response +import com.vitorpamplona.quartz.relay.RelayInfo +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.int + +/** + * NIP-86 RPC dispatcher. Holds the [BanStore] (mutated by ban/allow + * methods), the live [RelayInfo] handle (mutated by `changerelay*` + * methods, which atomically swap the doc), and the underlying + * [IEventStore] so `banevent` can also delete the offending event. + * + * The dispatcher is transport-agnostic — `LocalRelayServer` calls + * [dispatch] from its HTTP route, but the same handler also works for + * in-process tests that build a [Nip86Request] directly. + * + * [supportedMethods] is the canonical list this server actually + * implements; methods returned outside of it are no-ops and a NIP-86 + * client must not advertise them. + */ +class Nip86Server( + val banStore: BanStore, + /** + * Read-write access to the relay's NIP-11 info doc. The dispatcher + * mutates this when an admin calls `changerelayname` / + * `changerelaydescription` / `changerelayicon`. Relay code reading + * the doc (e.g. the NIP-11 endpoint) must consult this object on + * every request, not cache it. + */ + private val infoHolder: InfoHolder, + private val store: IEventStore? = null, +) { + /** Pluggable container so the relay's NIP-11 doc can be swapped at runtime. */ + interface InfoHolder { + fun get(): RelayInfo + + fun set(info: RelayInfo) + } + + val supportedMethods: List = + listOf( + Nip86Method.SUPPORTED_METHODS, + Nip86Method.BAN_PUBKEY, + Nip86Method.UNBAN_PUBKEY, + Nip86Method.LIST_BANNED_PUBKEYS, + Nip86Method.ALLOW_PUBKEY, + Nip86Method.UNALLOW_PUBKEY, + Nip86Method.LIST_ALLOWED_PUBKEYS, + Nip86Method.BAN_EVENT, + Nip86Method.ALLOW_EVENT, + Nip86Method.LIST_BANNED_EVENTS, + Nip86Method.ALLOW_KIND, + Nip86Method.DISALLOW_KIND, + Nip86Method.LIST_ALLOWED_KINDS, + Nip86Method.CHANGE_RELAY_NAME, + Nip86Method.CHANGE_RELAY_DESCRIPTION, + Nip86Method.CHANGE_RELAY_ICON, + ) + + /** + * Dispatches a single RPC request. Synchronous-looking but does + * suspend internally for the `banevent` event-store delete path. + */ + suspend fun dispatch(req: Nip86Request): Nip86Response = + runCatching { + when (req.method) { + Nip86Method.SUPPORTED_METHODS -> { + result(buildJsonArray { supportedMethods.forEach { add(JsonPrimitive(it)) } }) + } + + Nip86Method.BAN_PUBKEY -> { + val (pk, reason) = req.params.stringPair() ?: return malformed("expected [pubkey, reason?]") + banStore.banPubkey(pk, reason) + result(JsonPrimitive(true)) + } + + Nip86Method.UNBAN_PUBKEY -> { + val (pk, _) = req.params.stringPair() ?: return malformed("expected [pubkey]") + banStore.unbanPubkey(pk) + result(JsonPrimitive(true)) + } + + Nip86Method.LIST_BANNED_PUBKEYS -> { + result( + banStore + .listBannedPubkeys() + .map { (pk, r) -> BannedPubkey(pk, r) } + .toJsonArray(BannedPubkey.serializer()), + ) + } + + Nip86Method.ALLOW_PUBKEY -> { + val (pk, reason) = req.params.stringPair() ?: return malformed("expected [pubkey, reason?]") + banStore.allowPubkey(pk, reason) + result(JsonPrimitive(true)) + } + + Nip86Method.UNALLOW_PUBKEY -> { + val (pk, _) = req.params.stringPair() ?: return malformed("expected [pubkey]") + banStore.unallowPubkey(pk) + result(JsonPrimitive(true)) + } + + Nip86Method.LIST_ALLOWED_PUBKEYS -> { + result( + banStore + .listAllowedPubkeys() + .map { (pk, r) -> AllowedPubkey(pk, r) } + .toJsonArray(AllowedPubkey.serializer()), + ) + } + + Nip86Method.BAN_EVENT -> { + val (id, reason) = req.params.stringPair() ?: return malformed("expected [event_id, reason?]") + banStore.banEvent(id, reason) + // Also remove the event from the store if it's there. + store?.delete(Filter(ids = listOf(id))) + result(JsonPrimitive(true)) + } + + Nip86Method.ALLOW_EVENT -> { + val (id, _) = req.params.stringPair() ?: return malformed("expected [event_id]") + banStore.allowEvent(id) + result(JsonPrimitive(true)) + } + + Nip86Method.LIST_BANNED_EVENTS -> { + result( + banStore + .listBannedEvents() + .map { (id, r) -> BannedEvent(id, r) } + .toJsonArray(BannedEvent.serializer()), + ) + } + + Nip86Method.ALLOW_KIND -> { + val k = req.params.firstInt() ?: return malformed("expected [kind]") + banStore.allowKind(k) + result(JsonPrimitive(true)) + } + + Nip86Method.DISALLOW_KIND -> { + val k = req.params.firstInt() ?: return malformed("expected [kind]") + banStore.disallowKind(k) + result(JsonPrimitive(true)) + } + + Nip86Method.LIST_ALLOWED_KINDS -> { + result(buildJsonArray { banStore.listAllowedKinds().forEach { add(JsonPrimitive(it)) } }) + } + + Nip86Method.CHANGE_RELAY_NAME -> { + val name = req.params.firstString() ?: return malformed("expected [name]") + rewriteInfo { it.copy(name = name) } + result(JsonPrimitive(true)) + } + + Nip86Method.CHANGE_RELAY_DESCRIPTION -> { + val desc = req.params.firstString() ?: return malformed("expected [description]") + rewriteInfo { it.copy(description = desc) } + result(JsonPrimitive(true)) + } + + Nip86Method.CHANGE_RELAY_ICON -> { + val icon = req.params.firstString() ?: return malformed("expected [icon_url]") + rewriteInfo { it.copy(icon = icon) } + result(JsonPrimitive(true)) + } + + else -> { + Nip86Response(error = "method not supported: ${req.method}") + } + } + }.getOrElse { e -> + Nip86Response(error = "internal: ${e.message ?: e::class.simpleName}") + } + + private fun rewriteInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) { + val current = infoHolder.get().document + infoHolder.set(RelayInfo(transform(current))) + } + + /** [Nip11RelayInformation] is not a `data class`; do a manual field-by-field copy. */ + private fun Nip11RelayInformation.copy( + name: String? = this.name, + description: String? = this.description, + icon: String? = this.icon, + ) = Nip11RelayInformation( + id = this.id, + name = name, + description = description, + icon = icon, + pubkey = this.pubkey, + self = this.self, + contact = this.contact, + supported_nips = this.supported_nips, + supported_nip_extensions = this.supported_nip_extensions, + software = this.software, + version = this.version, + limitation = this.limitation, + relay_countries = this.relay_countries, + language_tags = this.language_tags, + tags = this.tags, + posting_policy = this.posting_policy, + privacy_policy = this.privacy_policy, + terms_of_service = this.terms_of_service, + payments_url = this.payments_url, + retention = this.retention, + fees = this.fees, + nip50 = this.nip50, + supported_grasps = this.supported_grasps, + ) +} + +private fun malformed(reason: String) = Nip86Response(error = "invalid params: $reason") + +private fun result(j: JsonElement) = Nip86Response(result = j, error = null) + +private val rpcJson = Json { encodeDefaults = false } + +private fun List.toJsonArray(serializer: KSerializer): JsonElement = rpcJson.encodeToJsonElement(ListSerializer(serializer), this) + +private fun JsonArray.stringPair(): Pair? { + val first = (getOrNull(0) as? JsonPrimitive)?.contentOrNull() ?: return null + val second = (getOrNull(1) as? JsonPrimitive)?.contentOrNull() + return first to second +} + +private fun JsonArray.firstString(): String? = (getOrNull(0) as? JsonPrimitive)?.contentOrNull() + +private fun JsonArray.firstInt(): Int? = + runCatching { + (this[0] as? JsonPrimitive)?.int + }.getOrNull() + +private fun JsonPrimitive.contentOrNull(): String? = if (this == JsonNull) null else content diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt new file mode 100644 index 0000000000..1ce0aa72a6 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt @@ -0,0 +1,131 @@ +/* + * 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.admin + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.math.abs + +/** + * Verifies a NIP-98 `Authorization: Nostr ` header. + * + * NIP-98 reuses kind 27235 events with `u`, `method`, and (for bodies) + * `payload` tags. The relay must check: + * 1. Header is `Nostr `. + * 2. Decoded body is a kind-27235 event with a valid Schnorr signature. + * 3. The event's `created_at` is within ±60 s of now (NIP-98 spec). + * 4. The `method` tag matches the HTTP method. + * 5. The `u` tag matches the requested URL. + * 6. If a body is present, the `payload` tag matches `sha256(body)` hex. + * + * Returns the verified pubkey on success; `null` on any failure (the + * caller turns this into a `401 Unauthorized`). + */ +class Nip98AuthVerifier( + private val now: () -> Long = { TimeUtils.now() }, + /** Allowed clock skew in seconds. NIP-98 says 60. */ + private val toleranceSeconds: Long = 60, +) { + @OptIn(ExperimentalEncodingApi::class) + fun verify( + authorizationHeader: String?, + method: String, + url: String, + body: ByteArray?, + ): Result { + if (authorizationHeader.isNullOrBlank()) return Result.Missing + if (!authorizationHeader.startsWith(SCHEME)) return Result.Malformed("expected '$SCHEME ' header") + + val token = authorizationHeader.substring(SCHEME.length).trim() + val json = + try { + Base64.decode(token).decodeToString() + } catch (_: IllegalArgumentException) { + return Result.Malformed("token is not valid base64") + } + + val event = + try { + OptimizedJsonMapper.fromJson(json) + } catch (_: Exception) { + return Result.Malformed("token does not decode to a Nostr event") + } + + if (event.kind != HTTPAuthorizationEvent.KIND) { + return Result.Malformed("event kind ${event.kind} != ${HTTPAuthorizationEvent.KIND}") + } + if (!event.verify()) return Result.Malformed("bad event signature or id") + + val skew = abs(event.createdAt - now()) + if (skew > toleranceSeconds) { + return Result.Malformed("created_at is ${skew}s away from now (max ${toleranceSeconds}s)") + } + + // Re-wrap as the typed event so the tag accessors work. + val auth = + HTTPAuthorizationEvent( + event.id, + event.pubKey, + event.createdAt, + event.tags, + event.content, + event.sig, + ) + + if (!auth.method().equals(method, ignoreCase = true)) { + return Result.Malformed("method mismatch: expected $method, got ${auth.method()}") + } + if (auth.url() != url) { + return Result.Malformed("url mismatch: expected $url, got ${auth.url()}") + } + if (body != null && body.isNotEmpty()) { + val expected = sha256(body).toHexKey() + if (auth.payloadHash() != expected) { + return Result.Malformed("payload hash mismatch") + } + } + + return Result.Verified(event.pubKey) + } + + sealed interface Result { + data class Verified( + val pubkey: HexKey, + ) : Result + + object Missing : Result + + data class Malformed( + val reason: String, + ) : Result + } + + companion object { + const val SCHEME = "Nostr " + } +} 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 index 5e2cd78f83..4ba2bcb507 100644 --- 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 @@ -42,6 +42,7 @@ data class RelayConfig( val options: OptionsSection = OptionsSection(), val limits: LimitsSection = LimitsSection(), val authorization: AuthorizationSection = AuthorizationSection(), + val admin: AdminSection = AdminSection(), ) { /** * Maps the `[info]` section into a [RelayInfo] used by the NIP-11 @@ -133,6 +134,17 @@ data class RelayConfig( val kind_blacklist: List = emptyList(), ) + /** + * NIP-86 relay management API. When [pubkeys] is non-empty, + * `LocalRelayServer` 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. + */ + data class AdminSection( + val pubkeys: List = emptyList(), + ) + companion object { private val mapper = tomlMapper { } diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/BanStoreTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/BanStoreTest.kt new file mode 100644 index 0000000000..3b85c82aaa --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/BanStoreTest.kt @@ -0,0 +1,96 @@ +/* + * 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.admin + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BanStoreTest { + @Test + fun pubkeyBanIsCaseInsensitive() { + val s = BanStore() + s.banPubkey("ABCDEF1234".padEnd(64, '0'), "spam") + assertTrue(s.isBanned("abcdef1234".padEnd(64, '0'))) + s.unbanPubkey("abcdef1234".padEnd(64, '0')) + assertFalse(s.isBanned("ABCDEF1234".padEnd(64, '0'))) + } + + @Test + fun allowListEmptyMeansEveryoneAllowed() { + val s = BanStore() + assertFalse(s.hasAllowList()) + // No allow list → policy decision is purely deny-based; the + // store doesn't say a pubkey IS allowed unless it's listed. + assertFalse(s.isAllowedPubkey("aaaa".padEnd(64, '0'))) + } + + @Test + fun allowListNonEmptyTracksMembers() { + val s = BanStore() + s.allowPubkey("aa".padEnd(64, '0'), "trusted") + assertTrue(s.hasAllowList()) + assertTrue(s.isAllowedPubkey("aa".padEnd(64, '0'))) + assertFalse(s.isAllowedPubkey("bb".padEnd(64, '0'))) + s.unallowPubkey("aa".padEnd(64, '0')) + assertFalse(s.hasAllowList()) + } + + @Test + fun eventBanRoundTrip() { + val s = BanStore() + s.banEvent("ee".padEnd(64, '0'), "policy") + assertTrue(s.isBannedEvent("EE".padEnd(64, '0'))) + s.allowEvent("ee".padEnd(64, '0')) + assertFalse(s.isBannedEvent("ee".padEnd(64, '0'))) + } + + @Test + fun kindAllowDenyRules() { + val s = BanStore() + // Empty allow + empty deny → every kind is allowed. + assertTrue(s.isKindAllowed(1)) + + s.allowKind(1) + s.allowKind(7) + // Allow non-empty → only listed kinds are allowed. + assertTrue(s.isKindAllowed(1)) + assertFalse(s.isKindAllowed(4)) + + s.disallowKind(7) + // Disallowing a kind removes it from the allow list and blocks. + assertFalse(s.isKindAllowed(7)) + assertTrue(s.isKindAllowed(1)) + assertEquals(listOf(1), s.listAllowedKinds()) + assertEquals(listOf(7), s.listDisallowedKinds()) + } + + @Test + fun listsReflectStateForAuditTrail() { + val s = BanStore() + s.banPubkey("aa".padEnd(64, '0'), "spam") + s.banPubkey("bb".padEnd(64, '0'), null) + val banned = s.listBannedPubkeys().toMap() + assertEquals("spam", banned["aa".padEnd(64, '0')]) + assertEquals(null, banned["bb".padEnd(64, '0')]) + } +} diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt new file mode 100644 index 0000000000..0aabacd554 --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt @@ -0,0 +1,232 @@ +/* + * 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.admin + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.relay.LocalRelayServer +import com.vitorpamplona.quartz.relay.Relay +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Drives a real `LocalRelayServer` over HTTP and proves the NIP-86 + * admin RPC flow works end-to-end: NIP-98 auth, admin allow-list + * gate, ban mutation, and the resulting policy effect on a follow-up + * EVENT publish. + */ +class Nip86EndToEndTest { + private lateinit var relay: Relay + private lateinit var server: LocalRelayServer + private lateinit var scope: CoroutineScope + private lateinit var nostrClient: NostrClient + + private val httpClient = OkHttpClient.Builder().build() + + private val admin = NostrSignerSync(KeyPair()) + private val outsider = NostrSignerSync(KeyPair()) + private val targetUser = NostrSignerSync(KeyPair()) + + @BeforeTest + fun setup() { + val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl() + relay = Relay(url = placeholder) + server = + LocalRelayServer( + relay = relay, + host = "127.0.0.1", + port = 0, + adminPubkeys = setOf(admin.pubKey), + ).start() + scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient } + nostrClient = NostrClient(builder, scope) + } + + @AfterTest + fun teardown() { + nostrClient.disconnect() + scope.cancel() + server.stop(gracePeriodMillis = 200, timeoutMillis = 500) + relay.close() + } + + private val httpUrl get() = server.url.replace("ws://", "http://") + + /** Sends a NIP-86 RPC request signed by [signer] and returns the raw HTTP response. */ + private fun rpc( + request: Nip86Request, + signer: NostrSignerSync, + ): okhttp3.Response { + val body = JsonMapper.toJson(request).encodeToByteArray() + val authTemplate = + HTTPAuthorizationEvent.build(url = httpUrl, method = "POST", file = body) + val authToken = signer.sign(authTemplate).toAuthToken() + return httpClient + .newCall( + Request + .Builder() + .url(httpUrl) + .post(body.toRequestBody("application/nostr+json+rpc".toMediaType())) + .header("Authorization", authToken) + .build(), + ).execute() + } + + @Test + fun supportedMethodsListsTheServersMethods() { + rpc(Nip86Request.supportedMethods(), admin).use { + assertEquals(200, it.code) + val json = JsonMapper.fromJson(it.body.string()) + val arr = json.result as JsonArray + val names = arr.map { e -> e.jsonPrimitive.content } + assertTrue(names.contains("supportedmethods")) + assertTrue(names.contains("banpubkey")) + } + } + + @Test + fun foreignSignerReturns403() { + rpc(Nip86Request.supportedMethods(), outsider).use { + assertEquals(403, it.code) + } + } + + @Test + fun missingAuthHeaderReturns401() { + val body = JsonMapper.toJson(Nip86Request.supportedMethods()).encodeToByteArray() + httpClient + .newCall( + Request + .Builder() + .url(httpUrl) + .post(body.toRequestBody("application/nostr+json+rpc".toMediaType())) + .build(), + ).execute() + .use { + assertEquals(401, it.code) + assertTrue(it.headers["WWW-Authenticate"]?.startsWith("Nostr") == true) + } + } + + @Test + fun banPubkeyBlocksSubsequentEventsFromThatAuthor() = + runBlocking { + val relayUrl = server.url.normalizeRelayUrl() + + // Baseline: targetUser can publish. + val before = nostrClient.publishAndConfirm(targetUser.sign(TextNoteEvent.build("first")), setOf(relayUrl)) + assertEquals(true, before) + + // Admin bans them. + rpc(Nip86Request.banPubkey(targetUser.pubKey, "spam"), admin).use { + assertEquals(200, it.code) + val resp = JsonMapper.fromJson(it.body.string()) + assertEquals(true, (resp.result as JsonPrimitive).boolean) + } + + // Subsequent EVENT from the banned author is rejected. + val after = nostrClient.publishAndConfirm(targetUser.sign(TextNoteEvent.build("second")), setOf(relayUrl)) + assertEquals(false, after, "DynamicBanPolicy must reject events from banned pubkeys") + } + + @Test + fun changeRelayNameFlowsToNip11Endpoint() { + rpc(Nip86Request.changeRelayName("renamed-by-admin"), admin).use { + assertEquals(200, it.code) + } + + // Read the NIP-11 endpoint and confirm the new name is live. + val response = + httpClient + .newCall( + Request + .Builder() + .url(httpUrl) + .header("Accept", "application/nostr+json") + .build(), + ).execute() + response.use { + val info = Nip11RelayInformation.fromJson(it.body.string()) + assertEquals("renamed-by-admin", info.name) + } + } + + @Test + fun adminEndpointDisabledWhenNoPubkeysConfigured() = + runBlocking { + // Spin up a *separate* server with no admin pubkeys. + val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl() + val openRelay = Relay(url = placeholder) + val openServer = + LocalRelayServer(openRelay, host = "127.0.0.1", port = 0).start() + try { + val openHttpUrl = openServer.url.replace("ws://", "http://") + val body = + JsonMapper.toJson(Nip86Request.supportedMethods()).encodeToByteArray() + val authToken = + admin + .sign( + HTTPAuthorizationEvent.build(url = openHttpUrl, method = "POST", file = body), + ).toAuthToken() + httpClient + .newCall( + Request + .Builder() + .url(openHttpUrl) + .post(body.toRequestBody("application/nostr+json+rpc".toMediaType())) + .header("Authorization", authToken) + .build(), + ).execute() + .use { + assertEquals(403, it.code) + } + } finally { + openServer.stop(gracePeriodMillis = 100, timeoutMillis = 500) + openRelay.close() + } + } +} diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86ServerTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86ServerTest.kt new file mode 100644 index 0000000000..284470dd28 --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86ServerTest.kt @@ -0,0 +1,198 @@ +/* + * 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.admin + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import com.vitorpamplona.quartz.relay.RelayInfo +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Nip86ServerTest { + private fun fixture(): Triple { + val store = BanStore() + val holder = + Holder(RelayInfo(Nip11RelayInformation(name = "before", description = "before-desc"))) + val server = Nip86Server(banStore = store, infoHolder = holder, store = null) + return Triple(server, store, holder) + } + + private class Holder( + var current: RelayInfo, + ) : Nip86Server.InfoHolder { + override fun get() = current + + override fun set(info: RelayInfo) { + current = info + } + } + + private val pk = "a".repeat(64) + private val pk2 = "b".repeat(64) + private val eventId = "c".repeat(64) + private val relayUrl = RelayUrlNormalizer.normalize("ws://test/") + + @Test + fun supportedMethodsRoundTrip() = + runBlocking { + val (server, _, _) = fixture() + val resp = server.dispatch(Nip86Request.supportedMethods()) + assertNull(resp.error) + val arr = resp.result as JsonArray + val names = arr.map { it.jsonPrimitive.content } + assertTrue(Nip86Method.SUPPORTED_METHODS in names) + assertTrue(Nip86Method.BAN_PUBKEY in names) + assertTrue(Nip86Method.CHANGE_RELAY_NAME in names) + } + + @Test + fun banPubkeyMutatesStoreAndListsRoundTripWithReason() = + runBlocking { + val (server, banStore, _) = fixture() + + val ok = server.dispatch(Nip86Request.banPubkey(pk, "spam")) + assertEquals(true, (ok.result as JsonPrimitive).boolean) + assertTrue(banStore.isBanned(pk)) + + val list = server.dispatch(Nip86Request.listBannedPubkeys()) + val parsed = + kotlinx.serialization.json.Json + .decodeFromJsonElement( + kotlinx.serialization.builtins.ListSerializer(BannedPubkey.serializer()), + list.result as JsonArray, + ) + assertEquals(1, parsed.size) + assertEquals(pk, parsed[0].pubkey) + assertEquals("spam", parsed[0].reason) + + server.dispatch(Nip86Request.unbanPubkey(pk)) + assertTrue(banStore.listBannedPubkeys().isEmpty()) + } + + @Test + fun allowPubkeyAndListRoundTrip() = + runBlocking { + val (server, banStore, _) = fixture() + server.dispatch(Nip86Request.allowPubkey(pk, "trusted")) + server.dispatch(Nip86Request.allowPubkey(pk2)) + assertTrue(banStore.hasAllowList()) + + val resp = server.dispatch(Nip86Request.listAllowedPubkeys()) + val list = + kotlinx.serialization.json.Json + .decodeFromJsonElement( + kotlinx.serialization.builtins.ListSerializer(AllowedPubkey.serializer()), + resp.result as JsonArray, + ) + assertEquals(2, list.size) + assertEquals(setOf(pk, pk2), list.map { it.pubkey }.toSet()) + } + + @Test + fun banEventMarksIdAndDeletesFromStoreWhenStorePresent() = + runBlocking { + val (server, banStore, _) = fixture() + server.dispatch(Nip86Request.banEvent(eventId, "off-topic")) + assertTrue(banStore.isBannedEvent(eventId)) + + val resp = server.dispatch(Nip86Request.listBannedEvents()) + val list = + kotlinx.serialization.json.Json + .decodeFromJsonElement( + kotlinx.serialization.builtins.ListSerializer(BannedEvent.serializer()), + resp.result as JsonArray, + ) + assertEquals(1, list.size) + assertEquals(eventId, list[0].id) + assertEquals("off-topic", list[0].reason) + + // allowevent (which is "unban") removes the entry. + server.dispatch(Nip86Request.allowEvent(eventId)) + assertTrue(banStore.listBannedEvents().isEmpty()) + } + + @Test + fun allowKindAndDisallowKind() = + runBlocking { + val (server, banStore, _) = fixture() + server.dispatch(Nip86Request.allowKind(1)) + server.dispatch(Nip86Request.allowKind(7)) + server.dispatch(Nip86Request.disallowKind(4)) + + val list = server.dispatch(Nip86Request.listAllowedKinds()) + val ints = (list.result as JsonArray).map { it.jsonPrimitive.int } + assertEquals(listOf(1, 7), ints) + + assertTrue(banStore.isKindAllowed(1)) + assertTrue(banStore.isKindAllowed(7)) + assertEquals(false, banStore.isKindAllowed(4)) + assertEquals(false, banStore.isKindAllowed(99)) + } + + @Test + fun changeRelayNameDescriptionIconRewriteInfoDoc() = + runBlocking { + val (server, _, holder) = fixture() + assertEquals("before", holder.current.document.name) + + server.dispatch(Nip86Request.changeRelayName("after")) + assertEquals("after", holder.current.document.name) + + server.dispatch(Nip86Request.changeRelayDescription("nice relay")) + assertEquals("nice relay", holder.current.document.description) + + server.dispatch(Nip86Request.changeRelayIcon("https://x/icon.png")) + assertEquals("https://x/icon.png", holder.current.document.icon) + } + + @Test + fun unsupportedMethodReturnsError() = + runBlocking { + val (server, _, _) = fixture() + val resp = server.dispatch(Nip86Request(method = "frobnicate")) + assertNotNull(resp.error) + assertTrue(resp.error!!.contains("frobnicate")) + } + + @Test + fun missingParamsAreReportedAsErrors() = + runBlocking { + val (server, _, _) = fixture() + // banpubkey requires at least one positional param. + val resp = server.dispatch(Nip86Request(method = Nip86Method.BAN_PUBKEY)) + assertNotNull(resp.error) + assertTrue(resp.error!!.startsWith("invalid params")) + } +} diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifierTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifierTest.kt new file mode 100644 index 0000000000..50aabd6ca3 --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifierTest.kt @@ -0,0 +1,121 @@ +/* + * 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.admin + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class Nip98AuthVerifierTest { + private val verifier = Nip98AuthVerifier(now = { 1_000L }) + + private fun signedToken( + url: String, + method: String, + body: ByteArray? = null, + signer: NostrSignerSync = NostrSignerSync(KeyPair()), + createdAt: Long = 1_000L, + ): Pair { + val template = HTTPAuthorizationEvent.build(url = url, method = method, file = body, createdAt = createdAt) + val signed = signer.sign(template) + return signer.pubKey to signed.toAuthToken() + } + + @Test + fun verifiesAValidPostWithBody() = + runBlocking { + val body = "hello".encodeToByteArray() + val (pubkey, header) = signedToken("http://x/", "POST", body) + val r = verifier.verify(header, "POST", "http://x/", body) + assertIs(r) + assertEquals(pubkey, r.pubkey) + } + + @Test + fun missingHeaderReturnsMissing() { + val r = verifier.verify(null, "POST", "http://x/", null) + assertIs(r) + } + + @Test + fun wrongSchemeIsMalformed() { + val r = verifier.verify("Bearer abc", "POST", "http://x/", null) + assertIs(r) + assertTrue(r.reason.contains("Nostr")) + } + + @Test + fun urlMismatchIsMalformed() { + val (_, header) = signedToken("http://x/", "POST") + val r = verifier.verify(header, "POST", "http://y/", null) + assertIs(r) + assertTrue(r.reason.contains("url mismatch")) + } + + @Test + fun methodMismatchIsMalformed() { + val (_, header) = signedToken("http://x/", "POST") + val r = verifier.verify(header, "GET", "http://x/", null) + assertIs(r) + assertTrue(r.reason.contains("method mismatch")) + } + + @Test + fun payloadHashMismatchIsMalformed() { + val (_, header) = signedToken("http://x/", "POST", "alpha".encodeToByteArray()) + val r = verifier.verify(header, "POST", "http://x/", "beta".encodeToByteArray()) + assertIs(r) + assertTrue(r.reason.contains("payload hash")) + } + + @Test + fun staleCreatedAtIsMalformed() { + // Verifier's clock is fixed at 1_000; sign a token created 5 + // minutes earlier — outside the 60s tolerance. + val (_, header) = signedToken("http://x/", "POST", createdAt = 1_000L - 600) + val r = verifier.verify(header, "POST", "http://x/", null) + assertIs(r) + assertTrue(r.reason.contains("created_at")) + } + + @Test + fun nonAuthEventKindIsMalformed() { + // Build a kind-1 event by hand and shove it into the header — it + // must be rejected because NIP-98 specifically uses kind 27235. + val signer = NostrSignerSync(KeyPair()) + val template = + com.vitorpamplona.quartz.nip10Notes.TextNoteEvent + .build("not an auth event") + val signed = signer.sign(template) + val token = + "Nostr " + + kotlin.io.encoding.Base64 + .encode(signed.toJson().encodeToByteArray()) + val r = verifier.verify(token, "POST", "http://x/", null) + assertIs(r) + assertTrue(r.reason.contains("kind")) + } +} From b7ba3f6d158ed55dbe17d89323b0c7da4b1235e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 03:22:56 +0000 Subject: [PATCH 10/17] fix(relay): security + concurrency audit + on-disk persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses every Critical/High/Medium finding from the code-quality audit, plus the operator request to persist NIP-86 admin state and NIP-11 doc mutations across restarts. ## Security (audit Critical) - C1 — NIP-98 verifier no longer trusts the client-supplied `Host` header. New `LocalRelayServer.publicUrl` (and `[admin].public_url`) must be set in any production deployment; the verifier compares against this fixed string. Falls back to Host for loopback unit tests with a docstring warning. - C2 — NIP-98 replay protection. Verifier now keeps a bounded LinkedHashMap of recently-accepted event ids (TTL = 2× tolerance, max 1024 entries, LRU evicted) and rejects duplicates. Replay check runs LAST so a one-shot id isn't burned on a request that fails signature/url/method validation. - C3 — NIP-86 admin POST body is now capped at 1 MiB (configurable via `LocalRelayServer.maxAdminBodyBytes`). The `Content-Length` header is honored as a fast pre-read reject; any body that exceeds the cap during streaming returns 413 PayloadTooLarge before being buffered. ## Concurrency / correctness (audit High) - H1 — Per-session writer coroutine + bounded outbound queue (SESSION_OUTGOING_BUFFER = 1024). When the queue fills, the connection is closed cleanly instead of silently `trySend`-ing into a void; subscribers will never silently miss EVENT/EOSE again. - H3 — `BanStore` kind ops serialize through a new `kindLock` so concurrent admin RPCs and policy reads see consistent state. `allowKind` and `disallowKind` are now symmetric: each removes the kind from the opposite set, so `allowKind(K)` after `disallowKind(K)` actually re-allows K. - H4 — `InProcessWebSocket` reconnect after disconnect is now supported. Each `connect()` allocates a fresh CoroutineScope + drainer channel, so a prior `disconnect()` (which cancelled the previous scope) doesn't leave the new connect with a dead drainer. - H5 — `Nip86Server.dispatch` re-throws `CancellationException` through the runCatching's getOrElse so structured concurrency works (cancellation no longer reported as an RPC error). - M1 — `LiveEventStore.query` dedupes events between the historical replay and the live SharedFlow during the in-flight overlap window. Events seen during `store.query` are tracked in a transient set and filtered out of the live stream until EOSE; the set is dropped after EOSE so live-only events don't accumulate memory. ## Operator-facing (audit Medium / Low) - L4 — Audit-trail log: every admin RPC writes a single structured line to stderr (pubkey + method + ok/error). - L3 — RelayConfig.resolveInfo() default supported_nips list now matches RelayInfo.default() (both advertise NIP-86). - M2 — Hex-id and pubkey params validated as 64-char hex on ban/allow/list methods; malformed input returns "invalid params" instead of being silently stored. - M4 — argparse supports `--key=value` form in addition to `--key value`. - M5 — `RelayHub.close()` is idempotent and rejects subsequent `getOrCreate` calls so a relay created mid-shutdown can't leak its store. - M7 — `LocalRelayServer.stop()` sets a `shuttingDown` flag *before* notifying clients; new WebSocket upgrades during the grace window are rejected so they don't miss the NOTICE. - L5 — Shutdown hook runCatching-wraps both `server.stop()` and `relay.close()` so a throw in one doesn't skip the other. - `--verify` was renamed to `--no-verify` semantically (verify is the default). The `--verify` flag is no longer documented. ## On-disk persistence (operator request) New `RelayStateStore` writes a single JSON sidecar file holding the live NIP-11 doc + all NIP-86 ban / allow / kind lists. Atomic write via temp + ATOMIC_MOVE rename so a crash mid-save can never leave the file half-written. - `BanStore` now takes an optional `onMutation: () -> Unit` callback; every mutation fires it. `Relay` wires it to `snapshot()` which captures BanStore + info into a `RelayPersistedState` and calls `RelayStateStore.save`. - `Relay` accepts a new `stateFile: File?` constructor arg. At construction it loads the snapshot via `RelayStateStore.load` and seeds the in-memory state — using a private `seedFromSnapshot` bulk-load that bypasses the mutation callback so we don't write back what we just read. - New config field `[admin].state_file = "/var/lib/quartz-relay/events.db.admin.json"`. Convention: place next to the SQLite event-store file. - Corrupt state files are tolerated: log to stderr and start fresh (refuse to silently overwrite operator data). ## Tests 8 new persistence tests: cold-boot writes nothing, ban/allow/info round-trip across restart, kind allow/deny round-trip, corrupt-file recovery, atomic write (no leftover .tmp), in-memory mode when no state file is configured, full RelayStateStore round-trip across all sections. Total :quartz-relay tests: 95, 0 failures. --- quartz-relay/build.gradle.kts | 1 + quartz-relay/config.example.toml | 12 + .../quartz/relay/InProcessWebSocket.kt | 41 +++- .../quartz/relay/LocalRelayServer.kt | 169 +++++++++++++- .../com/vitorpamplona/quartz/relay/Main.kt | 32 ++- .../com/vitorpamplona/quartz/relay/Relay.kt | 69 +++++- .../vitorpamplona/quartz/relay/RelayHub.kt | 18 +- .../quartz/relay/admin/BanStore.kt | 86 ++++++-- .../quartz/relay/admin/Nip86Server.kt | 14 ++ .../quartz/relay/admin/Nip98AuthVerifier.kt | 39 +++- .../quartz/relay/config/RelayConfig.kt | 22 +- .../relay/persistence/RelayStateStore.kt | 98 +++++++++ .../relay/persistence/PersistenceTest.kt | 208 ++++++++++++++++++ .../nip01Core/relay/server/LiveEventStore.kt | 21 +- 14 files changed, 777 insertions(+), 53 deletions(-) create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/persistence/RelayStateStore.kt create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/persistence/PersistenceTest.kt diff --git a/quartz-relay/build.gradle.kts b/quartz-relay/build.gradle.kts index 0585a8a70e..f8dc0f0fec 100644 --- a/quartz-relay/build.gradle.kts +++ b/quartz-relay/build.gradle.kts @@ -2,6 +2,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.jetbrainsKotlinJvm) + alias(libs.plugins.serialization) application } diff --git a/quartz-relay/config.example.toml b/quartz-relay/config.example.toml index 4e0134d037..0729446c70 100644 --- a/quartz-relay/config.example.toml +++ b/quartz-relay/config.example.toml @@ -68,3 +68,15 @@ require_auth = false # the listed pubkeys can run admin RPCs (banpubkey / banevent / # changerelayname / …). Empty (the default) disables the endpoint. # pubkeys = ["abcdef...64hex..."] +# +# Canonical URL the relay is reachable at, e.g. behind a reverse proxy. +# NIP-98 binds requests to this URL via the `u` tag. **Required** in +# any production deployment — without it, an attacker can spoof the +# Host header to bypass URL binding. +# public_url = "https://relay.example.com/" + +# Path for the JSON snapshot that persists NIP-86 admin state (ban +# lists + the live NIP-11 doc) across restarts. When unset, admin +# state is in-memory only and forgotten on every restart. Convention +# is to place this next to the SQLite event-store file. +# state_file = "/var/lib/quartz-relay/events.db.admin.json" diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/InProcessWebSocket.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/InProcessWebSocket.kt index a54560089b..0b50351a3c 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/InProcessWebSocket.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/InProcessWebSocket.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel @@ -40,40 +41,58 @@ import kotlinx.coroutines.launch * drained by a single coroutine, preserving message order per the * [WebSocketListener] contract. * - Server-side `send` callbacks → [WebSocketListener.onMessage]. + * + * Reconnect-after-disconnect is supported: each [connect] creates a + * fresh scope + drain channel so a previous [disconnect] (which + * cancels both) doesn't leave a dead drainer behind. */ class InProcessWebSocket( private val relay: Relay, private val out: WebSocketListener, ) : WebSocket { - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val incoming = Channel(UNLIMITED) + private var scope: CoroutineScope? = null + private var incoming: Channel? = null + private var drainJob: Job? = null private var session: RelaySession? = null override fun needsReconnect(): Boolean = session == null override fun connect() { if (session != null) return + val newScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val newIncoming = Channel(UNLIMITED) val s = relay.server.connect { json -> out.onMessage(json) } + + scope = newScope + incoming = newIncoming session = s - out.onOpen(0, false) - scope.launch { - for (msg in incoming) { - s.receive(msg) + drainJob = + newScope.launch { + for (msg in newIncoming) { + s.receive(msg) + } } - } + + out.onOpen(0, false) } override fun disconnect() { val s = session ?: return session = null - incoming.close() - scope.cancel() + incoming?.close() + incoming = null + drainJob = null + scope?.cancel() + scope = null s.close() out.onClosed(1000, "client disconnect") } override fun send(msg: String): Boolean { - if (session == null) return false - return incoming.trySend(msg).isSuccess + // Capture the current channel reference: we want to fail + // (return false) if the socket was disconnected, even if a + // racing thread is mid-`connect()`. + val ch = incoming ?: return false + return ch.trySend(msg).isSuccess } } diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt index 416f838439..a3b582bf8b 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt @@ -43,10 +43,11 @@ import io.ktor.server.routing.post import io.ktor.server.routing.routing import io.ktor.server.websocket.WebSockets import io.ktor.server.websocket.webSocket -import io.ktor.utils.io.toByteArray +import io.ktor.utils.io.readAvailable import io.ktor.websocket.Frame import io.ktor.websocket.readText import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.concurrent.ConcurrentHashMap @@ -89,6 +90,29 @@ class LocalRelayServer( * gated by NIP-98 HTTP-Auth membership in this set. */ val adminPubkeys: Set = emptySet(), + /** + * Canonical public URL the relay is reachable at, e.g. + * `https://relay.example.com/`. NIP-98 admin requests must sign + * the **same** URL string they're sending to. When the relay sits + * behind TLS termination or a reverse proxy, the `Host` header + * the relay sees does not match what the client signs, so the + * verifier must compare against this configured value. + * + * `null` (the default) falls back to the request's `Host` header + * with `http://` — fine for local-loopback unit tests, **NOT + * SAFE** in a public deployment because an attacker can spoof + * `Host` and bind their signature to any URL. + */ + val publicUrl: String? = null, + /** + * Maximum body size accepted on the NIP-86 POST endpoint, in + * bytes. Bounded *before* auth verification because we read the + * body to compute its sha256 for NIP-98's payload binding — + * unbounded reads would let an unauthenticated attacker stream + * gigabytes and OOM the relay. 1 MiB easily fits any plausible + * RPC payload. + */ + val maxAdminBodyBytes: Int = 1 shl 20, ) { /** * Bridges the relay's mutable [RelayInfo] to [Nip86Server.InfoHolder] @@ -110,6 +134,16 @@ class LocalRelayServer( private var engine: CIOApplicationEngine? = null private var resolvedPort: Int = -1 + /** + * Set when [stop] begins. Once true, the WebSocket handler refuses + * new upgrades — Ktor's `engine.stop` will eventually do this too, + * but Ktor's grace window means new connections can land between + * `notifyShutdown` and the actual port-close, missing the NOTICE + * we just sent to existing clients. + */ + @Volatile + private var shuttingDown: Boolean = false + /** * Active client sessions, registered when their WebSocket handler * runs and removed on disconnect. Exposed (read-only) so [stop] can @@ -166,20 +200,61 @@ class LocalRelayServer( handleNip86(call) } webSocket(path) { + if (shuttingDown) { + // Just return — Ktor closes the WS for us. + // We can't `close(reason)` here because the + // CIO engine's outgoing channel may already + // be torn down during shutdown. + return@webSocket + } + // Per-session outbound queue. The relay's + // `connect` callback runs on whatever thread + // produced the message — it can't suspend, so + // we hand off to a dedicated writer coroutine + // that does suspend on `outgoing.send` and thus + // applies real backpressure on slow clients. + // When the queue fills, that's a slow consumer + // — drop the connection cleanly so subscribers + // don't silently miss EVENT/EOSE. + val outQueue = + kotlinx.coroutines.channels + .Channel(capacity = SESSION_OUTGOING_BUFFER) + val writerJob = + launch { + try { + for (json in outQueue) { + outgoing.send(Frame.Text(json)) + } + } catch (_: kotlinx.coroutines.channels.ClosedSendChannelException) { + // socket closed — let the handler's + // finally block run normal teardown + } + } + var droppedForBackpressure = false val session = relay.server.connect { json -> - // ktor-websockets schedules outgoing frames on its own - // dispatcher; trySend never blocks the relay thread. - outgoing.trySend(Frame.Text(json)) + val res = outQueue.trySend(json) + if (!res.isSuccess && !res.isClosed) { + // Buffer is full → slow client. + // Mark + close the queue; the + // writer drains, then we let the + // outer handler's finally close + // the WS session. + droppedForBackpressure = true + outQueue.close() + } } activeSessions.add(session) try { incoming.consumeEach { frame -> + if (droppedForBackpressure) return@consumeEach if (frame is Frame.Text) { session.receive(frame.readText()) } } } finally { + outQueue.close() + writerJob.cancel() activeSessions.remove(session) session.close() } @@ -223,6 +298,11 @@ class LocalRelayServer( timeoutMillis: Long = 10_000, ) { val e = engine ?: return + // Order: (1) refuse new connections so they don't slip in and + // miss the NOTICE; (2) NOTICE every existing session so + // well-behaved clients reconnect later; (3) hand off to Ktor + // for the grace + timeout dance. + shuttingDown = true notifyShutdown() e.stop(gracePeriodMillis, timeoutMillis) engine = null @@ -251,15 +331,30 @@ class LocalRelayServer( return } - val body = call.receiveChannel().toByteArray() + // Cap the body BEFORE we read it. We have to read the bytes + // (NIP-98 payload-hash binds them), but unauthenticated + // attackers shouldn't be able to stream gigabytes here. + val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull() + if (declared != null && declared > maxAdminBodyBytes) { + call.respondText( + "request body exceeds $maxAdminBodyBytes-byte cap", + ContentType.Text.Plain, + HttpStatusCode.PayloadTooLarge, + ) + return + } + val body = readBoundedBody(call, maxAdminBodyBytes) ?: return + val authHeader = call.request.header(HttpHeaders.Authorization) - // NIP-86 spec: the URL the client signed must be the relay's - // canonical http(s) URL, not the WS one. We reconstruct it from - // the request so the comparison is symmetric whether the - // operator runs the relay behind a reverse proxy or directly. + // The URL the client signed must match the relay's CANONICAL + // public URL — not whatever `Host` header reaches us. An + // attacker can spoof `Host`, and behind TLS termination the + // verifier would compare against the wrong scheme. Operators + // configure [publicUrl] explicitly. The Host fallback is for + // local loopback unit tests only and is documented as unsafe. val signedUrl = - "http://" + - (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path + publicUrl + ?: ("http://" + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path) val verification = nip98.verify(authHeader, method = "POST", url = signedUrl, body = body) val pubkey = @@ -310,6 +405,19 @@ class LocalRelayServer( } val response: Nip86Response = nip86.dispatch(req) + + // Audit log: structured single line so an operator can grep + // "nip86" / pubkey / method without a logging framework + // dependency. Keep it best-effort — System.err is already what + // the rest of Main.kt uses, and a missing log line shouldn't + // fail the response. + runCatching { + System.err.println( + "nip86 audit pubkey=$pubkey method=${req.method} ok=${response.error == null}" + + (response.error?.let { " error=$it" } ?: ""), + ) + } + call.respondText( JsonMapper.toJson(response), ContentType.parse("application/nostr+json+rpc"), @@ -317,6 +425,35 @@ class LocalRelayServer( ) } + /** + * Reads up to [maxBytes] bytes from the request body and returns + * them. If the stream produces more than [maxBytes] (i.e. a + * lying or absent `Content-Length`), responds 413 and returns + * `null` — caller stops handling. + */ + private suspend fun readBoundedBody( + call: io.ktor.server.application.ApplicationCall, + maxBytes: Int, + ): ByteArray? { + val ch = call.receiveChannel() + val buf = ByteArray(maxBytes + 1) + var pos = 0 + while (pos <= maxBytes) { + val read = ch.readAvailable(buf, pos, buf.size - pos) + if (read <= 0) break + pos += read + } + if (pos > maxBytes) { + call.respondText( + "request body exceeds $maxBytes-byte cap", + ContentType.Text.Plain, + HttpStatusCode.PayloadTooLarge, + ) + return null + } + return buf.copyOfRange(0, pos) + } + /** * Best-effort NOTICE to every active client. Failures are * swallowed — a flaky socket on its way out is exactly the case @@ -329,4 +466,14 @@ class LocalRelayServer( runCatching { session.send(notice) } } } + + companion object { + /** + * Per-session outbound buffer size. When a slow client falls + * this many frames behind, we close their connection rather + * than silently dropping further frames (which would corrupt + * NIP-01 by missing EVENT/EOSE messages). + */ + const val SESSION_OUTGOING_BUFFER: Int = 1024 + } } 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 e0dc801cfc..cdfe9890be 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 @@ -101,7 +101,8 @@ fun main(args: Array) { composePolicy(config, advertisedUrl, requireAuth, verifySigs) } - val relay = Relay(advertisedUrl, store, info, policyBuilder) + val stateFile = config.admin.state_file?.let { File(it) } + val relay = Relay(advertisedUrl, store, info, policyBuilder, stateFile = stateFile) // Frame cap honors max_ws_frame_bytes when set; max_ws_message_bytes // is treated as the same cap (Ktor's WebSockets plugin only exposes // a single per-frame limit; multi-frame messages remain unbounded). @@ -115,12 +116,15 @@ fun main(args: Array) { path = path, maxFrameBytes = frameLimit, adminPubkeys = config.admin.pubkeys.toSet(), + publicUrl = config.admin.public_url, ).start() Runtime.getRuntime().addShutdownHook( Thread { - server.stop() - relay.close() + // Each step wrapped so a throw in `server.stop()` doesn't + // skip `relay.close()` (which closes the SQLite store). + runCatching { server.stop() } + runCatching { relay.close() } }, ) @@ -188,13 +192,23 @@ private fun parseArgs(args: Array): Args { while (i < args.size) { val a = args[i] if (a.startsWith("--")) { - val next = args.getOrNull(i + 1) - if (next != null && !next.startsWith("--")) { - opts[a] = next - i += 2 - } else { - flags += a + // Support both `--key value` and `--key=value`. Splitting + // on the first `=` lets operators paste config values that + // happen to contain `=` (e.g. NIP-11 contact emails) by + // using the space-separated form. + val eq = a.indexOf('=') + if (eq > 0) { + opts[a.substring(0, eq)] = a.substring(eq + 1) i += 1 + } else { + val next = args.getOrNull(i + 1) + if (next != null && !next.startsWith("--")) { + opts[a] = next + i += 2 + } else { + flags += a + i += 1 + } } } else { i += 1 diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt index b6e155005c..8e535093db 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt @@ -32,7 +32,11 @@ import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.relay.admin.BanStore import com.vitorpamplona.quartz.relay.admin.DynamicBanPolicy +import com.vitorpamplona.quartz.relay.persistence.BannedEntry +import com.vitorpamplona.quartz.relay.persistence.RelayPersistedState +import com.vitorpamplona.quartz.relay.persistence.RelayStateStore import kotlinx.coroutines.SupervisorJob +import java.io.File import kotlin.coroutines.CoroutineContext /** @@ -57,20 +61,40 @@ class Relay( info: RelayInfo = RelayInfo.default(url), policyBuilder: () -> IRelayPolicy = { EmptyPolicy }, parentContext: CoroutineContext = SupervisorJob(), + /** + * Optional path for the operator-state JSON snapshot. When set, + * the file is loaded at boot to seed [info] and [banStore], and + * rewritten atomically on every NIP-86 mutation and + * [updateInfo] call so admin actions survive restarts. + * + * Convention: place next to the SQLite event-store file + * (e.g. `events.db` → `events.db.admin.json`). `null` keeps + * everything in memory only — fine for tests. + */ + stateFile: File? = null, ) : AutoCloseable { + private val stateStore: RelayStateStore? = stateFile?.let { RelayStateStore(it) } + /** * 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. + * + * If a [RelayStateStore] is configured and the snapshot exists, + * the persisted info doc takes precedence over the constructor + * default — operators expect their last `changerelayname` to + * survive a restart. */ @Volatile - var info: RelayInfo = info + var info: RelayInfo = + stateStore?.load()?.info?.let { RelayInfo(it) } ?: info private set /** Mutates the live NIP-11 doc. Called by [admin.Nip86Server]. */ fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) { info = RelayInfo(transform(info.document)) + snapshot() } /** @@ -78,7 +102,48 @@ class Relay( * [admin.Nip86Server] mutate this; the policy stack consults it on * every accept call via [DynamicBanPolicy]. */ - val banStore: BanStore = BanStore() + val banStore: BanStore = BanStore(onMutation = { snapshot() }) + + init { + // Seed the in-memory ban state from disk *without* triggering + // [snapshot] on every entry — the snapshot is exactly what we + // just loaded. + stateStore?.load()?.let { snap -> + banStore.seedFromSnapshot( + bannedPubkeys = snap.bannedPubkeys.map { it.key to it.reason }, + allowedPubkeys = snap.allowedPubkeys.map { it.key to it.reason }, + bannedEvents = snap.bannedEvents.map { it.key to it.reason }, + allowedKinds = snap.allowedKinds, + disallowedKinds = snap.disallowedKinds, + ) + } + } + + /** + * Writes the current state (NIP-11 doc + ban lists) to disk. + * No-op when no `stateFile` was configured. + * + * Best-effort: any I/O failure is logged to stderr and swallowed + * so an unwritable disk doesn't take the relay down. Operators + * monitor for missing snapshots out-of-band. + */ + fun snapshot() { + val s = stateStore ?: return + runCatching { + s.save( + RelayPersistedState( + 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(), + ), + ) + }.onFailure { + System.err.println("warning: failed to write relay state file: ${it.message}") + } + } val server = NostrServer( diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt index 6f967f28dc..f1d6315205 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt @@ -53,10 +53,15 @@ class RelayHub( AutoCloseable { private val relays = ConcurrentHashMap() - fun getOrCreate(url: NormalizedRelayUrl): Relay = - relays.getOrPut(url) { + @Volatile + private var closed = false + + fun getOrCreate(url: NormalizedRelayUrl): Relay { + check(!closed) { "RelayHub has been closed" } + return relays.getOrPut(url) { Relay(url = url, policyBuilder = defaultPolicy) } + } fun getOrCreate(url: String): Relay = getOrCreate(RelayUrlNormalizer.normalize(url)) @@ -69,8 +74,15 @@ class RelayHub( out: WebSocketListener, ): WebSocket = InProcessWebSocket(getOrCreate(url), out) + /** + * Idempotent. Sets the closed flag first so concurrent + * `getOrCreate` calls fail-fast — otherwise a relay created + * between iteration and clear would leak (its store would never + * be closed). + */ override fun close() { - relays.values.forEach { it.close() } + closed = true + relays.values.forEach { runCatching { it.close() } } relays.clear() } } diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt index 9ef38df27c..8d69582b3e 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt @@ -35,7 +35,14 @@ import java.util.concurrent.ConcurrentHashMap * layered on by replacing this class behind the [DynamicBanPolicy] * interface. */ -class BanStore { +class BanStore( + /** + * Called after every mutation. The relay uses this to snapshot the + * full state to disk so admin actions survive a restart. `null` + * disables persistence (in-memory only — fine for tests). + */ + private val onMutation: (() -> Unit)? = null, +) { /** * Pubkeys whose events the relay rejects. Compared case-insensitive * (lowercased on insert / lookup) so an admin pasting a hex pubkey @@ -59,12 +66,17 @@ class BanStore { /** * Allowed kinds. When non-empty, events whose kind is not in the - * list are rejected. + * list are rejected. Kind ops mutate two related sets (allow + + * disallow) and need to look symmetric to readers, so we serialise + * all kind reads/writes through [kindLock] rather than rely on + * the per-set thread safety of `ConcurrentHashMap.newKeySet`. */ - private val allowedKinds = ConcurrentHashMap.newKeySet() + private val allowedKinds = HashSet() /** Disallowed kinds. Always blocks regardless of [allowedKinds]. */ - private val disallowedKinds = ConcurrentHashMap.newKeySet() + private val disallowedKinds = HashSet() + + private val kindLock = Any() // -- Pubkey ban list ----------------------------------------------------- @@ -73,10 +85,12 @@ class BanStore { reason: String? = null, ) { bannedPubkeys[pubkey.lowercase()] = reasonOrEmpty(reason) + fireMutation() } fun unbanPubkey(pubkey: HexKey) { bannedPubkeys.remove(pubkey.lowercase()) + fireMutation() } fun isBanned(pubkey: HexKey): Boolean = bannedPubkeys.containsKey(pubkey.lowercase()) @@ -90,10 +104,12 @@ class BanStore { reason: String? = null, ) { allowedPubkeys[pubkey.lowercase()] = reasonOrEmpty(reason) + fireMutation() } fun unallowPubkey(pubkey: HexKey) { allowedPubkeys.remove(pubkey.lowercase()) + fireMutation() } fun isAllowedPubkey(pubkey: HexKey): Boolean = allowedPubkeys.containsKey(pubkey.lowercase()) @@ -109,11 +125,13 @@ class BanStore { reason: String? = null, ) { bannedEventIds[eventId.lowercase()] = reasonOrEmpty(reason) + fireMutation() } /** Removes an event id from the ban list. Mirrors NIP-86 `allowevent`. */ fun allowEvent(eventId: HexKey) { bannedEventIds.remove(eventId.lowercase()) + fireMutation() } fun isBannedEvent(eventId: HexKey): Boolean = bannedEventIds.containsKey(eventId.lowercase()) @@ -122,23 +140,63 @@ class BanStore { // -- Kind allow / deny -------------------------------------------------- + /** + * `allowKind` and `disallowKind` are symmetric: each adds to its + * own set AND removes the kind from the opposite set. Otherwise + * an `allowKind(K)` after a `disallowKind(K)` would leave K in + * both sets and stay blocked, surprising operators. + */ fun allowKind(kind: Int) { - allowedKinds.add(kind) + synchronized(kindLock) { + disallowedKinds.remove(kind) + allowedKinds.add(kind) + } + fireMutation() } fun disallowKind(kind: Int) { - disallowedKinds.add(kind) - // Disallowing a kind implicitly removes it from the allow list. - allowedKinds.remove(kind) + synchronized(kindLock) { + allowedKinds.remove(kind) + disallowedKinds.add(kind) + } + fireMutation() } - fun listAllowedKinds(): List = allowedKinds.sorted() + fun listAllowedKinds(): List = synchronized(kindLock) { allowedKinds.sorted() } - fun listDisallowedKinds(): List = disallowedKinds.sorted() + fun listDisallowedKinds(): List = synchronized(kindLock) { disallowedKinds.sorted() } - fun isKindAllowed(kind: Int): Boolean { - if (kind in disallowedKinds) return false - if (allowedKinds.isEmpty()) return true - return kind in allowedKinds + fun isKindAllowed(kind: Int): Boolean = + synchronized(kindLock) { + if (kind in disallowedKinds) return false + if (allowedKinds.isEmpty()) return true + return kind in allowedKinds + } + + /** + * Bulk-load state without firing [onMutation]. Used at startup to + * seed the in-memory state from a persisted snapshot — we don't + * want every individual `put` to trigger another disk write. After + * this call the store behaves exactly as if every entry had been + * mutated through the public API. + */ + internal fun seedFromSnapshot( + bannedPubkeys: List>, + allowedPubkeys: List>, + bannedEvents: List>, + allowedKinds: List, + disallowedKinds: List, + ) { + bannedPubkeys.forEach { (k, r) -> this.bannedPubkeys[k.lowercase()] = reasonOrEmpty(r) } + allowedPubkeys.forEach { (k, r) -> this.allowedPubkeys[k.lowercase()] = reasonOrEmpty(r) } + bannedEvents.forEach { (k, r) -> this.bannedEventIds[k.lowercase()] = reasonOrEmpty(r) } + synchronized(kindLock) { + this.allowedKinds.addAll(allowedKinds) + this.disallowedKinds.addAll(disallowedKinds) + } + } + + private fun fireMutation() { + onMutation?.invoke() } } diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt index 2f1eccbc61..8324f338ea 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt @@ -106,12 +106,14 @@ class Nip86Server( Nip86Method.BAN_PUBKEY -> { val (pk, reason) = req.params.stringPair() ?: return malformed("expected [pubkey, reason?]") + if (!isHex64(pk)) return malformed("pubkey must be 64-char hex") banStore.banPubkey(pk, reason) result(JsonPrimitive(true)) } Nip86Method.UNBAN_PUBKEY -> { val (pk, _) = req.params.stringPair() ?: return malformed("expected [pubkey]") + if (!isHex64(pk)) return malformed("pubkey must be 64-char hex") banStore.unbanPubkey(pk) result(JsonPrimitive(true)) } @@ -127,12 +129,14 @@ class Nip86Server( Nip86Method.ALLOW_PUBKEY -> { val (pk, reason) = req.params.stringPair() ?: return malformed("expected [pubkey, reason?]") + if (!isHex64(pk)) return malformed("pubkey must be 64-char hex") banStore.allowPubkey(pk, reason) result(JsonPrimitive(true)) } Nip86Method.UNALLOW_PUBKEY -> { val (pk, _) = req.params.stringPair() ?: return malformed("expected [pubkey]") + if (!isHex64(pk)) return malformed("pubkey must be 64-char hex") banStore.unallowPubkey(pk) result(JsonPrimitive(true)) } @@ -148,6 +152,7 @@ class Nip86Server( Nip86Method.BAN_EVENT -> { val (id, reason) = req.params.stringPair() ?: return malformed("expected [event_id, reason?]") + if (!isHex64(id)) return malformed("event_id must be 64-char hex") banStore.banEvent(id, reason) // Also remove the event from the store if it's there. store?.delete(Filter(ids = listOf(id))) @@ -156,6 +161,7 @@ class Nip86Server( Nip86Method.ALLOW_EVENT -> { val (id, _) = req.params.stringPair() ?: return malformed("expected [event_id]") + if (!isHex64(id)) return malformed("event_id must be 64-char hex") banStore.allowEvent(id) result(JsonPrimitive(true)) } @@ -208,6 +214,10 @@ class Nip86Server( } } }.getOrElse { e -> + // CancellationException must propagate so structured + // concurrency works — swallowing it would let a parent + // cancellation be reported as a benign RPC error. + if (e is kotlinx.coroutines.CancellationException) throw e Nip86Response(error = "internal: ${e.message ?: e::class.simpleName}") } @@ -270,3 +280,7 @@ private fun JsonArray.firstInt(): Int? = }.getOrNull() private fun JsonPrimitive.contentOrNull(): String? = if (this == JsonNull) null else content + +private val HEX64 = Regex("[0-9a-fA-F]{64}") + +private fun isHex64(s: String): Boolean = HEX64.matches(s) diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt index 1ce0aa72a6..86b3074c98 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt @@ -51,6 +51,20 @@ class Nip98AuthVerifier( /** Allowed clock skew in seconds. NIP-98 says 60. */ private val toleranceSeconds: Long = 60, ) { + /** + * Recently-accepted event ids → expiry epoch second. Bounded to + * [MAX_REPLAY_ENTRIES] (LRU eviction); each entry expires after + * `2 × toleranceSeconds` (twice the accepted window so a token + * can't be reused by an attacker who buffers across the boundary). + * + * `synchronized` access is sufficient — the table is small (~hundreds + * of entries at most) and admin RPC traffic is low-rate. + */ + private val seenEventIds: LinkedHashMap = + object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry?): Boolean = size > MAX_REPLAY_ENTRIES + } + @OptIn(ExperimentalEncodingApi::class) fun verify( authorizationHeader: String?, @@ -81,7 +95,8 @@ class Nip98AuthVerifier( } if (!event.verify()) return Result.Malformed("bad event signature or id") - val skew = abs(event.createdAt - now()) + val nowSec = now() + val skew = abs(event.createdAt - nowSec) if (skew > toleranceSeconds) { return Result.Malformed("created_at is ${skew}s away from now (max ${toleranceSeconds}s)") } @@ -110,6 +125,20 @@ class Nip98AuthVerifier( } } + // Replay check — done LAST so we don't burn a one-shot id on a + // request that would otherwise have failed signature/url/etc. + val expiry = nowSec + 2 * toleranceSeconds + synchronized(seenEventIds) { + // Evict expired entries while we hold the lock. + val it = seenEventIds.entries.iterator() + while (it.hasNext()) { + if (it.next().value <= nowSec) it.remove() else break + } + if (seenEventIds.put(event.id, expiry) != null) { + return Result.Malformed("replay: this NIP-98 token has already been used") + } + } + return Result.Verified(event.pubKey) } @@ -127,5 +156,13 @@ class Nip98AuthVerifier( companion object { const val SCHEME = "Nostr " + + /** + * Cap on the in-memory replay-cache size. With a 60s tolerance + * an attacker would need to push >MAX/120 verified requests per + * second (one new id per ~120 ms) to evict legitimate entries. + * 1024 is generous for an admin endpoint. + */ + const val MAX_REPLAY_ENTRIES = 1024 } } 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 index 4ba2bcb507..74fc540c63 100644 --- 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 @@ -62,7 +62,10 @@ data class RelayConfig( version = info.version ?: "1.08.0", supported_nips = info.supported_nips?.map(Int::toString) - ?: listOf("1", "9", "11", "40", "42", "45", "50", "62"), + // Keep in sync with `RelayInfo.default()` — + // both lists must reflect the NIPs actually + // wired into the relay. + ?: listOf("1", "9", "11", "40", "42", "45", "50", "62", "86"), privacy_policy = info.privacy_policy, terms_of_service = info.terms_of_service, relay_countries = info.relay_countries, @@ -140,9 +143,26 @@ data class RelayConfig( * that accepts JSON-RPC admin requests authenticated via NIP-98 * HTTP-Auth. Only requests signed by one of these pubkeys are * dispatched. + * + * [public_url] is the canonical URL the relay is reachable at, + * e.g. `https://relay.example.com/`. NIP-98's URL binding compares + * the signed `u` tag against this — without it, an attacker can + * spoof the `Host` header to bind their signature to any URL. + * Required when running behind TLS termination or a reverse proxy. */ data class AdminSection( val pubkeys: List = emptyList(), + val public_url: String? = null, + /** + * Path for the JSON snapshot that persists NIP-86 admin state + * (ban lists + the live NIP-11 doc) across restarts. When + * unset, admin state is in-memory only. + * + * Convention: place next to the SQLite event-store file — + * e.g. `[database].file = "/var/lib/quartz-relay/events.db"` + * pairs with `[admin].state_file = "/var/lib/quartz-relay/events.db.admin.json"`. + */ + val state_file: String? = null, ) companion object { diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/persistence/RelayStateStore.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/persistence/RelayStateStore.kt new file mode 100644 index 0000000000..bb18dd806e --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/persistence/RelayStateStore.kt @@ -0,0 +1,98 @@ +/* + * 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.persistence + +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** + * On-disk snapshot of the relay's *operator-mutable* state — the + * NIP-11 info doc (so `changerelayname/description/icon` survive a + * restart) and the NIP-86 ban / allow / kind lists. + * + * One JSON file per relay. Lives next to the SQLite event store by + * convention, but the path is configurable independently. Atomic + * write via temp + atomic rename so a crash mid-save can never leave + * the file half-written. + * + * The schema below intentionally mirrors NIP-86 list responses + * (`pubkey + reason`, `id + reason`) so a future operator-tools CLI + * can read these straight from disk without translation. + */ +class RelayStateStore( + val file: File, +) { + /** Load the snapshot from disk, or `null` if the file does not yet exist. */ + @Synchronized + fun load(): RelayPersistedState? { + if (!file.exists()) return null + return try { + json.decodeFromString(RelayPersistedState.serializer(), file.readText()) + } catch (e: Exception) { + // Corrupt file — log to stderr and refuse to overwrite. The + // operator chooses whether to fix or delete; we don't blow + // away their state silently. + System.err.println("warning: failed to read relay state file ${file.absolutePath}: ${e.message}") + null + } + } + + /** Atomically write the snapshot. */ + @Synchronized + fun save(state: RelayPersistedState) { + file.parentFile?.let { if (!it.exists()) it.mkdirs() } + val tmp = File(file.parentFile ?: file.absoluteFile.parentFile, "${file.name}.tmp") + tmp.writeText(json.encodeToString(RelayPersistedState.serializer(), state)) + Files.move( + tmp.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } + + private val json = + Json { + prettyPrint = true + encodeDefaults = false + ignoreUnknownKeys = true + } +} + +@Serializable +data class RelayPersistedState( + val info: Nip11RelayInformation? = null, + val bannedPubkeys: List = emptyList(), + val allowedPubkeys: List = emptyList(), + val bannedEvents: List = emptyList(), + val allowedKinds: List = emptyList(), + val disallowedKinds: List = emptyList(), +) + +@Serializable +data class BannedEntry( + val key: String, + val reason: String? = null, +) diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/persistence/PersistenceTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/persistence/PersistenceTest.kt new file mode 100644 index 0000000000..700c1145e6 --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/persistence/PersistenceTest.kt @@ -0,0 +1,208 @@ +/* + * 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.persistence + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.relay.Relay +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PersistenceTest { + private lateinit var dir: File + private lateinit var stateFile: File + private val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") + + @BeforeTest + fun setup() { + dir = Files.createTempDirectory("quartz-relay-persist-").toFile() + stateFile = File(dir, "admin.json") + } + + @AfterTest + fun teardown() { + dir.deleteRecursively() + } + + @Test + fun firstBootWritesNothingUntilFirstMutation() { + val relay = Relay(url = url, stateFile = stateFile) + try { + // No mutation yet → file does not exist. + assertTrue(!stateFile.exists(), "fresh relay must not eagerly write a snapshot") + } finally { + relay.close() + } + } + + @Test + fun banPubkeyTriggersSnapshotAndSurvivesRestart() { + val pk = "a".repeat(64) + val r1 = Relay(url = url, stateFile = stateFile) + try { + r1.banStore.banPubkey(pk, "spam") + } finally { + r1.close() + } + assertTrue(stateFile.exists(), "snapshot must be written after a mutation") + + // Fresh relay reads the snapshot and sees the ban. + val r2 = Relay(url = url, stateFile = stateFile) + try { + assertTrue(r2.banStore.isBanned(pk)) + assertEquals("spam", r2.banStore.listBannedPubkeys()[0].second) + } finally { + r2.close() + } + } + + @Test + fun updateInfoSurvivesRestart() { + val r1 = Relay(url = url, stateFile = stateFile) + try { + r1.updateInfo { it.copy(name = "renamed") } + } finally { + r1.close() + } + + val r2 = Relay(url = url, stateFile = stateFile) + try { + assertEquals("renamed", r2.info.document.name) + } finally { + r2.close() + } + } + + @Test + fun allowKindRoundTripsAcrossRestart() { + val r1 = Relay(url = url, stateFile = stateFile) + try { + r1.banStore.allowKind(1) + r1.banStore.allowKind(7) + r1.banStore.disallowKind(4) + } finally { + r1.close() + } + + val r2 = Relay(url = url, stateFile = stateFile) + try { + assertEquals(listOf(1, 7), r2.banStore.listAllowedKinds()) + assertEquals(listOf(4), r2.banStore.listDisallowedKinds()) + } finally { + r2.close() + } + } + + @Test + fun corruptStateFileIsTolerated() { + stateFile.writeText("not valid json {") + // Should not throw — just log and start fresh. + val r = Relay(url = url, stateFile = stateFile) + try { + assertTrue(r.banStore.listBannedPubkeys().isEmpty()) + assertTrue(r.banStore.listAllowedKinds().isEmpty()) + } finally { + r.close() + } + } + + @Test + fun snapshotWriteIsAtomicViaTempFile() { + // After a mutation completes, no `.tmp` file should remain. + val r = Relay(url = url, stateFile = stateFile) + try { + r.banStore.banPubkey("b".repeat(64)) + val tmp = File(dir, "admin.json.tmp") + assertTrue(!tmp.exists(), "tmp file must be moved into place, not left behind") + } finally { + r.close() + } + } + + @Test + fun missingStateFileMeansInMemoryOnly() { + val r = Relay(url = url) // no stateFile + try { + r.banStore.banPubkey("c".repeat(64)) + // No snapshot path → nothing on disk in our temp dir. + assertNull(dir.list()?.firstOrNull { it.startsWith("admin") }) + } finally { + r.close() + } + } + + @Test + fun stateStoreRoundTripsAllSections() { + val ss = RelayStateStore(stateFile) + val state = + RelayPersistedState( + info = Nip11RelayInformation(name = "x", description = "y"), + bannedPubkeys = listOf(BannedEntry("aa", "spam"), BannedEntry("bb", null)), + allowedPubkeys = listOf(BannedEntry("cc", "trusted")), + bannedEvents = listOf(BannedEntry("dd", "off-topic")), + allowedKinds = listOf(1, 7), + disallowedKinds = listOf(4, 1059), + ) + ss.save(state) + val loaded = ss.load()!! + assertEquals("x", loaded.info!!.name) + assertEquals(2, loaded.bannedPubkeys.size) + assertEquals(listOf(1, 7), loaded.allowedKinds) + assertEquals(listOf(4, 1059), loaded.disallowedKinds) + } + + /** + * Manual `Nip11RelayInformation.copy` — the class isn't a data + * class so Kotlin doesn't generate one. We only need `name` here. + */ + private fun Nip11RelayInformation.copy(name: String? = this.name) = + Nip11RelayInformation( + id = id, + name = name, + description = description, + icon = icon, + pubkey = pubkey, + self = self, + contact = contact, + supported_nips = supported_nips, + supported_nip_extensions = supported_nip_extensions, + software = software, + version = version, + limitation = limitation, + relay_countries = relay_countries, + language_tags = language_tags, + tags = tags, + posting_policy = posting_policy, + privacy_policy = privacy_policy, + terms_of_service = terms_of_service, + payments_url = payments_url, + retention = retention, + fees = fees, + nip50 = nip50, + supported_grasps = supported_grasps, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt index 2b2d87eb73..d53525f31e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt @@ -65,11 +65,30 @@ class LiveEventStore( // ephemeral kinds (20000-29999) where insert is a no-op — and // ephemeral events MUST still reach matching live subscribers per // NIP-01. + // + // Side effect of registering the collector first: an event + // inserted *during* `store.query` will be both replayed by the + // store AND emitted to the live stream. We dedupe by tracking + // ids seen during the historical replay and skipping them on + // the live path. The set is dropped after EOSE so live-only + // events don't accumulate memory. + var inHistoricalPhase = true + var seenIds: HashSet? = HashSet() + val historicalOnEach: (Event) -> Unit = { event -> + seenIds?.add(event.id) + onEach(event) + } newEventStream .onSubscription { - store.query(filters, onEach) + store.query(filters, historicalOnEach) onEose() + // Free the dedupe set once we've crossed EOSE: from + // here on the live stream is the only source of + // events, so duplicates aren't possible. + inHistoricalPhase = false + seenIds = null }.collect { newEvent -> + if (inHistoricalPhase && seenIds?.contains(newEvent.id) == true) return@collect if (filters.any { it.match(newEvent) }) { onEach(newEvent) } From d49d4a1025c8cfa0ae90941ae419e5ea863b6a80 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 03:41:49 +0000 Subject: [PATCH 11/17] perf(relay): load benchmark + bump per-session outbound buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in load benchmark suite (`-DrunLoadBenchmark=true`) covering: held-open WebSocket count, single + concurrent EVENT publish throughput, and live-event fan-out latency to N subscribers on one connection. Numbers from a small VM (4 GiB RAM, 4 vCPU, ulimit -n 4096): Connections (raw WS, one REQ each) 100 : 100 OK, 0.7 s 500 : 500 OK, 1.2 s 1000 : 1000 OK, 2.0 s 2000 : 1993 OK (hits the 4096-fd ceiling: 2 fds per WS) Single-publisher serial publish + OK 10000 events, 13.2 s, 760 EPS (round-trip latency bound) Concurrent publishers (each on its own WS) parallel=2 : 807 EPS parallel=4 : 1452 EPS parallel=8 : 1989 EPS ← knee parallel=16 : 1704 EPS ← SQLite single-writer ceiling parallel=32 : 1778 EPS Fanout (one publish, N active subs on a single WS) 100 subs : 67 ms last 500 subs : 52 ms last 1000 subs : 51 ms last 2000 subs : 111 ms last Bumped SESSION_OUTGOING_BUFFER from 1024 → 8192. The 1024 cap was hit by the 2000-sub fanout test (2000 outbound frames into one session's queue), causing the relay to drop the connection as a slow-consumer protection. 8192 fits the realistic upper bound for a high-fan-out client (a few thousand subs on one WS) and caps per-session memory at ~2 MiB before we drop. Numbers also confirm: - The SQLite single-writer plateau is around 2000 EPS on this box. Production behind WAL + a faster disk should beat that. - Each WebSocket consumes 2 file descriptors. Operators must raise `ulimit -n` to ~3× their target connection count. - Fan-out scales sub-linearly (2000 subs ≈ 2× the latency of 100 subs) — the bottleneck is shared (broadcast + SQLite write), not per-sub. Total :quartz-relay tests: 99 (4 new benchmarks, opt-in), 0 failures. --- quartz-relay/build.gradle.kts | 14 + .../quartz/relay/LocalRelayServer.kt | 8 +- .../quartz/relay/perf/LoadBenchmark.kt | 333 ++++++++++++++++++ 3 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/perf/LoadBenchmark.kt diff --git a/quartz-relay/build.gradle.kts b/quartz-relay/build.gradle.kts index f8dc0f0fec..8b7dd95b6d 100644 --- a/quartz-relay/build.gradle.kts +++ b/quartz-relay/build.gradle.kts @@ -27,6 +27,20 @@ sourceSets { } } +tasks.withType().configureEach { + // Forward `-DrunLoadBenchmark=true` to the test JVM so the + // perf.LoadBenchmark tests opt in. Off by default — load tests + // are noisy and slow. + systemProperty("runLoadBenchmark", System.getProperty("runLoadBenchmark") ?: "false") + // Show println output from test JVM so the benchmark numbers are + // actually visible without grepping the report XML. + testLogging { + showStandardStreams = + (System.getProperty("runLoadBenchmark") == "true") + events("standard_out") + } +} + dependencies { api(project(":quartz")) diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt index a3b582bf8b..ae6b989b8d 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt @@ -473,7 +473,13 @@ class LocalRelayServer( * this many frames behind, we close their connection rather * than silently dropping further frames (which would corrupt * NIP-01 by missing EVENT/EOSE messages). + * + * Sized to hold fan-out for a connection holding several + * thousand subscriptions when one event matches all of them + * — the realistic upper bound for a relay client. At ~250B + * per frame this caps per-session memory at ~2 MiB before + * we drop the connection. */ - const val SESSION_OUTGOING_BUFFER: Int = 1024 + const val SESSION_OUTGOING_BUFFER: Int = 8192 } } diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/perf/LoadBenchmark.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/perf/LoadBenchmark.kt new file mode 100644 index 0000000000..4b658bfd0d --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/perf/LoadBenchmark.kt @@ -0,0 +1,333 @@ +/* + * 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.perf + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.relay.LocalRelayServer +import com.vitorpamplona.quartz.relay.Relay +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import java.util.concurrent.atomic.AtomicLong +import kotlin.test.Test +import kotlin.time.measureTime + +/** + * Single-process load tests that report real numbers for: + * - WebSocket connection establishment rate + * - Concurrent subscription steady-state count + * - Live-event fanout latency to N subscribers + * - End-to-end EVENT publish throughput (EVENT → store → OK) + * + * Disabled by default — runs only when `runLoadBenchmark` system + * property is set. Add `-DrunLoadBenchmark=true` to a Gradle test + * invocation. Skipped under the normal test run because (a) numbers + * vary on busy CI runners, and (b) some scenarios spin up thousands + * of sockets which is rude on shared infra. + */ +class LoadBenchmark { + private val enabled = System.getProperty("runLoadBenchmark") == "true" + + private inline fun benchmark( + name: String, + block: () -> Unit, + ) { + if (!enabled) { + println("[skip] $name — set -DrunLoadBenchmark=true to enable") + return + } + println("--- $name ---") + block() + } + + /** + * How many concurrent WebSocket *connections* can we hold open? + * Each test client opens a raw WS, sends one REQ, expects EOSE. + * Stays connected after that. + */ + @Test + fun connectionsHeldOpen() = + benchmark("connections held open") { + for (target in listOf(100, 500, 1_000, 2_000, 5_000, 10_000)) { + runBenchmarkServer { server, http -> + val httpUrl = + okhttp3.Request + .Builder() + .url(server.url.replace("ws://", "http://")) + .build() + val sockets = java.util.concurrent.CopyOnWriteArrayList() + val opened = AtomicLong() + val gotEose = AtomicLong() + val opens = + measureTime { + repeat(target) { + val ws = + http.newWebSocket( + httpUrl, + object : okhttp3.WebSocketListener() { + override fun onOpen( + webSocket: okhttp3.WebSocket, + response: okhttp3.Response, + ) { + opened.incrementAndGet() + webSocket.send( + """["REQ","s",{"kinds":[1],"limit":1}]""", + ) + } + + override fun onMessage( + webSocket: okhttp3.WebSocket, + text: String, + ) { + if (text.startsWith("[\"EOSE\"")) { + gotEose.incrementAndGet() + } + } + }, + ) + sockets += ws + } + // Wait for either EOSE on every connection or 60s deadline. + val deadline = System.currentTimeMillis() + 60_000 + while (gotEose.get() < target && System.currentTimeMillis() < deadline) { + Thread.sleep(50) + } + } + // Let activeSessionCount settle. + Thread.sleep(200) + println( + "target=$target opened=${opened.get()} eosed=${gotEose.get()} " + + "active=${server.activeSessionCount} elapsedMs=${opens.inWholeMilliseconds}", + ) + sockets.forEach { runCatching { it.cancel() } } + if (gotEose.get() < target) { + println(" --> degradation at $target; stopping ramp-up") + return@runBenchmarkServer + } + } + } + } + + /** + * One publisher sends 10k events serially. Measures the round-trip + * `EVENT` → `OK true` time, which is dominated by SQLite write + * throughput + the write side of the policy stack. + */ + @Test + fun publishThroughputSingleClient() = + benchmark("publish throughput single client") { + runBenchmarkServer { server, http -> + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope) + try { + val signer = NostrSignerSync(KeyPair()) + val relayUrl = server.url.normalizeRelayUrl() + + val n = 10_000 + var ok = 0 + val elapsed = + measureTime { + runBlocking { + repeat(n) { i -> + val event = signer.sign(TextNoteEvent.build("hello $i")) + if (client.publishAndConfirm(event, setOf(relayUrl))) ok++ + } + } + } + val eps = (n * 1000.0) / elapsed.inWholeMilliseconds + println("events=$n ok=$ok elapsedMs=${elapsed.inWholeMilliseconds} eps=${"%.0f".format(eps)}") + } finally { + client.disconnect() + scope.cancel() + } + } + } + + /** + * One publisher, N subscribers. Publishes one EVENT and measures + * fan-out latency: time from publish to last subscriber receiving. + */ + @Test + fun fanoutLatency() = + benchmark("fanout latency") { + for (subs in listOf(100, 500, 1_000, 2_000)) { + runBenchmarkServer { server, http -> + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val subClient = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope) + val pubClient = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope) + try { + val relayUrl = server.url.normalizeRelayUrl() + val received = AtomicLong() + val firstReceiveNs = AtomicLong(-1) + val lastReceiveNs = AtomicLong(-1) + + // Set up `subs` subscribers. + val eosed = AtomicLong() + repeat(subs) { i -> + subClient.subscribe( + "fanout-$i", + mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), + object : SubscriptionListener { + override fun onEvent( + event: com.vitorpamplona.quartz.nip01Core.core.Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + val now = System.nanoTime() + firstReceiveNs.compareAndSet(-1, now) + lastReceiveNs.set(now) + received.incrementAndGet() + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eosed.incrementAndGet() + } + }, + ) + } + + runBlocking { + withTimeout(60_000) { + while (eosed.get() < subs) kotlinx.coroutines.delay(50) + } + } + println("$subs subs ready; publishing one event...") + + val signer = NostrSignerSync(KeyPair()) + val event = signer.sign(TextNoteEvent.build("fanout")) + val publishStart = System.nanoTime() + runBlocking { + pubClient.publishAndConfirm(event, setOf(relayUrl)) + } + val publishEnd = System.nanoTime() + + // Wait for fanout to complete. + runBlocking { + withTimeout(60_000) { + while (received.get() < subs) kotlinx.coroutines.delay(10) + } + } + + val firstFanoutMs = (firstReceiveNs.get() - publishStart) / 1_000_000.0 + val lastFanoutMs = (lastReceiveNs.get() - publishStart) / 1_000_000.0 + val publishMs = (publishEnd - publishStart) / 1_000_000.0 + println( + "subs=$subs publishMs=${"%.1f".format(publishMs)} " + + "fanoutFirstMs=${"%.1f".format(firstFanoutMs)} " + + "fanoutLastMs=${"%.1f".format(lastFanoutMs)} " + + "received=${received.get()}/$subs", + ) + } finally { + subClient.disconnect() + pubClient.disconnect() + scope.cancel() + } + } + } + } + + /** + * Many concurrent publishers, each on their own WebSocket. Tells + * us whether the SQLite single-writer bottleneck is the floor or + * if there's contention upstream. + */ + @Test + fun publishThroughputConcurrent() = + benchmark("publish throughput concurrent") { + for (parallel in listOf(2, 4, 8, 16, 32)) { + runBenchmarkServer { server, http -> + val total = 5_000 + val perThread = total / parallel + val ok = AtomicLong() + val elapsed = + measureTime { + val threads = + (0 until parallel).map { tid -> + Thread { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope) + try { + val signer = NostrSignerSync(KeyPair()) + val relayUrl = server.url.normalizeRelayUrl() + runBlocking { + repeat(perThread) { i -> + val ev = signer.sign(TextNoteEvent.build("hi-$tid-$i")) + if (client.publishAndConfirm(ev, setOf(relayUrl))) ok.incrementAndGet() + } + } + } finally { + client.disconnect() + scope.cancel() + } + }.also { it.start() } + } + threads.forEach { it.join() } + } + val eps = (ok.get() * 1000.0) / elapsed.inWholeMilliseconds + println( + "parallel=$parallel total=${ok.get()}/$total elapsedMs=${elapsed.inWholeMilliseconds} eps=${"%.0f".format(eps)}", + ) + } + } + } + + /** Spin up an isolated relay + http client per scenario. */ + private inline fun runBenchmarkServer(block: (LocalRelayServer, OkHttpClient) -> Unit) { + val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl() + val relay = Relay(url = placeholder) + val server = LocalRelayServer(relay, host = "127.0.0.1", port = 0).start() + val http = + OkHttpClient + .Builder() + // Don't bottleneck on the OkHttp dispatcher when we + // open thousands of WS connections from one client. + .dispatcher( + okhttp3.Dispatcher().apply { + maxRequests = 100_000 + maxRequestsPerHost = 100_000 + }, + ).build() + try { + block(server, http) + } finally { + http.dispatcher.executorService.shutdownNow() + server.stop(gracePeriodMillis = 200, timeoutMillis = 1_000) + relay.close() + } + } +} From c4401865759cc7499f31a91138bf5c4f4707f3aa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 04:02:02 +0000 Subject: [PATCH 12/17] feat(relay): NIP-77 negentropy reconciliation Server-side negentropy: NEG-OPEN snapshots the matching event set, NEG-MSG drives the round-trip via the kmp-negentropy library, and NEG-CLOSE frees per-subId state. Same access controls as REQ apply to NEG-OPEN. supported_nips bumped to advertise "77". --- .../vitorpamplona/quartz/relay/RelayInfo.kt | 5 +- .../quartz/relay/config/RelayConfig.kt | 2 +- .../quartz/relay/Nip77NegentropyTest.kt | 273 ++++++++++++++++++ .../nip01Core/relay/server/LiveEventStore.kt | 7 + .../nip01Core/relay/server/RelaySession.kt | 93 ++++++ 5 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip77NegentropyTest.kt diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt index 87884f284c..418650de98 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt @@ -50,8 +50,9 @@ data class RelayInfo( // DeletionRequestModule), NIP-11 (this doc), NIP-40 (expiration // via ExpirationModule), NIP-42 (AUTH — when policy enables), // NIP-45 (COUNT), NIP-50 (search via FTS), NIP-62 (right to vanish), - // NIP-86 (relay management API — when admin pubkeys are configured). - supported_nips = listOf("1", "9", "11", "40", "42", "45", "50", "62", "86"), + // NIP-77 (negentropy reconciliation), NIP-86 (relay management API + // — when admin pubkeys are configured). + supported_nips = listOf("1", "9", "11", "40", "42", "45", "50", "62", "77", "86"), ), ) 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 index 74fc540c63..9762c667ae 100644 --- 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 @@ -65,7 +65,7 @@ data class RelayConfig( // Keep in sync with `RelayInfo.default()` — // both lists must reflect the NIPs actually // wired into the relay. - ?: listOf("1", "9", "11", "40", "42", "45", "50", "62", "86"), + ?: listOf("1", "9", "11", "40", "42", "45", "50", "62", "77", "86"), privacy_policy = info.privacy_policy, terms_of_service = info.terms_of_service, relay_countries = info.relay_countries, diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip77NegentropyTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip77NegentropyTest.kt new file mode 100644 index 0000000000..a66a300001 --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip77NegentropyTest.kt @@ -0,0 +1,273 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage +import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage +import com.vitorpamplona.quartz.nip77Negentropy.NegentropySession +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * End-to-end NIP-77 reconciliation through `RelayHub` + the + * in-process WebSocket bridge. + * + * - The relay is preloaded with a known set of events. + * - A [NegentropySession] is initialised with a partially-overlapping + * set on the client side. + * - We drive the NEG-OPEN / NEG-MSG round trips manually until + * `processMessage` reports completion. + * - We assert that `haveIds` (events the client has that the relay + * doesn't) and `needIds` (events the relay has that the client + * doesn't) cover exactly the symmetric difference. + * + * NEG-CLOSE is exercised separately — sending NEG-MSG after CLOSE + * must surface a NEG-ERR from the relay. + */ +class Nip77NegentropyTest { + private lateinit var hub: RelayHub + private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") + + @BeforeTest + fun setup() { + hub = RelayHub() + } + + @AfterTest + fun teardown() { + hub.close() + } + + /** + * Bare-bones WS client that captures every server message into a + * channel and exposes a `send` that goes straight at the in-process + * bridge. We don't need NostrClient's filter-management for these + * tests — we drive the wire. + */ + private class WireClient( + hub: RelayHub, + url: NormalizedRelayUrl, + ) { + val incoming: Channel = Channel(UNLIMITED) + private val ws = + hub.build( + url, + object : WebSocketListener { + override fun onOpen( + pingMillis: Int, + compression: Boolean, + ) {} + + override fun onMessage(text: String) { + incoming.trySend(text) + } + + override fun onClosed( + code: Int, + reason: String, + ) { + incoming.close() + } + + override fun onFailure( + t: Throwable, + code: Int?, + response: String?, + ) { + incoming.close(t) + } + }, + ) + + init { + ws.connect() + } + + fun send(json: String) { + check(ws.send(json)) { "send returned false" } + } + + fun close() { + ws.disconnect() + } + } + + private suspend fun WireClient.nextMessage(timeoutMs: Long = 5_000): Message { + val raw = withTimeout(timeoutMs) { incoming.receive() } + return OptimizedJsonMapper.fromJsonToMessage(raw) + } + + /** Generates [count] signed text notes with monotonic createdAt. */ + private fun makeEvents(count: Int): List { + val signer = NostrSignerSync(KeyPair()) + val now = 1_700_000_000L + return List(count) { i -> + signer.sign(TextNoteEvent.build("event-$i", createdAt = now + i)) + } + } + + @Test + fun negentropyComputesSymmetricDifference() = + runBlocking { + // Universe of 10 events. Relay has events [0..7], client has [3..9] — + // overlap [3..7], relay-only [0..2], client-only [8..9]. + val all = makeEvents(10) + val relayEvents = all.subList(0, 8) + val clientEvents = all.subList(3, 10) + + hub.getOrCreate(relayUrl).preload(relayEvents) + + val client = WireClient(hub, relayUrl) + try { + val session = + NegentropySession( + subId = "neg-1", + filter = Filter(kinds = listOf(1)), + localEvents = clientEvents, + ) + + // Step 1: send NEG-OPEN. + val openCmd = session.open() + client.send(OptimizedJsonMapper.toJson(openCmd)) + + // Step 2: drive NEG-MSG round trips until reconciliation completes. + val haveIds = mutableSetOf() + val needIds = mutableSetOf() + var safety = 32 + while (safety-- > 0) { + val response = client.nextMessage() + if (response is NegErrMessage) { + kotlin.test.fail("relay sent NEG-ERR: ${response.reason}") + } + response as NegMsgMessage + val result = session.processMessage(response.message) + haveIds += result.haveIds + needIds += result.needIds + if (result.isComplete()) break + client.send(OptimizedJsonMapper.toJson(result.nextCmd!!)) + } + assertTrue(safety > 0, "reconciliation did not converge in 32 rounds") + + // Step 3: verify the symmetric difference. + val expectedNeed = relayEvents.subList(0, 3).map { it.id }.toSet() // [0..2] + val expectedHave = clientEvents.subList(5, 7).map { it.id }.toSet() // [8..9] + assertEquals(expectedNeed, needIds, "client should NEED events 0..2 from relay") + assertEquals(expectedHave, haveIds, "client should HAVE events 8..9 to send to relay") + } finally { + client.close() + } + } + + @Test + fun negCloseFreesServerStateAndReopenWorks() = + runBlocking { + val all = makeEvents(5) + hub.getOrCreate(relayUrl).preload(all) + + val client = WireClient(hub, relayUrl) + try { + // First session. + val s1 = NegentropySession("neg", Filter(kinds = listOf(1)), localEvents = emptyList()) + client.send(OptimizedJsonMapper.toJson(s1.open())) + val response = client.nextMessage() as NegMsgMessage + val r1 = s1.processMessage(response.message) + // Client had nothing, so it needs all 5 from the relay. + assertEquals(5, r1.needIds.size) + + // Close. + client.send(OptimizedJsonMapper.toJson(s1.close())) + + // Re-OPEN with the same subId and an empty client store — + // server must build a new session and respond. If the + // close didn't free state, this would either error or + // continue the previous reconciliation. + val s2 = NegentropySession("neg", Filter(kinds = listOf(1)), localEvents = emptyList()) + client.send(OptimizedJsonMapper.toJson(s2.open())) + val resp2 = client.nextMessage() as NegMsgMessage + val r2 = s2.processMessage(resp2.message) + assertEquals(5, r2.needIds.size) + } finally { + client.close() + } + } + + @Test + fun negMsgWithoutOpenReturnsNegErr() = + runBlocking { + val client = WireClient(hub, relayUrl) + try { + // Synthesise a stray NEG-MSG for a sub-id that was never opened. + val raw = """["NEG-MSG","ghost-sub","00"]""" + client.send(raw) + val response = client.nextMessage() + assertTrue(response is NegErrMessage, "expected NEG-ERR, got ${response::class.simpleName}") + assertEquals("ghost-sub", response.subId) + assertTrue(response.reason.contains("no negentropy session")) + } finally { + client.close() + } + } + + @Test + fun negOpenWithSameSubIdReplacesPriorSession() = + runBlocking { + val a = makeEvents(3) + val b = makeEvents(2) + hub.getOrCreate(relayUrl).preload(a + b) + + val client = WireClient(hub, relayUrl) + try { + // First open with localEvents = a; next we'll re-open + // and confirm the new session sees a fresh state. + val first = NegentropySession("dup", Filter(kinds = listOf(1)), localEvents = a) + client.send(OptimizedJsonMapper.toJson(first.open())) + client.nextMessage() as NegMsgMessage // discard + + // Re-OPEN with same subId, different localEvents. + val second = NegentropySession("dup", Filter(kinds = listOf(1)), localEvents = a + b) + client.send(OptimizedJsonMapper.toJson(second.open())) + val resp = client.nextMessage() as NegMsgMessage + val r = second.processMessage(resp.message) + // Client now has every event the relay has → nothing to need. + assertEquals(0, r.needIds.size) + assertEquals(0, r.haveIds.size) + } finally { + client.close() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt index d53525f31e..43543b1e54 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt @@ -96,4 +96,11 @@ class LiveEventStore( } suspend fun count(filters: List) = store.count(filters) + + /** + * One-shot snapshot query. Used by NIP-77 negentropy: the server + * needs the full set of event ids matching the filter at the + * moment the NEG-OPEN arrives, not a streamed/live result. + */ + suspend fun snapshotQuery(filter: Filter): List = store.query(filter) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index dd43f03d48..9baf769a95 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -34,6 +34,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd +import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage +import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd +import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd +import com.vitorpamplona.quartz.nip77Negentropy.NegentropyServerSession import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CoroutineScope @@ -53,6 +58,14 @@ class RelaySession( ) : AutoCloseable { private val subscriptions = LargeCache() + /** + * NIP-77 negentropy reconciliation sessions, keyed by NEG-OPEN + * subId. Plain hash map here (not [LargeCache]) because it's + * mutated only from the single-threaded `receive()` path — + * RelaySession.receive is serialised by the WebSocket handler. + */ + private val negSessions = HashMap() + private fun addSubscription( subId: String, job: Job, @@ -67,6 +80,7 @@ class RelaySession( fun cancelAllSubscriptions() { subscriptions.forEach { _, job -> job.cancel() } subscriptions.clear() + negSessions.clear() } fun send(message: Message) { @@ -107,6 +121,9 @@ class RelaySession( is ReqCmd -> handleReq(cmd) is CloseCmd -> handleClose(cmd) is CountCmd -> handleCount(cmd) + is NegOpenCmd -> handleNegOpen(cmd) + is NegMsgCmd -> handleNegMsg(cmd) + is NegCloseCmd -> handleNegClose(cmd) else -> send(NoticeMessage("error: unsupported command ${cmd.label()}")) } } @@ -194,6 +211,82 @@ class RelaySession( } } + // -- NIP-77: NEG-OPEN ----------------------------------------------------- + + /** + * Open a negentropy reconciliation session. The relay snapshots its + * matching events at this instant — concurrent inserts during the + * sync are not surfaced; clients re-open if they want fresh state. + * + * Access control reuses the REQ policy hook: a relay that requires + * AUTH or has kind/pubkey allow-deny lists applies the same rules + * to NEG-OPEN as it does to subscription REQs. + */ + private suspend fun handleNegOpen(cmd: NegOpenCmd) { + // Run the same access controls as REQ would. + val asReq = ReqCmd(cmd.subId, listOf(cmd.filter)) + val gate = policy.accept(asReq) + if (gate is PolicyResult.Rejected) { + send(NegErrMessage(cmd.subId, gate.reason)) + return + } + val filters = (gate as PolicyResult.Accepted).cmd.filters + + // Drop any prior session at this subId (NIP-77: same-subId + // OPEN replaces). + negSessions.remove(cmd.subId) + + val events = + if (filters.size == 1) { + store.snapshotQuery(filters[0]) + } else { + // Multiple filters: union the snapshots and dedupe by id. + val seen = HashSet() + val merged = mutableListOf() + for (f in filters) { + for (e in store.snapshotQuery(f)) { + if (seen.add(e.id)) merged += e + } + } + merged + } + + val neg = NegentropyServerSession(cmd.subId, events) + negSessions[cmd.subId] = neg + + try { + val response = neg.processMessage(cmd.initialMessage) + if (response != null) send(response) + } catch (e: Exception) { + negSessions.remove(cmd.subId) + send(NegErrMessage(cmd.subId, "error: ${e.message ?: e::class.simpleName}")) + } + } + + // -- NIP-77: NEG-MSG ------------------------------------------------------ + private fun handleNegMsg(cmd: NegMsgCmd) { + val neg = negSessions[cmd.subId] + if (neg == null) { + send(NegErrMessage(cmd.subId, "error: no negentropy session for ${cmd.subId}")) + return + } + try { + val response = neg.processMessage(cmd.message) + if (response != null) send(response) + } catch (e: Exception) { + negSessions.remove(cmd.subId) + send(NegErrMessage(cmd.subId, "error: ${e.message ?: e::class.simpleName}")) + } + } + + // -- NIP-77: NEG-CLOSE ---------------------------------------------------- + private fun handleNegClose(cmd: NegCloseCmd) { + // Spec: clients send NEG-CLOSE to free server-side state. + // Silent no-op if the session is unknown — there's no authoritative + // error response in NIP-77 for an unknown close. + negSessions.remove(cmd.subId) + } + init { policy.onConnect(::send) } From e78561af675529ebfc35204a5179f8f5d69c88d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 12:31:27 +0000 Subject: [PATCH 13/17] refactor(relay): split overgrown files + reduce duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit follow-ups, no behavior change. - Centralize NIPs/name/version constants in RelayInfo so RelayConfig and the default doc share one source of truth. - Extract NegSessionRegistry (NIP-77 state + open/msg/close) out of RelaySession; the connection class now routes commands. - Move multi-filter snapshot union/dedupe onto LiveEventStore. - Pull Nip86HttpRoute and WebSocketSessionPump out of LocalRelayServer (was 485 lines, three responsibilities). - Collapse Nip86Server.dispatch repetition with withHex/withHexAndReason/ withInt/withString helpers; reuse Hex.isHex64 instead of a local regex. - Make Nip11RelayInformation (and nested types) data classes so Nip86Server uses the synthesized copy() directly — drops the hand-rolled field-by-field shim. --- .../quartz/relay/LocalRelayServer.kt | 251 ++---------------- .../vitorpamplona/quartz/relay/RelayInfo.kt | 43 ++- .../quartz/relay/admin/Nip86Server.kt | 166 +++++------- .../quartz/relay/config/RelayConfig.kt | 15 +- .../quartz/relay/server/Nip86HttpRoute.kt | 181 +++++++++++++ .../relay/server/WebSocketSessionPump.kt | 114 ++++++++ .../nip01Core/relay/server/LiveEventStore.kt | 18 ++ .../relay/server/NegSessionRegistry.kt | 115 ++++++++ .../nip01Core/relay/server/RelaySession.kt | 98 +------ .../nip11RelayInfo/Nip11RelayInformation.kt | 10 +- 10 files changed, 565 insertions(+), 446 deletions(-) create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/Nip86HttpRoute.kt create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/WebSocketSessionPump.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NegSessionRegistry.kt diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt index ae6b989b8d..3c54b4c278 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt @@ -21,13 +21,12 @@ package com.vitorpamplona.quartz.relay import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession -import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request -import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response import com.vitorpamplona.quartz.relay.admin.Nip86Server import com.vitorpamplona.quartz.relay.admin.Nip98AuthVerifier +import com.vitorpamplona.quartz.relay.server.Nip86HttpRoute +import com.vitorpamplona.quartz.relay.server.WebSocketSessionPump import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode @@ -36,18 +35,12 @@ import io.ktor.server.cio.CIO import io.ktor.server.cio.CIOApplicationEngine import io.ktor.server.engine.embeddedServer import io.ktor.server.request.header -import io.ktor.server.request.receiveChannel import io.ktor.server.response.respondText import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.routing import io.ktor.server.websocket.WebSockets import io.ktor.server.websocket.webSocket -import io.ktor.utils.io.readAvailable -import io.ktor.websocket.Frame -import io.ktor.websocket.readText -import kotlinx.coroutines.channels.consumeEach -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.concurrent.ConcurrentHashMap @@ -114,10 +107,6 @@ class LocalRelayServer( */ val maxAdminBodyBytes: Int = 1 shl 20, ) { - /** - * Bridges the relay's mutable [RelayInfo] to [Nip86Server.InfoHolder] - * so admin RPCs can rewrite the NIP-11 doc atomically. - */ private val infoHolder = object : Nip86Server.InfoHolder { override fun get() = relay.info @@ -127,10 +116,18 @@ class LocalRelayServer( } } - private val nip86 = Nip86Server(banStore = relay.banStore, infoHolder = infoHolder, store = relay.store) - private val nip98 = Nip98AuthVerifier() + private val nip86Server = Nip86Server(banStore = relay.banStore, infoHolder = infoHolder, store = relay.store) + private val nip86Route = + Nip86HttpRoute( + server = nip86Server, + verifier = Nip98AuthVerifier(), + allowList = adminPubkeys.mapTo(HashSet()) { it.lowercase() }, + maxBodyBytes = maxAdminBodyBytes, + signedUrlFor = { call -> + publicUrl ?: ("http://" + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path) + }, + ) - private val adminAllowList: Set = adminPubkeys.mapTo(HashSet()) { it.lowercase() } private var engine: CIOApplicationEngine? = null private var resolvedPort: Int = -1 @@ -197,67 +194,18 @@ class LocalRelayServer( // NIP-86: POST application/nostr+json+rpc with a NIP-98 // signed Authorization header → JSON-RPC dispatch. post(path) { - handleNip86(call) + nip86Route.handle(call) } webSocket(path) { if (shuttingDown) { // Just return — Ktor closes the WS for us. - // We can't `close(reason)` here because the - // CIO engine's outgoing channel may already - // be torn down during shutdown. return@webSocket } - // Per-session outbound queue. The relay's - // `connect` callback runs on whatever thread - // produced the message — it can't suspend, so - // we hand off to a dedicated writer coroutine - // that does suspend on `outgoing.send` and thus - // applies real backpressure on slow clients. - // When the queue fills, that's a slow consumer - // — drop the connection cleanly so subscribers - // don't silently miss EVENT/EOSE. - val outQueue = - kotlinx.coroutines.channels - .Channel(capacity = SESSION_OUTGOING_BUFFER) - val writerJob = - launch { - try { - for (json in outQueue) { - outgoing.send(Frame.Text(json)) - } - } catch (_: kotlinx.coroutines.channels.ClosedSendChannelException) { - // socket closed — let the handler's - // finally block run normal teardown - } - } - var droppedForBackpressure = false - val session = - relay.server.connect { json -> - val res = outQueue.trySend(json) - if (!res.isSuccess && !res.isClosed) { - // Buffer is full → slow client. - // Mark + close the queue; the - // writer drains, then we let the - // outer handler's finally close - // the WS session. - droppedForBackpressure = true - outQueue.close() - } - } - activeSessions.add(session) - try { - incoming.consumeEach { frame -> - if (droppedForBackpressure) return@consumeEach - if (frame is Frame.Text) { - session.receive(frame.readText()) - } - } - } finally { - outQueue.close() - writerJob.cancel() - activeSessions.remove(session) - session.close() - } + WebSocketSessionPump(this).pump( + server = relay.server, + registerSession = activeSessions::add, + unregisterSession = activeSessions::remove, + ) } } } @@ -309,151 +257,6 @@ class LocalRelayServer( resolvedPort = -1 } - /** - * Handles a NIP-86 admin RPC request: - * 1. 403 if no admin pubkey list is configured (endpoint disabled). - * 2. 401 if the NIP-98 Authorization header is missing/invalid. - * 3. 403 if the verified pubkey isn't in [adminAllowList]. - * 4. 400 if the body isn't a valid Nip86Request. - * 5. 200 with a Nip86Response JSON body otherwise. - * - * `application/nostr+json+rpc` is the wire content type prescribed - * by NIP-86; we send it on responses and accept any body on the - * request (the auth event's payload-hash already binds the body). - */ - private suspend fun handleNip86(call: io.ktor.server.application.ApplicationCall) { - if (adminAllowList.isEmpty()) { - call.respondText( - "NIP-86 management API is not enabled on this relay.", - ContentType.Text.Plain, - HttpStatusCode.Forbidden, - ) - return - } - - // Cap the body BEFORE we read it. We have to read the bytes - // (NIP-98 payload-hash binds them), but unauthenticated - // attackers shouldn't be able to stream gigabytes here. - val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull() - if (declared != null && declared > maxAdminBodyBytes) { - call.respondText( - "request body exceeds $maxAdminBodyBytes-byte cap", - ContentType.Text.Plain, - HttpStatusCode.PayloadTooLarge, - ) - return - } - val body = readBoundedBody(call, maxAdminBodyBytes) ?: return - - val authHeader = call.request.header(HttpHeaders.Authorization) - // The URL the client signed must match the relay's CANONICAL - // public URL — not whatever `Host` header reaches us. An - // attacker can spoof `Host`, and behind TLS termination the - // verifier would compare against the wrong scheme. Operators - // configure [publicUrl] explicitly. The Host fallback is for - // local loopback unit tests only and is documented as unsafe. - val signedUrl = - publicUrl - ?: ("http://" + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path) - val verification = nip98.verify(authHeader, method = "POST", url = signedUrl, body = body) - - val pubkey = - when (verification) { - is Nip98AuthVerifier.Result.Verified -> { - verification.pubkey - } - - Nip98AuthVerifier.Result.Missing -> { - call.response.headers.append(HttpHeaders.WWWAuthenticate, Nip98AuthVerifier.SCHEME.trim()) - call.respondText( - "missing Authorization header (NIP-98)", - ContentType.Text.Plain, - HttpStatusCode.Unauthorized, - ) - return - } - - is Nip98AuthVerifier.Result.Malformed -> { - call.respondText( - "invalid NIP-98 Authorization: ${verification.reason}", - ContentType.Text.Plain, - HttpStatusCode.Unauthorized, - ) - return - } - } - - if (pubkey.lowercase() !in adminAllowList) { - call.respondText( - "pubkey is not on the admin list", - ContentType.Text.Plain, - HttpStatusCode.Forbidden, - ) - return - } - - val req = - try { - JsonMapper.fromJson(body.decodeToString()) - } catch (e: Exception) { - call.respondText( - "invalid Nip86Request: ${e.message ?: e::class.simpleName}", - ContentType.Text.Plain, - HttpStatusCode.BadRequest, - ) - return - } - - val response: Nip86Response = nip86.dispatch(req) - - // Audit log: structured single line so an operator can grep - // "nip86" / pubkey / method without a logging framework - // dependency. Keep it best-effort — System.err is already what - // the rest of Main.kt uses, and a missing log line shouldn't - // fail the response. - runCatching { - System.err.println( - "nip86 audit pubkey=$pubkey method=${req.method} ok=${response.error == null}" + - (response.error?.let { " error=$it" } ?: ""), - ) - } - - call.respondText( - JsonMapper.toJson(response), - ContentType.parse("application/nostr+json+rpc"), - HttpStatusCode.OK, - ) - } - - /** - * Reads up to [maxBytes] bytes from the request body and returns - * them. If the stream produces more than [maxBytes] (i.e. a - * lying or absent `Content-Length`), responds 413 and returns - * `null` — caller stops handling. - */ - private suspend fun readBoundedBody( - call: io.ktor.server.application.ApplicationCall, - maxBytes: Int, - ): ByteArray? { - val ch = call.receiveChannel() - val buf = ByteArray(maxBytes + 1) - var pos = 0 - while (pos <= maxBytes) { - val read = ch.readAvailable(buf, pos, buf.size - pos) - if (read <= 0) break - pos += read - } - if (pos > maxBytes) { - call.respondText( - "request body exceeds $maxBytes-byte cap", - ContentType.Text.Plain, - HttpStatusCode.PayloadTooLarge, - ) - return null - } - return buf.copyOfRange(0, pos) - } - /** * Best-effort NOTICE to every active client. Failures are * swallowed — a flaky socket on its way out is exactly the case @@ -466,20 +269,4 @@ class LocalRelayServer( runCatching { session.send(notice) } } } - - companion object { - /** - * Per-session outbound buffer size. When a slow client falls - * this many frames behind, we close their connection rather - * than silently dropping further frames (which would corrupt - * NIP-01 by missing EVENT/EOSE messages). - * - * Sized to hold fan-out for a connection holding several - * thousand subscriptions when one event matches all of them - * — the realistic upper bound for a relay client. At ~250B - * per frame this caps per-session memory at ~2 MiB before - * we drop the connection. - */ - const val SESSION_OUTGOING_BUFFER: Int = 8192 - } } diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt index 418650de98..34943a7b13 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt @@ -38,21 +38,42 @@ data class RelayInfo( val json: String by lazy { JsonMapper.toJson(document) } companion object { + const val NAME = "quartz-relay" + const val DESCRIPTION = "Embedded Nostr relay from the Amethyst quartz library." + const val SOFTWARE = "https://github.com/vitorpamplona/amethyst/tree/main/quartz-relay" + const val VERSION = "1.08.0" + + /** + * NIPs this relay implements out of the box. Single source of + * truth — both [default] and [com.vitorpamplona.quartz.relay.config.RelayConfig.resolveInfo] + * consult this list. Add a NIP here when its handler is wired + * into [com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession] + * (or in this module's policy stack). + * + * Currently: + * - 1 NIP-01 basic + * - 9 NIP-09 deletion (DeletionRequestModule) + * - 11 NIP-11 this doc + * - 40 NIP-40 expiration (ExpirationModule) + * - 42 NIP-42 AUTH (when policy enables) + * - 45 NIP-45 COUNT + * - 50 NIP-50 search (SQLite FTS) + * - 62 NIP-62 right to vanish + * - 77 NIP-77 negentropy reconciliation + * - 86 NIP-86 relay management API (when admin pubkeys configured) + */ + val SUPPORTED_NIPS: List = + listOf("1", "9", "11", "40", "42", "45", "50", "62", "77", "86") + /** Pre-built default for `Relay(url = ...)` — advertises the supported NIPs. */ fun default(url: NormalizedRelayUrl): RelayInfo = RelayInfo( Nip11RelayInformation( - name = "quartz-relay", - description = "Embedded Nostr relay from the Amethyst quartz library.", - software = "https://github.com/vitorpamplona/amethyst/tree/main/quartz-relay", - version = "1.08.0", - // Currently implemented: NIP-01 (basic), NIP-09 (deletion via - // DeletionRequestModule), NIP-11 (this doc), NIP-40 (expiration - // via ExpirationModule), NIP-42 (AUTH — when policy enables), - // NIP-45 (COUNT), NIP-50 (search via FTS), NIP-62 (right to vanish), - // NIP-77 (negentropy reconciliation), NIP-86 (relay management API - // — when admin pubkeys are configured). - supported_nips = listOf("1", "9", "11", "40", "42", "45", "50", "62", "77", "86"), + name = NAME, + description = DESCRIPTION, + software = SOFTWARE, + version = VERSION, + supported_nips = SUPPORTED_NIPS, ), ) diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt index 8324f338ea..7d03babef8 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt @@ -30,6 +30,8 @@ import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response import com.vitorpamplona.quartz.relay.RelayInfo +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.coroutines.CancellationException import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json @@ -101,112 +103,71 @@ class Nip86Server( runCatching { when (req.method) { Nip86Method.SUPPORTED_METHODS -> { - result(buildJsonArray { supportedMethods.forEach { add(JsonPrimitive(it)) } }) + ok(buildJsonArray { supportedMethods.forEach { add(JsonPrimitive(it)) } }) } Nip86Method.BAN_PUBKEY -> { - val (pk, reason) = req.params.stringPair() ?: return malformed("expected [pubkey, reason?]") - if (!isHex64(pk)) return malformed("pubkey must be 64-char hex") - banStore.banPubkey(pk, reason) - result(JsonPrimitive(true)) + withHexAndReason(req, "pubkey") { pk, reason -> banStore.banPubkey(pk, reason) } } Nip86Method.UNBAN_PUBKEY -> { - val (pk, _) = req.params.stringPair() ?: return malformed("expected [pubkey]") - if (!isHex64(pk)) return malformed("pubkey must be 64-char hex") - banStore.unbanPubkey(pk) - result(JsonPrimitive(true)) + withHex(req, "pubkey") { pk -> banStore.unbanPubkey(pk) } } Nip86Method.LIST_BANNED_PUBKEYS -> { - result( - banStore - .listBannedPubkeys() - .map { (pk, r) -> BannedPubkey(pk, r) } - .toJsonArray(BannedPubkey.serializer()), - ) + ok(banStore.listBannedPubkeys().map { (pk, r) -> BannedPubkey(pk, r) }.toJsonArray(BannedPubkey.serializer())) } Nip86Method.ALLOW_PUBKEY -> { - val (pk, reason) = req.params.stringPair() ?: return malformed("expected [pubkey, reason?]") - if (!isHex64(pk)) return malformed("pubkey must be 64-char hex") - banStore.allowPubkey(pk, reason) - result(JsonPrimitive(true)) + withHexAndReason(req, "pubkey") { pk, reason -> banStore.allowPubkey(pk, reason) } } Nip86Method.UNALLOW_PUBKEY -> { - val (pk, _) = req.params.stringPair() ?: return malformed("expected [pubkey]") - if (!isHex64(pk)) return malformed("pubkey must be 64-char hex") - banStore.unallowPubkey(pk) - result(JsonPrimitive(true)) + withHex(req, "pubkey") { pk -> banStore.unallowPubkey(pk) } } Nip86Method.LIST_ALLOWED_PUBKEYS -> { - result( - banStore - .listAllowedPubkeys() - .map { (pk, r) -> AllowedPubkey(pk, r) } - .toJsonArray(AllowedPubkey.serializer()), - ) + ok(banStore.listAllowedPubkeys().map { (pk, r) -> AllowedPubkey(pk, r) }.toJsonArray(AllowedPubkey.serializer())) } Nip86Method.BAN_EVENT -> { - val (id, reason) = req.params.stringPair() ?: return malformed("expected [event_id, reason?]") - if (!isHex64(id)) return malformed("event_id must be 64-char hex") - banStore.banEvent(id, reason) - // Also remove the event from the store if it's there. - store?.delete(Filter(ids = listOf(id))) - result(JsonPrimitive(true)) + withHexAndReason(req, "event_id") { id, reason -> + banStore.banEvent(id, reason) + // Also remove the event from the store if present. + store?.delete(Filter(ids = listOf(id))) + } } Nip86Method.ALLOW_EVENT -> { - val (id, _) = req.params.stringPair() ?: return malformed("expected [event_id]") - if (!isHex64(id)) return malformed("event_id must be 64-char hex") - banStore.allowEvent(id) - result(JsonPrimitive(true)) + withHex(req, "event_id") { id -> banStore.allowEvent(id) } } Nip86Method.LIST_BANNED_EVENTS -> { - result( - banStore - .listBannedEvents() - .map { (id, r) -> BannedEvent(id, r) } - .toJsonArray(BannedEvent.serializer()), - ) + ok(banStore.listBannedEvents().map { (id, r) -> BannedEvent(id, r) }.toJsonArray(BannedEvent.serializer())) } Nip86Method.ALLOW_KIND -> { - val k = req.params.firstInt() ?: return malformed("expected [kind]") - banStore.allowKind(k) - result(JsonPrimitive(true)) + withInt(req, "kind") { k -> banStore.allowKind(k) } } Nip86Method.DISALLOW_KIND -> { - val k = req.params.firstInt() ?: return malformed("expected [kind]") - banStore.disallowKind(k) - result(JsonPrimitive(true)) + withInt(req, "kind") { k -> banStore.disallowKind(k) } } Nip86Method.LIST_ALLOWED_KINDS -> { - result(buildJsonArray { banStore.listAllowedKinds().forEach { add(JsonPrimitive(it)) } }) + ok(buildJsonArray { banStore.listAllowedKinds().forEach { add(JsonPrimitive(it)) } }) } Nip86Method.CHANGE_RELAY_NAME -> { - val name = req.params.firstString() ?: return malformed("expected [name]") - rewriteInfo { it.copy(name = name) } - result(JsonPrimitive(true)) + withString(req, "name") { name -> rewriteInfo { it.copy(name = name) } } } Nip86Method.CHANGE_RELAY_DESCRIPTION -> { - val desc = req.params.firstString() ?: return malformed("expected [description]") - rewriteInfo { it.copy(description = desc) } - result(JsonPrimitive(true)) + withString(req, "description") { desc -> rewriteInfo { it.copy(description = desc) } } } Nip86Method.CHANGE_RELAY_ICON -> { - val icon = req.params.firstString() ?: return malformed("expected [icon_url]") - rewriteInfo { it.copy(icon = icon) } - result(JsonPrimitive(true)) + withString(req, "icon_url") { icon -> rewriteInfo { it.copy(icon = icon) } } } else -> { @@ -217,50 +178,63 @@ class Nip86Server( // CancellationException must propagate so structured // concurrency works — swallowing it would let a parent // cancellation be reported as a benign RPC error. - if (e is kotlinx.coroutines.CancellationException) throw e + if (e is CancellationException) throw e Nip86Response(error = "internal: ${e.message ?: e::class.simpleName}") } + private inline fun withHex( + req: Nip86Request, + label: String, + action: (String) -> Unit, + ): Nip86Response { + val (value, _) = req.params.stringPair() ?: return malformed("expected [$label]") + if (!Hex.isHex64(value)) return malformed("$label must be 64-char hex") + action(value) + return okTrue + } + + private suspend inline fun withHexAndReason( + req: Nip86Request, + label: String, + action: suspend (String, String?) -> Unit, + ): Nip86Response { + val (value, reason) = req.params.stringPair() ?: return malformed("expected [$label, reason?]") + if (!Hex.isHex64(value)) return malformed("$label must be 64-char hex") + action(value, reason) + return okTrue + } + + private inline fun withInt( + req: Nip86Request, + label: String, + action: (Int) -> Unit, + ): Nip86Response { + val v = req.params.firstInt() ?: return malformed("expected [$label]") + action(v) + return okTrue + } + + private inline fun withString( + req: Nip86Request, + label: String, + action: (String) -> Unit, + ): Nip86Response { + val v = req.params.firstString() ?: return malformed("expected [$label]") + action(v) + return okTrue + } + private fun rewriteInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) { val current = infoHolder.get().document infoHolder.set(RelayInfo(transform(current))) } - - /** [Nip11RelayInformation] is not a `data class`; do a manual field-by-field copy. */ - private fun Nip11RelayInformation.copy( - name: String? = this.name, - description: String? = this.description, - icon: String? = this.icon, - ) = Nip11RelayInformation( - id = this.id, - name = name, - description = description, - icon = icon, - pubkey = this.pubkey, - self = this.self, - contact = this.contact, - supported_nips = this.supported_nips, - supported_nip_extensions = this.supported_nip_extensions, - software = this.software, - version = this.version, - limitation = this.limitation, - relay_countries = this.relay_countries, - language_tags = this.language_tags, - tags = this.tags, - posting_policy = this.posting_policy, - privacy_policy = this.privacy_policy, - terms_of_service = this.terms_of_service, - payments_url = this.payments_url, - retention = this.retention, - fees = this.fees, - nip50 = this.nip50, - supported_grasps = this.supported_grasps, - ) } private fun malformed(reason: String) = Nip86Response(error = "invalid params: $reason") -private fun result(j: JsonElement) = Nip86Response(result = j, error = null) +private fun ok(j: JsonElement) = Nip86Response(result = j, error = null) + +private val okTrue = ok(JsonPrimitive(true)) private val rpcJson = Json { encodeDefaults = false } @@ -280,7 +254,3 @@ private fun JsonArray.firstInt(): Int? = }.getOrNull() private fun JsonPrimitive.contentOrNull(): String? = if (this == JsonNull) null else content - -private val HEX64 = Regex("[0-9a-fA-F]{64}") - -private fun isHex64(s: String): Boolean = HEX64.matches(s) 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 index 9762c667ae..e1f6d83b39 100644 --- 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 @@ -51,21 +51,16 @@ data class RelayConfig( fun resolveInfo(advertisedUrl: NormalizedRelayUrl): RelayInfo = RelayInfo( Nip11RelayInformation( - name = info.name ?: "quartz-relay", - description = info.description ?: "Embedded Nostr relay from the Amethyst quartz library.", + name = info.name ?: RelayInfo.NAME, + description = info.description ?: RelayInfo.DESCRIPTION, 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", + software = info.software ?: RelayInfo.SOFTWARE, + version = info.version ?: RelayInfo.VERSION, supported_nips = info.supported_nips?.map(Int::toString) - // Keep in sync with `RelayInfo.default()` — - // both lists must reflect the NIPs actually - // wired into the relay. - ?: listOf("1", "9", "11", "40", "42", "45", "50", "62", "77", "86"), + ?: RelayInfo.SUPPORTED_NIPS, privacy_policy = info.privacy_policy, terms_of_service = info.terms_of_service, relay_countries = info.relay_countries, diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/Nip86HttpRoute.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/Nip86HttpRoute.kt new file mode 100644 index 0000000000..e73d1653e2 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/Nip86HttpRoute.kt @@ -0,0 +1,181 @@ +/* + * 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.server + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response +import com.vitorpamplona.quartz.relay.admin.Nip86Server +import com.vitorpamplona.quartz.relay.admin.Nip98AuthVerifier +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.header +import io.ktor.server.request.receiveChannel +import io.ktor.server.response.respondText +import io.ktor.utils.io.readAvailable + +/** + * NIP-86 admin POST handler. Owns the gating order: + * 1. 403 if no admin pubkey list is configured (endpoint disabled). + * 2. 413 if body exceeds [maxBodyBytes] (declared or actual). + * 3. 401 if the NIP-98 Authorization header is missing/invalid. + * 4. 403 if the verified pubkey isn't in [allowList]. + * 5. 400 if the body isn't a valid Nip86Request. + * 6. 200 with a Nip86Response JSON body otherwise. + * + * The [signedUrlFor] callback resolves what URL the client must have + * signed in their NIP-98 token. Operators configure the canonical + * `publicUrl`; loopback tests fall back to the request's `Host` + * header. We pass it as a callback rather than a string so the route + * doesn't need to know about Ktor request internals. + */ +internal class Nip86HttpRoute( + private val server: Nip86Server, + private val verifier: Nip98AuthVerifier, + private val allowList: Set, + private val maxBodyBytes: Int, + private val signedUrlFor: (ApplicationCall) -> String, +) { + suspend fun handle(call: ApplicationCall) { + if (allowList.isEmpty()) { + call.respondText( + "NIP-86 management API is not enabled on this relay.", + ContentType.Text.Plain, + HttpStatusCode.Forbidden, + ) + return + } + + val body = readBoundedBody(call) ?: return + val pubkey = verifyAuth(call, body) ?: return + if (pubkey.lowercase() !in allowList) { + call.respondText( + "pubkey is not on the admin list", + ContentType.Text.Plain, + HttpStatusCode.Forbidden, + ) + return + } + + val req = + try { + JsonMapper.fromJson(body.decodeToString()) + } catch (e: Exception) { + call.respondText( + "invalid Nip86Request: ${e.message ?: e::class.simpleName}", + ContentType.Text.Plain, + HttpStatusCode.BadRequest, + ) + return + } + + val response: Nip86Response = server.dispatch(req) + audit(pubkey, req, response) + call.respondText( + JsonMapper.toJson(response), + ContentType.parse("application/nostr+json+rpc"), + HttpStatusCode.OK, + ) + } + + private suspend fun readBoundedBody(call: ApplicationCall): ByteArray? { + val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull() + if (declared != null && declared > maxBodyBytes) { + call.respondText( + "request body exceeds $maxBodyBytes-byte cap", + ContentType.Text.Plain, + HttpStatusCode.PayloadTooLarge, + ) + return null + } + val ch = call.receiveChannel() + val buf = ByteArray(maxBodyBytes + 1) + var pos = 0 + while (pos <= maxBodyBytes) { + val read = ch.readAvailable(buf, pos, buf.size - pos) + if (read <= 0) break + pos += read + } + if (pos > maxBodyBytes) { + call.respondText( + "request body exceeds $maxBodyBytes-byte cap", + ContentType.Text.Plain, + HttpStatusCode.PayloadTooLarge, + ) + return null + } + return buf.copyOfRange(0, pos) + } + + private suspend fun verifyAuth( + call: ApplicationCall, + body: ByteArray, + ): HexKey? { + val header = call.request.header(HttpHeaders.Authorization) + val verification = verifier.verify(header, method = "POST", url = signedUrlFor(call), body = body) + return when (verification) { + is Nip98AuthVerifier.Result.Verified -> { + verification.pubkey + } + + Nip98AuthVerifier.Result.Missing -> { + call.response.headers.append(HttpHeaders.WWWAuthenticate, Nip98AuthVerifier.SCHEME.trim()) + call.respondText( + "missing Authorization header (NIP-98)", + ContentType.Text.Plain, + HttpStatusCode.Unauthorized, + ) + null + } + + is Nip98AuthVerifier.Result.Malformed -> { + call.respondText( + "invalid NIP-98 Authorization: ${verification.reason}", + ContentType.Text.Plain, + HttpStatusCode.Unauthorized, + ) + null + } + } + } + + /** + * Audit log: structured single line so an operator can grep + * "nip86" / pubkey / method without a logging framework + * dependency. Best-effort — a missing log line shouldn't fail + * the response. + */ + private fun audit( + pubkey: HexKey, + req: Nip86Request, + response: Nip86Response, + ) { + runCatching { + System.err.println( + "nip86 audit pubkey=$pubkey method=${req.method} ok=${response.error == null}" + + (response.error?.let { " error=$it" } ?: ""), + ) + } + } +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/WebSocketSessionPump.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/WebSocketSessionPump.kt new file mode 100644 index 0000000000..952e6131f5 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/WebSocketSessionPump.kt @@ -0,0 +1,114 @@ +/* + * 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.server + +import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer +import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession +import io.ktor.server.websocket.DefaultWebSocketServerSession +import io.ktor.websocket.Frame +import io.ktor.websocket.readText +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedSendChannelException +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.launch + +/** + * Per-WebSocket pump that owns the bounded outbound queue and the + * writer coroutine. Pulled out of `LocalRelayServer` so that file + * stays focused on Ktor wiring; the slow-client / backpressure + * policy now lives next to the data structures it manages. + * + * Lifecycle: + * 1. `connect(server, registerSession)` opens a [RelaySession], + * registers it with the supplied callback, and starts the + * writer coroutine that drains [outQueue] into [outgoing]. + * 2. `pump()` reads inbound frames until the socket closes. + * 3. `finally`-style teardown closes the queue, cancels the + * writer, unregisters the session, and closes it. + * + * Slow-client policy: when [outQueue] fills, [SESSION_OUTGOING_BUFFER] + * frames behind, the connection is dropped rather than silently + * losing EVENT/EOSE — silent drop would corrupt NIP-01. + */ +internal class WebSocketSessionPump( + private val ws: DefaultWebSocketServerSession, +) { + private val outQueue = Channel(capacity = SESSION_OUTGOING_BUFFER) + private var droppedForBackpressure = false + + suspend fun pump( + server: NostrServer, + registerSession: (RelaySession) -> Unit, + unregisterSession: (RelaySession) -> Unit, + ) { + val writerJob = + ws.launch { + try { + for (json in outQueue) { + ws.outgoing.send(Frame.Text(json)) + } + } catch (_: ClosedSendChannelException) { + // socket closed — outer handler runs normal teardown. + } + } + val session = + server.connect { json -> + val res = outQueue.trySend(json) + if (!res.isSuccess && !res.isClosed) { + // Buffer is full → slow client. Mark + close the + // queue; the writer drains, then the outer handler + // closes the WS session. + droppedForBackpressure = true + outQueue.close() + } + } + registerSession(session) + try { + ws.incoming.consumeEach { frame -> + if (droppedForBackpressure) return@consumeEach + if (frame is Frame.Text) { + session.receive(frame.readText()) + } + } + } finally { + outQueue.close() + writerJob.cancel() + unregisterSession(session) + session.close() + } + } + + companion object { + /** + * Per-session outbound buffer size. When a slow client falls + * this many frames behind, we close their connection rather + * than silently dropping further frames (which would corrupt + * NIP-01 by missing EVENT/EOSE messages). + * + * Sized to hold fan-out for a connection holding several + * thousand subscriptions when one event matches all of them + * — the realistic upper bound for a relay client. At ~250B + * per frame this caps per-session memory at ~2 MiB before + * we drop the connection. + */ + const val SESSION_OUTGOING_BUFFER: Int = 8192 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt index 43543b1e54..00d2436f90 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt @@ -103,4 +103,22 @@ class LiveEventStore( * moment the NEG-OPEN arrives, not a streamed/live result. */ suspend fun snapshotQuery(filter: Filter): List = store.query(filter) + + /** + * Multi-filter snapshot. Unions the per-filter results and + * deduplicates by event id so an event matching N filters is + * yielded once. Used by NIP-77 NEG-OPEN when the policy stack + * rewrote the single incoming filter into several. + */ + suspend fun snapshotQuery(filters: List): List { + if (filters.size == 1) return snapshotQuery(filters[0]) + val seen = HashSet() + val merged = ArrayList() + for (f in filters) { + for (e in store.query(f)) { + if (seen.add(e.id)) merged += e + } + } + return merged + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NegSessionRegistry.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NegSessionRegistry.kt new file mode 100644 index 0000000000..f85b723d0c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NegSessionRegistry.kt @@ -0,0 +1,115 @@ +/* + * 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.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd +import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage +import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd +import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd +import com.vitorpamplona.quartz.nip77Negentropy.NegentropyServerSession + +/** + * Per-connection NIP-77 negentropy state and dispatch. + * + * Owns the map of active reconciliation sessions keyed by NEG-OPEN + * subId, and the open/msg/close handlers. Pulled out of [RelaySession] + * so the connection class only routes commands while this class owns + * the negentropy lifecycle and error mapping. + * + * Plain [HashMap] is sufficient because the registry is mutated only + * from [RelaySession.receive] — that path is single-threaded per the + * WebSocket handler contract. + */ +class NegSessionRegistry( + private val store: LiveEventStore, + private val send: (Message) -> Unit, +) { + private val sessions = HashMap() + + /** + * Open a reconciliation session. The relay snapshots its matching + * events at this instant — concurrent inserts during the sync are + * not surfaced; clients re-open if they want fresh state. + * + * Access control reuses the REQ policy hook: a relay that requires + * AUTH or has kind/pubkey allow-deny lists applies the same rules + * to NEG-OPEN as it does to subscription REQs. + */ + suspend fun open( + cmd: NegOpenCmd, + policy: IRelayPolicy, + ) { + val gate = policy.accept(ReqCmd(cmd.subId, listOf(cmd.filter))) + if (gate is PolicyResult.Rejected) { + send(NegErrMessage(cmd.subId, gate.reason)) + return + } + val filters = (gate as PolicyResult.Accepted).cmd.filters + + // NIP-77: same-subId OPEN replaces any prior session. + sessions.remove(cmd.subId) + + val events = store.snapshotQuery(filters) + val session = NegentropyServerSession(cmd.subId, events) + sessions[cmd.subId] = session + + runMessage(cmd.subId, session) { it.processMessage(cmd.initialMessage) } + } + + fun msg(cmd: NegMsgCmd) { + val session = sessions[cmd.subId] + if (session == null) { + send(NegErrMessage(cmd.subId, "error: no negentropy session for ${cmd.subId}")) + return + } + runMessage(cmd.subId, session) { it.processMessage(cmd.message) } + } + + /** + * Spec: clients send NEG-CLOSE to free server-side state. + * Silent no-op if the session is unknown — there's no authoritative + * error response in NIP-77 for an unknown close. + */ + fun close(cmd: NegCloseCmd) { + sessions.remove(cmd.subId) + } + + /** Dropped on `RelaySession.cancelAllSubscriptions`. */ + fun clear() { + sessions.clear() + } + + private inline fun runMessage( + subId: String, + session: NegentropyServerSession, + block: (NegentropyServerSession) -> Message?, + ) { + try { + val response = block(session) + if (response != null) send(response) + } catch (e: Exception) { + sessions.remove(subId) + send(NegErrMessage(subId, "error: ${e.message ?: e::class.simpleName}")) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index 9baf769a95..d72ff491cf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -35,12 +35,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd -import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd -import com.vitorpamplona.quartz.nip77Negentropy.NegentropyServerSession import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -58,13 +57,8 @@ class RelaySession( ) : AutoCloseable { private val subscriptions = LargeCache() - /** - * NIP-77 negentropy reconciliation sessions, keyed by NEG-OPEN - * subId. Plain hash map here (not [LargeCache]) because it's - * mutated only from the single-threaded `receive()` path — - * RelaySession.receive is serialised by the WebSocket handler. - */ - private val negSessions = HashMap() + /** NIP-77 negentropy state for this connection. */ + private val negentropy = NegSessionRegistry(store, ::send) private fun addSubscription( subId: String, @@ -80,7 +74,7 @@ class RelaySession( fun cancelAllSubscriptions() { subscriptions.forEach { _, job -> job.cancel() } subscriptions.clear() - negSessions.clear() + negentropy.clear() } fun send(message: Message) { @@ -121,9 +115,9 @@ class RelaySession( is ReqCmd -> handleReq(cmd) is CloseCmd -> handleClose(cmd) is CountCmd -> handleCount(cmd) - is NegOpenCmd -> handleNegOpen(cmd) - is NegMsgCmd -> handleNegMsg(cmd) - is NegCloseCmd -> handleNegClose(cmd) + is NegOpenCmd -> negentropy.open(cmd, policy) + is NegMsgCmd -> negentropy.msg(cmd) + is NegCloseCmd -> negentropy.close(cmd) else -> send(NoticeMessage("error: unsupported command ${cmd.label()}")) } } @@ -195,7 +189,7 @@ class RelaySession( }, onEose = { send(EoseMessage(cmd.subId)) }, ) - } catch (_: kotlinx.coroutines.CancellationException) { + } catch (_: CancellationException) { // Subscription was closed – this is expected. } } @@ -211,82 +205,6 @@ class RelaySession( } } - // -- NIP-77: NEG-OPEN ----------------------------------------------------- - - /** - * Open a negentropy reconciliation session. The relay snapshots its - * matching events at this instant — concurrent inserts during the - * sync are not surfaced; clients re-open if they want fresh state. - * - * Access control reuses the REQ policy hook: a relay that requires - * AUTH or has kind/pubkey allow-deny lists applies the same rules - * to NEG-OPEN as it does to subscription REQs. - */ - private suspend fun handleNegOpen(cmd: NegOpenCmd) { - // Run the same access controls as REQ would. - val asReq = ReqCmd(cmd.subId, listOf(cmd.filter)) - val gate = policy.accept(asReq) - if (gate is PolicyResult.Rejected) { - send(NegErrMessage(cmd.subId, gate.reason)) - return - } - val filters = (gate as PolicyResult.Accepted).cmd.filters - - // Drop any prior session at this subId (NIP-77: same-subId - // OPEN replaces). - negSessions.remove(cmd.subId) - - val events = - if (filters.size == 1) { - store.snapshotQuery(filters[0]) - } else { - // Multiple filters: union the snapshots and dedupe by id. - val seen = HashSet() - val merged = mutableListOf() - for (f in filters) { - for (e in store.snapshotQuery(f)) { - if (seen.add(e.id)) merged += e - } - } - merged - } - - val neg = NegentropyServerSession(cmd.subId, events) - negSessions[cmd.subId] = neg - - try { - val response = neg.processMessage(cmd.initialMessage) - if (response != null) send(response) - } catch (e: Exception) { - negSessions.remove(cmd.subId) - send(NegErrMessage(cmd.subId, "error: ${e.message ?: e::class.simpleName}")) - } - } - - // -- NIP-77: NEG-MSG ------------------------------------------------------ - private fun handleNegMsg(cmd: NegMsgCmd) { - val neg = negSessions[cmd.subId] - if (neg == null) { - send(NegErrMessage(cmd.subId, "error: no negentropy session for ${cmd.subId}")) - return - } - try { - val response = neg.processMessage(cmd.message) - if (response != null) send(response) - } catch (e: Exception) { - negSessions.remove(cmd.subId) - send(NegErrMessage(cmd.subId, "error: ${e.message ?: e::class.simpleName}")) - } - } - - // -- NIP-77: NEG-CLOSE ---------------------------------------------------- - private fun handleNegClose(cmd: NegCloseCmd) { - // Spec: clients send NEG-CLOSE to free server-side state. - // Silent no-op if the session is unknown — there's no authoritative - // error response in NIP-77 for an unknown close. - negSessions.remove(cmd.subId) - } - init { policy.onConnect(::send) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt index 2478fcaea1..9558d8998b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt @@ -27,7 +27,7 @@ import kotlinx.serialization.Serializable @Stable @Serializable -class Nip11RelayInformation( +data class Nip11RelayInformation( val id: String? = null, val name: String? = null, val description: String? = null, @@ -59,7 +59,7 @@ class Nip11RelayInformation( @Stable @Serializable - class RelayInformationFee( + data class RelayInformationFee( val amount: Int? = null, val unit: String? = null, val period: Int? = null, @@ -68,7 +68,7 @@ class Nip11RelayInformation( @Stable @Serializable - class RelayInformationFees( + data class RelayInformationFees( val admission: List? = null, val subscription: List? = null, val publication: List? = null, @@ -76,7 +76,7 @@ class Nip11RelayInformation( @Stable @Serializable - class RelayInformationLimitation( + data class RelayInformationLimitation( val max_message_length: Int? = null, val max_subscriptions: Int? = null, val max_filters: Int? = null, @@ -96,7 +96,7 @@ class Nip11RelayInformation( @Stable @Serializable - class RelayInformationRetentionData( + data class RelayInformationRetentionData( val kinds: ArrayList? = null, val time: Int? = null, val count: Int? = null, From f7d4e3340911a7976a2ae806bb7bbf05a6a13de2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 13:02:23 +0000 Subject: [PATCH 14/17] refactor: promote relay-server toolkit from quartz-relay to quartz Generalize the operator-agnostic pieces so any embed of the relay server can use them without depending on quartz-relay (Ktor, TOML, operator wrapper). quartz-relay shrinks to its actual job: TOML config, Ktor wiring, persistence sidecar, and the Relay composition. - Nip98AuthVerifier -> quartz nip98HttpAuth (verify() now suspend, uses kotlinx Mutex for KMP). Pairs with HTTPAuthorizationEvent. - PassThroughPolicy + KindAllowDenyPolicy + PubkeyAllowDenyPolicy + RejectFutureEventsPolicy -> quartz nip01Core/relay/server/policies alongside the existing EmptyPolicy / VerifyPolicy / FullAuthPolicy. - Collapse EmptyPolicy into 'object EmptyPolicy : PassThroughPolicy()' (PassThroughPolicy is now an open class instead of abstract). - BanStore -> quartz nip86RelayManagement/server. Reimplemented lock-free with kotlin.concurrent.atomics.AtomicReference + immutable state snapshots so it works in commonMain. - DynamicBanPolicy -> renamed to BanListPolicy; lives next to the BanStore it consults. The "Dynamic" qualifier was a contrast to static policies in quartz-relay; in its new home the name describes what it actually does. - Nip86Server -> quartz nip86RelayManagement/server next to Nip86Client. Drops the RelayInfo wrapper indirection: InfoHolder now operates on Nip11RelayInformation directly. - InProcessWebSocket -> quartz nip01Core/relay/server/inprocess. Constructor takes NostrServer (was: the operator-side Relay wrapper) so any embed can wire the same in-process bridge. - Move BanStoreTest, Nip86ServerTest, Nip98AuthVerifierTest into quartz/jvmAndroidTest. Adjust runBlocking-bodied tests so JUnit 4 sees Unit returns. PoliciesTest stays in quartz-relay tests because it uses module-local SyntheticEvents fixtures. --- .../quartz/relay/LocalRelayServer.kt | 23 +- .../com/vitorpamplona/quartz/relay/Main.kt | 6 +- .../com/vitorpamplona/quartz/relay/Relay.kt | 17 +- .../vitorpamplona/quartz/relay/RelayHub.kt | 3 +- .../quartz/relay/admin/BanStore.kt | 202 ------------------ .../quartz/relay/server/Nip86HttpRoute.kt | 4 +- .../quartz/relay/admin/Nip86EndToEndTest.kt | 2 +- .../relay/policies/PoliciesIntegrationTest.kt | 3 + .../quartz/relay/policies/PoliciesTest.kt | 3 + .../server/inprocess}/InProcessWebSocket.kt | 24 ++- .../relay/server/policies/EmptyPolicy.kt | 29 +-- .../server}/policies/KindAllowDenyPolicy.kt | 8 +- .../server}/policies/PassThroughPolicy.kt | 8 +- .../server}/policies/PubkeyAllowDenyPolicy.kt | 2 +- .../policies/RejectFutureEventsPolicy.kt | 4 +- .../server/BanListPolicy.kt | 20 +- .../nip86RelayManagement/server/BanStore.kt | 187 ++++++++++++++++ .../server}/Nip86Server.kt | 26 +-- .../nip98HttpAuth}/Nip98AuthVerifier.kt | 33 +-- .../server}/BanStoreTest.kt | 2 +- .../server}/Nip86ServerTest.kt | 44 ++-- .../nip98HttpAuth}/Nip98AuthVerifierTest.kt | 91 ++++---- 22 files changed, 378 insertions(+), 363 deletions(-) delete mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay => quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess}/InProcessWebSocket.kt (81%) rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay => quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server}/policies/KindAllowDenyPolicy.kt (89%) rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay => quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server}/policies/PassThroughPolicy.kt (89%) rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay => quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server}/policies/PubkeyAllowDenyPolicy.kt (97%) rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay => quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server}/policies/RejectFutureEventsPolicy.kt (94%) rename quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/DynamicBanPolicy.kt => quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt (78%) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin => quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server}/Nip86Server.kt (92%) rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin => quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip98HttpAuth}/Nip98AuthVerifier.kt (85%) rename {quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin => quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server}/BanStoreTest.kt (98%) rename {quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin => quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server}/Nip86ServerTest.kt (86%) rename {quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin => quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip98HttpAuth}/Nip98AuthVerifierTest.kt (51%) diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt index 3c54b4c278..06262048ee 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt @@ -23,8 +23,9 @@ package com.vitorpamplona.quartz.relay import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession -import com.vitorpamplona.quartz.relay.admin.Nip86Server -import com.vitorpamplona.quartz.relay.admin.Nip98AuthVerifier +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server +import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier import com.vitorpamplona.quartz.relay.server.Nip86HttpRoute import com.vitorpamplona.quartz.relay.server.WebSocketSessionPump import io.ktor.http.ContentType @@ -47,12 +48,14 @@ import java.util.concurrent.ConcurrentHashMap /** * Hosts a [Relay] over a real `ws://` endpoint backed by Ktor + CIO. * - * Use this when something other than the in-process [InProcessWebSocket] needs - * to talk to the relay — Android instrumented tests, the `cli` tooling, - * external clients, or a standalone "run a Nostr relay" process. + * Use this when something other than the in-process + * [com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket] + * needs to talk to the relay — Android instrumented tests, the `cli` + * tooling, external clients, or a standalone "run a Nostr relay" + * process. * - * For unit-test wiring inside a single JVM, prefer [RelayHub] + - * [InProcessWebSocket] — same protocol, no socket overhead. + * For unit-test wiring inside a single JVM, prefer [RelayHub] + the + * in-process socket — same protocol, no socket overhead. * * Lifecycle: * ``` @@ -109,10 +112,10 @@ class LocalRelayServer( ) { private val infoHolder = object : Nip86Server.InfoHolder { - override fun get() = relay.info + override fun get(): Nip11RelayInformation = relay.info.document - override fun set(info: RelayInfo) { - relay.updateInfo { info.document } + override fun set(info: Nip11RelayInformation) { + relay.updateInfo { info } } } 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 cdfe9890be..74203f4f36 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 @@ -24,13 +24,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.relay.config.RelayConfig -import com.vitorpamplona.quartz.relay.policies.KindAllowDenyPolicy -import com.vitorpamplona.quartz.relay.policies.PubkeyAllowDenyPolicy -import com.vitorpamplona.quartz.relay.policies.RejectFutureEventsPolicy import java.io.File /** diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt index 8e535093db..2c17cfaf9c 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt @@ -30,8 +30,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import com.vitorpamplona.quartz.relay.admin.BanStore -import com.vitorpamplona.quartz.relay.admin.DynamicBanPolicy +import com.vitorpamplona.quartz.nip86RelayManagement.server.BanListPolicy +import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore import com.vitorpamplona.quartz.relay.persistence.BannedEntry import com.vitorpamplona.quartz.relay.persistence.RelayPersistedState import com.vitorpamplona.quartz.relay.persistence.RelayStateStore @@ -49,7 +49,8 @@ import kotlin.coroutines.CoroutineContext * NIP-45 (COUNT) and NIP-50 (search via the SQLite FTS index). * * Two transports: - * - [InProcessWebSocket] / [RelayHub] — no socket, fastest path, ideal + * - [com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket] / + * [RelayHub] — no socket, fastest path, ideal * for unit tests inside one JVM. * - [LocalRelayServer] — Ktor `embeddedServer` listening on a real port. * Use when external clients need to connect (`cli`, instrumented tests, @@ -91,7 +92,7 @@ class Relay( stateStore?.load()?.info?.let { RelayInfo(it) } ?: info private set - /** Mutates the live NIP-11 doc. Called by [admin.Nip86Server]. */ + /** Mutates the live NIP-11 doc. Called by [Nip86Server]. */ fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) { info = RelayInfo(transform(info.document)) snapshot() @@ -99,8 +100,8 @@ class Relay( /** * Runtime-mutable ban / allow lists. NIP-86 RPC handlers in - * [admin.Nip86Server] mutate this; the policy stack consults it on - * every accept call via [DynamicBanPolicy]. + * [Nip86Server] mutate this; the policy stack consults it on + * every accept call via [BanListPolicy]. */ val banStore: BanStore = BanStore(onMutation = { snapshot() }) @@ -148,13 +149,13 @@ class Relay( val server = NostrServer( store, - // Always prepend a DynamicBanPolicy so NIP-86 admin actions + // 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. policyBuilder = { val user = policyBuilder() - if (user === EmptyPolicy) DynamicBanPolicy(banStore) else user + DynamicBanPolicy(banStore) + if (user === EmptyPolicy) BanListPolicy(banStore) else user + BanListPolicy(banStore) }, parentContext, ) diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt index f1d6315205..0f67e82a0e 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.relay import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener @@ -72,7 +73,7 @@ class RelayHub( override fun build( url: NormalizedRelayUrl, out: WebSocketListener, - ): WebSocket = InProcessWebSocket(getOrCreate(url), out) + ): WebSocket = InProcessWebSocket(getOrCreate(url).server, out) /** * Idempotent. Sets the closed flag first so concurrent diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt deleted file mode 100644 index 8d69582b3e..0000000000 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt +++ /dev/null @@ -1,202 +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.quartz.relay.admin - -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import java.util.concurrent.ConcurrentHashMap - -/** - * Mutable, thread-safe runtime state for the NIP-86 management API. - * - * Each entry carries an optional reason string so list-* RPCs can echo - * back why an admin took the action — useful for audit trails. - * - * Today the state is in-memory only; a process restart wipes the bans. - * Wiring a persistent backend is a separate concern (a JSON file - * snapshot on each mutation, or a small SQLite table) and can be - * layered on by replacing this class behind the [DynamicBanPolicy] - * interface. - */ -class BanStore( - /** - * Called after every mutation. The relay uses this to snapshot the - * full state to disk so admin actions survive a restart. `null` - * disables persistence (in-memory only — fine for tests). - */ - private val onMutation: (() -> Unit)? = null, -) { - /** - * Pubkeys whose events the relay rejects. Compared case-insensitive - * (lowercased on insert / lookup) so an admin pasting a hex pubkey - * with mixed case still works. Empty-string value means "no - * reason given" — `ConcurrentHashMap` rejects nulls. - */ - private val bannedPubkeys = ConcurrentHashMap() - - /** - * Pubkeys explicitly allowed. When non-empty, this acts as a - * whitelist: events from any pubkey not on the list are rejected. - */ - private val allowedPubkeys = ConcurrentHashMap() - - /** Event ids the relay refuses to store/replay. */ - private val bannedEventIds = ConcurrentHashMap() - - private fun reasonOrEmpty(s: String?): String = s ?: "" - - private fun nullIfEmpty(s: String): String? = s.ifEmpty { null } - - /** - * Allowed kinds. When non-empty, events whose kind is not in the - * list are rejected. Kind ops mutate two related sets (allow + - * disallow) and need to look symmetric to readers, so we serialise - * all kind reads/writes through [kindLock] rather than rely on - * the per-set thread safety of `ConcurrentHashMap.newKeySet`. - */ - private val allowedKinds = HashSet() - - /** Disallowed kinds. Always blocks regardless of [allowedKinds]. */ - private val disallowedKinds = HashSet() - - private val kindLock = Any() - - // -- Pubkey ban list ----------------------------------------------------- - - fun banPubkey( - pubkey: HexKey, - reason: String? = null, - ) { - bannedPubkeys[pubkey.lowercase()] = reasonOrEmpty(reason) - fireMutation() - } - - fun unbanPubkey(pubkey: HexKey) { - bannedPubkeys.remove(pubkey.lowercase()) - fireMutation() - } - - fun isBanned(pubkey: HexKey): Boolean = bannedPubkeys.containsKey(pubkey.lowercase()) - - fun listBannedPubkeys(): List> = bannedPubkeys.entries.map { it.key to nullIfEmpty(it.value) } - - // -- Pubkey allow list --------------------------------------------------- - - fun allowPubkey( - pubkey: HexKey, - reason: String? = null, - ) { - allowedPubkeys[pubkey.lowercase()] = reasonOrEmpty(reason) - fireMutation() - } - - fun unallowPubkey(pubkey: HexKey) { - allowedPubkeys.remove(pubkey.lowercase()) - fireMutation() - } - - fun isAllowedPubkey(pubkey: HexKey): Boolean = allowedPubkeys.containsKey(pubkey.lowercase()) - - fun listAllowedPubkeys(): List> = allowedPubkeys.entries.map { it.key to nullIfEmpty(it.value) } - - fun hasAllowList(): Boolean = allowedPubkeys.isNotEmpty() - - // -- Event id ban list --------------------------------------------------- - - fun banEvent( - eventId: HexKey, - reason: String? = null, - ) { - bannedEventIds[eventId.lowercase()] = reasonOrEmpty(reason) - fireMutation() - } - - /** Removes an event id from the ban list. Mirrors NIP-86 `allowevent`. */ - fun allowEvent(eventId: HexKey) { - bannedEventIds.remove(eventId.lowercase()) - fireMutation() - } - - fun isBannedEvent(eventId: HexKey): Boolean = bannedEventIds.containsKey(eventId.lowercase()) - - fun listBannedEvents(): List> = bannedEventIds.entries.map { it.key to nullIfEmpty(it.value) } - - // -- Kind allow / deny -------------------------------------------------- - - /** - * `allowKind` and `disallowKind` are symmetric: each adds to its - * own set AND removes the kind from the opposite set. Otherwise - * an `allowKind(K)` after a `disallowKind(K)` would leave K in - * both sets and stay blocked, surprising operators. - */ - fun allowKind(kind: Int) { - synchronized(kindLock) { - disallowedKinds.remove(kind) - allowedKinds.add(kind) - } - fireMutation() - } - - fun disallowKind(kind: Int) { - synchronized(kindLock) { - allowedKinds.remove(kind) - disallowedKinds.add(kind) - } - fireMutation() - } - - fun listAllowedKinds(): List = synchronized(kindLock) { allowedKinds.sorted() } - - fun listDisallowedKinds(): List = synchronized(kindLock) { disallowedKinds.sorted() } - - fun isKindAllowed(kind: Int): Boolean = - synchronized(kindLock) { - if (kind in disallowedKinds) return false - if (allowedKinds.isEmpty()) return true - return kind in allowedKinds - } - - /** - * Bulk-load state without firing [onMutation]. Used at startup to - * seed the in-memory state from a persisted snapshot — we don't - * want every individual `put` to trigger another disk write. After - * this call the store behaves exactly as if every entry had been - * mutated through the public API. - */ - internal fun seedFromSnapshot( - bannedPubkeys: List>, - allowedPubkeys: List>, - bannedEvents: List>, - allowedKinds: List, - disallowedKinds: List, - ) { - bannedPubkeys.forEach { (k, r) -> this.bannedPubkeys[k.lowercase()] = reasonOrEmpty(r) } - allowedPubkeys.forEach { (k, r) -> this.allowedPubkeys[k.lowercase()] = reasonOrEmpty(r) } - bannedEvents.forEach { (k, r) -> this.bannedEventIds[k.lowercase()] = reasonOrEmpty(r) } - synchronized(kindLock) { - this.allowedKinds.addAll(allowedKinds) - this.disallowedKinds.addAll(disallowedKinds) - } - } - - private fun fireMutation() { - onMutation?.invoke() - } -} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/Nip86HttpRoute.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/Nip86HttpRoute.kt index e73d1653e2..43f89e837d 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/Nip86HttpRoute.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/Nip86HttpRoute.kt @@ -24,8 +24,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response -import com.vitorpamplona.quartz.relay.admin.Nip86Server -import com.vitorpamplona.quartz.relay.admin.Nip98AuthVerifier +import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server +import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt index 0aabacd554..9659646770 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt @@ -170,7 +170,7 @@ class Nip86EndToEndTest { // Subsequent EVENT from the banned author is rejected. val after = nostrClient.publishAndConfirm(targetUser.sign(TextNoteEvent.build("second")), setOf(relayUrl)) - assertEquals(false, after, "DynamicBanPolicy must reject events from banned pubkeys") + assertEquals(false, after, "BanListPolicy must reject events from banned pubkeys") } @Test diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt index c4fcfe8dd3..2bb4c48157 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt @@ -26,6 +26,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCon import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +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.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.relay.RelayHub diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt index 0c544c7f11..5636e837a2 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt @@ -22,6 +22,9 @@ package com.vitorpamplona.quartz.relay.policies import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult +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.relay.fixtures.SyntheticEvents import kotlin.test.Test import kotlin.test.assertTrue diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/InProcessWebSocket.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocket.kt similarity index 81% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/InProcessWebSocket.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocket.kt index 0b50351a3c..1c4bb53c33 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/InProcessWebSocket.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocket.kt @@ -18,8 +18,9 @@ * 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 +package com.vitorpamplona.quartz.nip01Core.relay.server.inprocess +import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener @@ -33,21 +34,26 @@ import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.launch /** - * In-memory implementation of [WebSocket] that talks to a [Relay] without - * touching the network. Each instance opens one [RelaySession] on - * [connect] and routes: + * In-memory implementation of [WebSocket] that talks directly to a + * [NostrServer] without touching the network. Each instance opens one + * [RelaySession] on [connect] and routes: * - * - Outbound (`send`) → server `RelaySession.receive()` via an inbound channel - * drained by a single coroutine, preserving message order per the - * [WebSocketListener] contract. + * - Outbound (`send`) → server `RelaySession.receive()` via an inbound + * channel drained by a single coroutine, preserving message order + * per the [WebSocketListener] contract. * - Server-side `send` callbacks → [WebSocketListener.onMessage]. * + * Use this to wire a `NostrClient` to an embedded server in unit tests + * or single-JVM scenarios without paying for a real TCP socket. Because + * it implements [WebSocket], it slots into anywhere a `WebsocketBuilder` + * expects. + * * Reconnect-after-disconnect is supported: each [connect] creates a * fresh scope + drain channel so a previous [disconnect] (which * cancels both) doesn't leave a dead drainer behind. */ class InProcessWebSocket( - private val relay: Relay, + private val server: NostrServer, private val out: WebSocketListener, ) : WebSocket { private var scope: CoroutineScope? = null @@ -61,7 +67,7 @@ class InProcessWebSocket( if (session != null) return val newScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) val newIncoming = Channel(UNLIMITED) - val s = relay.server.connect { json -> out.onMessage(json) } + val s = server.connect { json -> out.onMessage(json) } scope = newScope incoming = newIncoming diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt index fe7429a43e..cd1106436b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt @@ -20,28 +20,11 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.server.policies -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd -import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy -import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult - /** - * Allows all commands without authentication. This is the default policy. + * Allows all commands without authentication. The default policy. + * + * Singleton form of [PassThroughPolicy] for callers that want a + * shared no-op (saves an allocation and lets the relay shortcut + * `policy === EmptyPolicy` checks when composing stacks). */ -object EmptyPolicy : IRelayPolicy { - override fun onConnect(send: (Message) -> Unit) { } - - override fun accept(cmd: EventCmd) = PolicyResult.Accepted(cmd) - - override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd) - - override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) - - override fun accept(cmd: AuthCmd) = PolicyResult.Accepted(cmd) - - override fun canSendToSession(event: Event) = true -} +object EmptyPolicy : PassThroughPolicy() diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/KindAllowDenyPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/KindAllowDenyPolicy.kt similarity index 89% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/KindAllowDenyPolicy.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/KindAllowDenyPolicy.kt index b23978ef9e..77331db711 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/KindAllowDenyPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/KindAllowDenyPolicy.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay.policies +package com.vitorpamplona.quartz.nip01Core.relay.server.policies import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult @@ -27,10 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult * Operator-controlled kind allow/deny list. Mirrors nostr-rs-relay's * `[authorization].kind_whitelist` / `kind_blacklist`. * - * - When [allow] is non-empty, only events whose [Event.kind] is in - * [allow] are accepted; everything else gets `blocked: kind X not allowed`. + * - When [allow] is non-empty, only events whose kind is in [allow] + * are accepted; everything else is rejected. * - When [deny] is non-empty, events whose kind is in [deny] are - * rejected with `blocked: kind X denied`. + * rejected. * - Both lists may be empty (no-op pass-through). * - When both are set, allow is checked first (deny inside allow is * still denied, matching nostr-rs-relay's precedence). diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PassThroughPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PassThroughPolicy.kt similarity index 89% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PassThroughPolicy.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PassThroughPolicy.kt index f79d2a85b4..09e8d4760f 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PassThroughPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PassThroughPolicy.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay.policies +package com.vitorpamplona.quartz.nip01Core.relay.server.policies import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message @@ -33,8 +33,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult * Convenience base that accepts everything by default. Subclasses * override only the hook(s) they actually enforce so the call sites * stay readable. + * + * Concrete (not abstract) so [EmptyPolicy] can subclass it as a + * singleton and external code that just wants a no-op policy can + * instantiate this directly. */ -abstract class PassThroughPolicy : IRelayPolicy { +open class PassThroughPolicy : IRelayPolicy { override fun onConnect(send: (Message) -> Unit) {} override fun accept(cmd: EventCmd): PolicyResult = PolicyResult.Accepted(cmd) diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PubkeyAllowDenyPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PubkeyAllowDenyPolicy.kt similarity index 97% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PubkeyAllowDenyPolicy.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PubkeyAllowDenyPolicy.kt index d43ceeb0a0..199c86a97c 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PubkeyAllowDenyPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PubkeyAllowDenyPolicy.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay.policies +package com.vitorpamplona.quartz.nip01Core.relay.server.policies import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RejectFutureEventsPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/RejectFutureEventsPolicy.kt similarity index 94% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RejectFutureEventsPolicy.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/RejectFutureEventsPolicy.kt index 30affa837d..34f0082085 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RejectFutureEventsPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/RejectFutureEventsPolicy.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay.policies +package com.vitorpamplona.quartz.nip01Core.relay.server.policies import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils * seconds in the future relative to the relay's clock. Mirrors * nostr-rs-relay's `[options].reject_future_seconds`. * - * This catches both clock-skew accidents and intentional far-future + * Catches both clock-skew accidents and intentional far-future * timestamps used to push events to the top of newest-first feeds. * * The current time is read from [TimeUtils.now] (epoch seconds), the diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/DynamicBanPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt similarity index 78% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/DynamicBanPolicy.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt index 9bc1aa4895..398f93d7d7 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/DynamicBanPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt @@ -18,26 +18,26 @@ * 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.admin +package com.vitorpamplona.quartz.nip86RelayManagement.server import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult -import com.vitorpamplona.quartz.relay.policies.PassThroughPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy /** * Reads the live [BanStore] on every EVENT and rejects events that * violate any of: banned-event-id, banned-pubkey, missing from a - * non-empty allow list, or kind disallowed / not in the kind allow - * list. + * non-empty pubkey allow list, or kind disallowed / not in the kind + * allow list. * * This is the runtime-mutable counterpart of the static - * [com.vitorpamplona.quartz.relay.policies.KindAllowDenyPolicy] + - * [com.vitorpamplona.quartz.relay.policies.PubkeyAllowDenyPolicy] — - * both sets compose: the event must clear both layers. NIP-86 admin - * RPC mutations land here; the static policies stay frozen at - * boot-time config values. + * [com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy] + + * [com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy] — + * both layers compose: the event must clear all stacked policies. + * NIP-86 admin RPC mutations land in the [BanStore]; the static + * policies stay frozen at boot-time config values. */ -class DynamicBanPolicy( +class BanListPolicy( val banStore: BanStore, ) : PassThroughPolicy() { override fun accept(cmd: EventCmd): PolicyResult { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt new file mode 100644 index 0000000000..ba38eff154 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt @@ -0,0 +1,187 @@ +/* + * 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.nip86RelayManagement.server + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +/** + * Lock-free runtime state for the NIP-86 management API. Holds the + * ban/allow lists that [BanListPolicy] consults on every accept call, + * plus an [onMutation] hook so the relay can persist the latest + * snapshot whenever an admin RPC mutates state. + * + * Each entry carries an optional reason string so list-* RPCs can echo + * back why an admin took the action — useful for audit trails. + * + * Persistence is intentionally NOT inside this class; supply + * [onMutation] to flush to disk (or wherever) and use [seedFromSnapshot] + * at boot to load. `null` keeps the store in-memory only. + * + * Concurrency: state is held in a single [AtomicReference] and mutated + * via copy-on-write CAS loops. Reads are wait-free single-load atomic. + * The data structures are tiny (operator-controlled) so the per-write + * map copy is negligible. + */ +@OptIn(ExperimentalAtomicApi::class) +class BanStore( + private val onMutation: (() -> Unit)? = null, +) { + /** + * Single immutable snapshot of all ban/allow state. Combined into + * one object so kind allow/disallow lock-step (allow adds to + * allowedKinds AND removes from disallowedKinds) is naturally + * atomic — no possibility of an interleaved reader observing a + * kind in both sets. + */ + private data class State( + val bannedPubkeys: Map = emptyMap(), + val allowedPubkeys: Map = emptyMap(), + val bannedEventIds: Map = emptyMap(), + val allowedKinds: Set = emptySet(), + val disallowedKinds: Set = emptySet(), + ) + + private val state = AtomicReference(State()) + + private inline fun mutate(transform: (State) -> State) { + while (true) { + val current = state.load() + if (state.compareAndSet(current, transform(current))) break + } + onMutation?.invoke() + } + + // -- Pubkey ban list ----------------------------------------------------- + + fun banPubkey( + pubkey: HexKey, + reason: String? = null, + ) = mutate { it.copy(bannedPubkeys = it.bannedPubkeys + (pubkey.lowercase() to reason)) } + + fun unbanPubkey(pubkey: HexKey) = mutate { it.copy(bannedPubkeys = it.bannedPubkeys - pubkey.lowercase()) } + + fun isBanned(pubkey: HexKey): Boolean = pubkey.lowercase() in state.load().bannedPubkeys + + fun listBannedPubkeys(): List> = + state + .load() + .bannedPubkeys.entries + .map { it.key to it.value } + + // -- Pubkey allow list --------------------------------------------------- + + fun allowPubkey( + pubkey: HexKey, + reason: String? = null, + ) = mutate { it.copy(allowedPubkeys = it.allowedPubkeys + (pubkey.lowercase() to reason)) } + + fun unallowPubkey(pubkey: HexKey) = mutate { it.copy(allowedPubkeys = it.allowedPubkeys - pubkey.lowercase()) } + + fun isAllowedPubkey(pubkey: HexKey): Boolean = pubkey.lowercase() in state.load().allowedPubkeys + + fun listAllowedPubkeys(): List> = + state + .load() + .allowedPubkeys.entries + .map { it.key to it.value } + + fun hasAllowList(): Boolean = state.load().allowedPubkeys.isNotEmpty() + + // -- Event id ban list --------------------------------------------------- + + fun banEvent( + eventId: HexKey, + reason: String? = null, + ) = mutate { it.copy(bannedEventIds = it.bannedEventIds + (eventId.lowercase() to reason)) } + + /** Removes an event id from the ban list. Mirrors NIP-86 `allowevent`. */ + fun allowEvent(eventId: HexKey) = mutate { it.copy(bannedEventIds = it.bannedEventIds - eventId.lowercase()) } + + fun isBannedEvent(eventId: HexKey): Boolean = eventId.lowercase() in state.load().bannedEventIds + + fun listBannedEvents(): List> = + state + .load() + .bannedEventIds.entries + .map { it.key to it.value } + + // -- Kind allow / deny -------------------------------------------------- + + /** + * `allowKind` and `disallowKind` are symmetric: each adds to its + * own set AND removes the kind from the opposite set. Otherwise + * an `allowKind(K)` after a `disallowKind(K)` would leave K in + * both sets and stay blocked, surprising operators. + */ + fun allowKind(kind: Int) = + mutate { + it.copy( + allowedKinds = it.allowedKinds + kind, + disallowedKinds = it.disallowedKinds - kind, + ) + } + + fun disallowKind(kind: Int) = + mutate { + it.copy( + allowedKinds = it.allowedKinds - kind, + disallowedKinds = it.disallowedKinds + kind, + ) + } + + fun listAllowedKinds(): List = state.load().allowedKinds.sorted() + + fun listDisallowedKinds(): List = state.load().disallowedKinds.sorted() + + fun isKindAllowed(kind: Int): Boolean { + val s = state.load() + if (kind in s.disallowedKinds) return false + if (s.allowedKinds.isEmpty()) return true + return kind in s.allowedKinds + } + + /** + * Bulk-load state without firing [onMutation]. Used at startup to + * seed the in-memory state from a persisted snapshot — we don't + * want every individual `put` to trigger another disk write. After + * this call the store behaves exactly as if every entry had been + * mutated through the public API. + */ + fun seedFromSnapshot( + bannedPubkeys: List> = emptyList(), + allowedPubkeys: List> = emptyList(), + bannedEvents: List> = emptyList(), + allowedKinds: List = emptyList(), + disallowedKinds: List = emptyList(), + ) { + state.store( + State( + bannedPubkeys = bannedPubkeys.associate { (k, r) -> k.lowercase() to r }, + allowedPubkeys = allowedPubkeys.associate { (k, r) -> k.lowercase() to r }, + bannedEventIds = bannedEvents.associate { (k, r) -> k.lowercase() to r }, + allowedKinds = allowedKinds.toSet(), + disallowedKinds = disallowedKinds.toSet(), + ), + ) + } +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt similarity index 92% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt index 7d03babef8..7b5734f820 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay.admin +package com.vitorpamplona.quartz.nip86RelayManagement.server import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IEventStore @@ -29,7 +29,6 @@ import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response -import com.vitorpamplona.quartz.relay.RelayInfo import com.vitorpamplona.quartz.utils.Hex import kotlinx.coroutines.CancellationException import kotlinx.serialization.KSerializer @@ -43,14 +42,16 @@ import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.int /** - * NIP-86 RPC dispatcher. Holds the [BanStore] (mutated by ban/allow - * methods), the live [RelayInfo] handle (mutated by `changerelay*` - * methods, which atomically swap the doc), and the underlying - * [IEventStore] so `banevent` can also delete the offending event. + * Server-side dispatcher for the NIP-86 relay management API. * - * The dispatcher is transport-agnostic — `LocalRelayServer` calls - * [dispatch] from its HTTP route, but the same handler also works for - * in-process tests that build a [Nip86Request] directly. + * Holds the [BanStore] (mutated by ban/allow methods), an [InfoHolder] + * for the live NIP-11 doc (mutated by `changerelay*` methods, which + * atomically swap it), and an optional [IEventStore] so `banevent` + * can also delete the offending event from the store. + * + * Transport-agnostic — relay implementations call [dispatch] from + * whatever HTTP route they expose (e.g. POST `application/nostr+json+rpc`), + * and in-process tests can build a [Nip86Request] directly. * * [supportedMethods] is the canonical list this server actually * implements; methods returned outside of it are no-ops and a NIP-86 @@ -70,9 +71,9 @@ class Nip86Server( ) { /** Pluggable container so the relay's NIP-11 doc can be swapped at runtime. */ interface InfoHolder { - fun get(): RelayInfo + fun get(): Nip11RelayInformation - fun set(info: RelayInfo) + fun set(info: Nip11RelayInformation) } val supportedMethods: List = @@ -225,8 +226,7 @@ class Nip86Server( } private fun rewriteInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) { - val current = infoHolder.get().document - infoHolder.set(RelayInfo(transform(current))) + infoHolder.set(transform(infoHolder.get())) } } diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifier.kt similarity index 85% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifier.kt index 86b3074c98..b88bcccc61 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifier.kt @@ -18,33 +18,35 @@ * 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.admin +package com.vitorpamplona.quartz.nip98HttpAuth import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.verify -import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.abs /** - * Verifies a NIP-98 `Authorization: Nostr ` header. + * Server-side counterpart to [HTTPAuthorizationEvent]. Verifies a + * NIP-98 `Authorization: Nostr ` header. * * NIP-98 reuses kind 27235 events with `u`, `method`, and (for bodies) - * `payload` tags. The relay must check: + * `payload` tags. Verification checks: * 1. Header is `Nostr `. * 2. Decoded body is a kind-27235 event with a valid Schnorr signature. - * 3. The event's `created_at` is within ±60 s of now (NIP-98 spec). + * 3. The event's `created_at` is within ±[toleranceSeconds] of now. * 4. The `method` tag matches the HTTP method. * 5. The `u` tag matches the requested URL. * 6. If a body is present, the `payload` tag matches `sha256(body)` hex. * - * Returns the verified pubkey on success; `null` on any failure (the - * caller turns this into a `401 Unauthorized`). + * Returns the verified pubkey on success; a [Result.Malformed] / + * [Result.Missing] otherwise (the caller turns these into 401/403). */ class Nip98AuthVerifier( private val now: () -> Long = { TimeUtils.now() }, @@ -57,16 +59,19 @@ class Nip98AuthVerifier( * `2 × toleranceSeconds` (twice the accepted window so a token * can't be reused by an attacker who buffers across the boundary). * - * `synchronized` access is sufficient — the table is small (~hundreds - * of entries at most) and admin RPC traffic is low-rate. + * Guarded by [seenLock] so the eviction sweep + insertion are + * atomic. We use a coroutine [Mutex] so the type works in KMP + * commonMain (no `synchronized` block). */ private val seenEventIds: LinkedHashMap = object : LinkedHashMap(64, 0.75f, true) { override fun removeEldestEntry(eldest: Map.Entry?): Boolean = size > MAX_REPLAY_ENTRIES } + private val seenLock = Mutex() + @OptIn(ExperimentalEncodingApi::class) - fun verify( + suspend fun verify( authorizationHeader: String?, method: String, url: String, @@ -128,8 +133,12 @@ class Nip98AuthVerifier( // Replay check — done LAST so we don't burn a one-shot id on a // request that would otherwise have failed signature/url/etc. val expiry = nowSec + 2 * toleranceSeconds - synchronized(seenEventIds) { - // Evict expired entries while we hold the lock. + seenLock.withLock { + // Evict expired entries while we hold the lock. Insertion + // order (LinkedHashMap default) tracks expiry order + // because every entry's expiry = now + 2·tolerance, so + // the first non-expired entry guarantees no later entry + // is expired either. val it = seenEventIds.entries.iterator() while (it.hasNext()) { if (it.next().value <= nowSec) it.remove() else break diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/BanStoreTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt similarity index 98% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/BanStoreTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt index 3b85c82aaa..66bfdb0f29 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/BanStoreTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay.admin +package com.vitorpamplona.quartz.nip86RelayManagement.server import kotlin.test.Test import kotlin.test.assertEquals diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86ServerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt similarity index 86% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86ServerTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt index 284470dd28..9911f13552 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86ServerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt @@ -18,16 +18,14 @@ * 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.admin +package com.vitorpamplona.quartz.nip86RelayManagement.server -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request -import com.vitorpamplona.quartz.relay.RelayInfo import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonPrimitive @@ -43,18 +41,17 @@ import kotlin.test.assertTrue class Nip86ServerTest { private fun fixture(): Triple { val store = BanStore() - val holder = - Holder(RelayInfo(Nip11RelayInformation(name = "before", description = "before-desc"))) + val holder = Holder(Nip11RelayInformation(name = "before", description = "before-desc")) val server = Nip86Server(banStore = store, infoHolder = holder, store = null) return Triple(server, store, holder) } private class Holder( - var current: RelayInfo, + var current: Nip11RelayInformation, ) : Nip86Server.InfoHolder { override fun get() = current - override fun set(info: RelayInfo) { + override fun set(info: Nip11RelayInformation) { current = info } } @@ -62,10 +59,9 @@ class Nip86ServerTest { private val pk = "a".repeat(64) private val pk2 = "b".repeat(64) private val eventId = "c".repeat(64) - private val relayUrl = RelayUrlNormalizer.normalize("ws://test/") @Test - fun supportedMethodsRoundTrip() = + fun supportedMethodsRoundTrip() { runBlocking { val (server, _, _) = fixture() val resp = server.dispatch(Nip86Request.supportedMethods()) @@ -76,9 +72,10 @@ class Nip86ServerTest { assertTrue(Nip86Method.BAN_PUBKEY in names) assertTrue(Nip86Method.CHANGE_RELAY_NAME in names) } + } @Test - fun banPubkeyMutatesStoreAndListsRoundTripWithReason() = + fun banPubkeyMutatesStoreAndListsRoundTripWithReason() { runBlocking { val (server, banStore, _) = fixture() @@ -100,9 +97,10 @@ class Nip86ServerTest { server.dispatch(Nip86Request.unbanPubkey(pk)) assertTrue(banStore.listBannedPubkeys().isEmpty()) } + } @Test - fun allowPubkeyAndListRoundTrip() = + fun allowPubkeyAndListRoundTrip() { runBlocking { val (server, banStore, _) = fixture() server.dispatch(Nip86Request.allowPubkey(pk, "trusted")) @@ -119,9 +117,10 @@ class Nip86ServerTest { assertEquals(2, list.size) assertEquals(setOf(pk, pk2), list.map { it.pubkey }.toSet()) } + } @Test - fun banEventMarksIdAndDeletesFromStoreWhenStorePresent() = + fun banEventMarksIdAndDeletesFromStoreWhenStorePresent() { runBlocking { val (server, banStore, _) = fixture() server.dispatch(Nip86Request.banEvent(eventId, "off-topic")) @@ -142,9 +141,10 @@ class Nip86ServerTest { server.dispatch(Nip86Request.allowEvent(eventId)) assertTrue(banStore.listBannedEvents().isEmpty()) } + } @Test - fun allowKindAndDisallowKind() = + fun allowKindAndDisallowKind() { runBlocking { val (server, banStore, _) = fixture() server.dispatch(Nip86Request.allowKind(1)) @@ -160,34 +160,37 @@ class Nip86ServerTest { assertEquals(false, banStore.isKindAllowed(4)) assertEquals(false, banStore.isKindAllowed(99)) } + } @Test - fun changeRelayNameDescriptionIconRewriteInfoDoc() = + fun changeRelayNameDescriptionIconRewriteInfoDoc() { runBlocking { val (server, _, holder) = fixture() - assertEquals("before", holder.current.document.name) + assertEquals("before", holder.current.name) server.dispatch(Nip86Request.changeRelayName("after")) - assertEquals("after", holder.current.document.name) + assertEquals("after", holder.current.name) server.dispatch(Nip86Request.changeRelayDescription("nice relay")) - assertEquals("nice relay", holder.current.document.description) + assertEquals("nice relay", holder.current.description) server.dispatch(Nip86Request.changeRelayIcon("https://x/icon.png")) - assertEquals("https://x/icon.png", holder.current.document.icon) + assertEquals("https://x/icon.png", holder.current.icon) } + } @Test - fun unsupportedMethodReturnsError() = + fun unsupportedMethodReturnsError() { runBlocking { val (server, _, _) = fixture() val resp = server.dispatch(Nip86Request(method = "frobnicate")) assertNotNull(resp.error) assertTrue(resp.error!!.contains("frobnicate")) } + } @Test - fun missingParamsAreReportedAsErrors() = + fun missingParamsAreReportedAsErrors() { runBlocking { val (server, _, _) = fixture() // banpubkey requires at least one positional param. @@ -195,4 +198,5 @@ class Nip86ServerTest { assertNotNull(resp.error) assertTrue(resp.error!!.startsWith("invalid params")) } + } } diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifierTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifierTest.kt similarity index 51% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifierTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifierTest.kt index 50aabd6ca3..2323410d5b 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifierTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifierTest.kt @@ -18,11 +18,10 @@ * 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.admin +package com.vitorpamplona.quartz.nip98HttpAuth import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -56,66 +55,80 @@ class Nip98AuthVerifierTest { @Test fun missingHeaderReturnsMissing() { - val r = verifier.verify(null, "POST", "http://x/", null) - assertIs(r) + runBlocking { + val r = verifier.verify(null, "POST", "http://x/", null) + assertIs(r) + } } @Test fun wrongSchemeIsMalformed() { - val r = verifier.verify("Bearer abc", "POST", "http://x/", null) - assertIs(r) - assertTrue(r.reason.contains("Nostr")) + runBlocking { + val r = verifier.verify("Bearer abc", "POST", "http://x/", null) + assertIs(r) + assertTrue(r.reason.contains("Nostr")) + } } @Test fun urlMismatchIsMalformed() { - val (_, header) = signedToken("http://x/", "POST") - val r = verifier.verify(header, "POST", "http://y/", null) - assertIs(r) - assertTrue(r.reason.contains("url mismatch")) + runBlocking { + val (_, header) = signedToken("http://x/", "POST") + val r = verifier.verify(header, "POST", "http://y/", null) + assertIs(r) + assertTrue(r.reason.contains("url mismatch")) + } } @Test fun methodMismatchIsMalformed() { - val (_, header) = signedToken("http://x/", "POST") - val r = verifier.verify(header, "GET", "http://x/", null) - assertIs(r) - assertTrue(r.reason.contains("method mismatch")) + runBlocking { + val (_, header) = signedToken("http://x/", "POST") + val r = verifier.verify(header, "GET", "http://x/", null) + assertIs(r) + assertTrue(r.reason.contains("method mismatch")) + } } @Test fun payloadHashMismatchIsMalformed() { - val (_, header) = signedToken("http://x/", "POST", "alpha".encodeToByteArray()) - val r = verifier.verify(header, "POST", "http://x/", "beta".encodeToByteArray()) - assertIs(r) - assertTrue(r.reason.contains("payload hash")) + runBlocking { + val (_, header) = signedToken("http://x/", "POST", "alpha".encodeToByteArray()) + val r = verifier.verify(header, "POST", "http://x/", "beta".encodeToByteArray()) + assertIs(r) + assertTrue(r.reason.contains("payload hash")) + } } @Test fun staleCreatedAtIsMalformed() { - // Verifier's clock is fixed at 1_000; sign a token created 5 - // minutes earlier — outside the 60s tolerance. - val (_, header) = signedToken("http://x/", "POST", createdAt = 1_000L - 600) - val r = verifier.verify(header, "POST", "http://x/", null) - assertIs(r) - assertTrue(r.reason.contains("created_at")) + runBlocking { + // Verifier's clock is fixed at 1_000; sign a token created 5 + // minutes earlier — outside the 60s tolerance. + val (_, header) = signedToken("http://x/", "POST", createdAt = 1_000L - 600) + val r = verifier.verify(header, "POST", "http://x/", null) + assertIs(r) + assertTrue(r.reason.contains("created_at")) + } } @Test fun nonAuthEventKindIsMalformed() { - // Build a kind-1 event by hand and shove it into the header — it - // must be rejected because NIP-98 specifically uses kind 27235. - val signer = NostrSignerSync(KeyPair()) - val template = - com.vitorpamplona.quartz.nip10Notes.TextNoteEvent - .build("not an auth event") - val signed = signer.sign(template) - val token = - "Nostr " + - kotlin.io.encoding.Base64 - .encode(signed.toJson().encodeToByteArray()) - val r = verifier.verify(token, "POST", "http://x/", null) - assertIs(r) - assertTrue(r.reason.contains("kind")) + runBlocking { + // Build a kind-1 event by hand and shove it into the header — it + // must be rejected because NIP-98 specifically uses kind 27235. + val signer = NostrSignerSync(KeyPair()) + val template = + com.vitorpamplona.quartz.nip10Notes.TextNoteEvent + .build("not an auth event") + val signed = signer.sign(template) + val token = + "Nostr " + + kotlin.io.encoding.Base64 + .encode(signed.toJson().encodeToByteArray()) + val r = verifier.verify(token, "POST", "http://x/", null) + assertIs(r) + assertTrue(r.reason.contains("kind")) + } } } From c2f24a5213c3918273ef03c8c8d7d7c86846c880 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 13:12:34 +0000 Subject: [PATCH 15/17] refactor: rename quartz-relay module to geode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay implementation now stands as its own module. Fitting brand for a Nostr relay shipped alongside the Quartz library — a geode is a rock that holds quartz inside. - Rename module directory quartz-relay/ -> geode/. - Rename Gradle module path :quartz-relay -> :geode (settings.gradle). - Rename Kotlin package com.vitorpamplona.quartz.relay -> com.vitorpamplona.geode. - Rename application name + main class binding to match. - Update the relay's NIP-11 advertised name to "geode" and the software URL to /tree/main/geode. Test asserting the doc updated. - Refresh comments / Main.kt usage line / config.example.toml header. - Update consumers: quartz/build.gradle.kts dependency path, and quartz NostrClient tests that import the in-process RelayHub. --- {quartz-relay => geode}/build.gradle.kts | 4 ++-- {quartz-relay => geode}/config.example.toml | 12 ++++++------ .../com/vitorpamplona/geode}/LocalRelayServer.kt | 6 +++--- .../src/main/kotlin/com/vitorpamplona/geode}/Main.kt | 10 +++++----- .../main/kotlin/com/vitorpamplona/geode}/Relay.kt | 8 ++++---- .../main/kotlin/com/vitorpamplona/geode}/RelayHub.kt | 2 +- .../kotlin/com/vitorpamplona/geode}/RelayInfo.kt | 8 ++++---- .../com/vitorpamplona/geode}/config/RelayConfig.kt | 8 ++++---- .../vitorpamplona/geode}/fixtures/RelayFixtures.kt | 2 +- .../vitorpamplona/geode}/fixtures/SyntheticEvents.kt | 2 +- .../geode}/persistence/RelayStateStore.kt | 2 +- .../vitorpamplona/geode}/server/Nip86HttpRoute.kt | 2 +- .../geode}/server/WebSocketSessionPump.kt | 2 +- .../com/vitorpamplona/geode}/GracefulShutdownTest.kt | 2 +- .../com/vitorpamplona/geode}/LocalRelayServerTest.kt | 6 +++--- .../com/vitorpamplona/geode}/Nip01ComplianceTest.kt | 4 ++-- .../com/vitorpamplona/geode}/Nip09DeletionTest.kt | 2 +- .../com/vitorpamplona/geode}/Nip40ExpirationTest.kt | 2 +- .../com/vitorpamplona/geode}/Nip62VanishTest.kt | 2 +- .../com/vitorpamplona/geode}/Nip77NegentropyTest.kt | 2 +- .../vitorpamplona/geode}/admin/Nip86EndToEndTest.kt | 6 +++--- .../vitorpamplona/geode}/config/RelayConfigTest.kt | 4 ++-- .../com/vitorpamplona/geode}/perf/LoadBenchmark.kt | 6 +++--- .../geode}/persistence/PersistenceTest.kt | 4 ++-- .../geode}/policies/PoliciesIntegrationTest.kt | 6 +++--- .../vitorpamplona/geode}/policies/PoliciesTest.kt | 4 ++-- quartz/build.gradle.kts | 10 +++++----- .../quartz/nip01Core/relay/BaseNostrClientTest.kt | 2 +- .../nip01Core/relay/NostrClientManualSubTest.kt | 2 +- .../nip01Core/relay/NostrClientQueryCountTest.kt | 2 +- .../nip01Core/relay/NostrClientRepeatSubTest.kt | 2 +- .../relay/NostrClientReqBypassingRelayLimitsTest.kt | 2 +- .../relay/NostrClientSubscriptionAsFlowTest.kt | 2 +- .../nip01Core/relay/NostrClientSubscriptionTest.kt | 2 +- .../NostrClientSubscriptionUntilEoseAsFlowTest.kt | 2 +- settings.gradle | 2 +- 36 files changed, 73 insertions(+), 73 deletions(-) rename {quartz-relay => geode}/build.gradle.kts (95%) rename {quartz-relay => geode}/config.example.toml (90%) rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay => geode/src/main/kotlin/com/vitorpamplona/geode}/LocalRelayServer.kt (98%) rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay => geode/src/main/kotlin/com/vitorpamplona/geode}/Main.kt (96%) rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay => geode/src/main/kotlin/com/vitorpamplona/geode}/Relay.kt (97%) rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay => geode/src/main/kotlin/com/vitorpamplona/geode}/RelayHub.kt (98%) rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay => geode/src/main/kotlin/com/vitorpamplona/geode}/RelayInfo.kt (94%) rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay => geode/src/main/kotlin/com/vitorpamplona/geode}/config/RelayConfig.kt (96%) rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay => geode/src/main/kotlin/com/vitorpamplona/geode}/fixtures/RelayFixtures.kt (98%) rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay => geode/src/main/kotlin/com/vitorpamplona/geode}/fixtures/SyntheticEvents.kt (98%) rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay => geode/src/main/kotlin/com/vitorpamplona/geode}/persistence/RelayStateStore.kt (98%) rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay => geode/src/main/kotlin/com/vitorpamplona/geode}/server/Nip86HttpRoute.kt (99%) rename {quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay => geode/src/main/kotlin/com/vitorpamplona/geode}/server/WebSocketSessionPump.kt (99%) rename {quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay => geode/src/test/kotlin/com/vitorpamplona/geode}/GracefulShutdownTest.kt (99%) rename {quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay => geode/src/test/kotlin/com/vitorpamplona/geode}/LocalRelayServerTest.kt (99%) rename {quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay => geode/src/test/kotlin/com/vitorpamplona/geode}/Nip01ComplianceTest.kt (99%) rename {quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay => geode/src/test/kotlin/com/vitorpamplona/geode}/Nip09DeletionTest.kt (99%) rename {quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay => geode/src/test/kotlin/com/vitorpamplona/geode}/Nip40ExpirationTest.kt (99%) rename {quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay => geode/src/test/kotlin/com/vitorpamplona/geode}/Nip62VanishTest.kt (99%) rename {quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay => geode/src/test/kotlin/com/vitorpamplona/geode}/Nip77NegentropyTest.kt (99%) rename {quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay => geode/src/test/kotlin/com/vitorpamplona/geode}/admin/Nip86EndToEndTest.kt (98%) rename {quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay => geode/src/test/kotlin/com/vitorpamplona/geode}/config/RelayConfigTest.kt (98%) rename {quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay => geode/src/test/kotlin/com/vitorpamplona/geode}/perf/LoadBenchmark.kt (99%) rename {quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay => geode/src/test/kotlin/com/vitorpamplona/geode}/persistence/PersistenceTest.kt (98%) rename {quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay => geode/src/test/kotlin/com/vitorpamplona/geode}/policies/PoliciesIntegrationTest.kt (97%) rename {quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay => geode/src/test/kotlin/com/vitorpamplona/geode}/policies/PoliciesTest.kt (98%) diff --git a/quartz-relay/build.gradle.kts b/geode/build.gradle.kts similarity index 95% rename from quartz-relay/build.gradle.kts rename to geode/build.gradle.kts index 8b7dd95b6d..8d2f2dc898 100644 --- a/quartz-relay/build.gradle.kts +++ b/geode/build.gradle.kts @@ -7,8 +7,8 @@ plugins { } application { - mainClass.set("com.vitorpamplona.quartz.relay.MainKt") - applicationName = "quartz-relay" + mainClass.set("com.vitorpamplona.geode.MainKt") + applicationName = "geode" } kotlin { diff --git a/quartz-relay/config.example.toml b/geode/config.example.toml similarity index 90% rename from quartz-relay/config.example.toml rename to geode/config.example.toml index 0729446c70..5835a55278 100644 --- a/quartz-relay/config.example.toml +++ b/geode/config.example.toml @@ -1,8 +1,8 @@ -# Example config for quartz-relay. Section layout mirrors +# Example config for geode. 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" +# ./gradlew :geode:run --args="--config /etc/geode.toml" # # CLI flags override individual values: e.g. `--port 8888` wins over # `[network].port`. @@ -12,8 +12,8 @@ # 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." +name = "Example Geode" +description = "A geode deployment." contact = "admin@example.com" # Operator pubkey (NIP-11). Optional. # pubkey = "..." @@ -30,7 +30,7 @@ path = "/" # 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" +file = "/var/lib/geode/events.db" [options] # Drop events whose Schnorr signature does not verify. Strongly @@ -79,4 +79,4 @@ require_auth = false # lists + the live NIP-11 doc) across restarts. When unset, admin # state is in-memory only and forgotten on every restart. Convention # is to place this next to the SQLite event-store file. -# state_file = "/var/lib/quartz-relay/events.db.admin.json" +# state_file = "/var/lib/geode/events.db.admin.json" diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/LocalRelayServer.kt similarity index 98% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt rename to geode/src/main/kotlin/com/vitorpamplona/geode/LocalRelayServer.kt index 06262048ee..3840bf4823 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/LocalRelayServer.kt @@ -18,16 +18,16 @@ * 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 +package com.vitorpamplona.geode +import com.vitorpamplona.geode.server.Nip86HttpRoute +import com.vitorpamplona.geode.server.WebSocketSessionPump import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier -import com.vitorpamplona.quartz.relay.server.Nip86HttpRoute -import com.vitorpamplona.quartz.relay.server.WebSocketSessionPump import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt similarity index 96% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt rename to geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 74203f4f36..228c407094 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -18,8 +18,9 @@ * 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 +package com.vitorpamplona.geode +import com.vitorpamplona.geode.config.RelayConfig import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy @@ -30,16 +31,15 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEven 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 --args="--config /etc/quartz-relay.toml" + * ./gradlew :geode:run --args="--config /etc/geode.toml" * or - * java -cp ... com.vitorpamplona.quartz.relay.MainKt --port 7447 --verify + * java -cp ... com.vitorpamplona.geode.MainKt --port 7447 --verify * * Configuration precedence (highest to lowest): * 1. CLI flags (`--host`, `--port`, …) @@ -128,7 +128,7 @@ fun main(args: Array) { }, ) - println("quartz-relay listening on ${server.url}") + println("geode listening on ${server.url}") println("NIP-11 info doc: curl -H 'Accept: application/nostr+json' http://$advertisedHost:$port$path") // Park the main thread; shutdown hook handles teardown. diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Relay.kt similarity index 97% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt rename to geode/src/main/kotlin/com/vitorpamplona/geode/Relay.kt index 2c17cfaf9c..48c339f4ee 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Relay.kt @@ -18,8 +18,11 @@ * 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 +package com.vitorpamplona.geode +import com.vitorpamplona.geode.persistence.BannedEntry +import com.vitorpamplona.geode.persistence.RelayPersistedState +import com.vitorpamplona.geode.persistence.RelayStateStore import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd @@ -32,9 +35,6 @@ import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip86RelayManagement.server.BanListPolicy import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore -import com.vitorpamplona.quartz.relay.persistence.BannedEntry -import com.vitorpamplona.quartz.relay.persistence.RelayPersistedState -import com.vitorpamplona.quartz.relay.persistence.RelayStateStore import kotlinx.coroutines.SupervisorJob import java.io.File import kotlin.coroutines.CoroutineContext diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayHub.kt similarity index 98% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt rename to geode/src/main/kotlin/com/vitorpamplona/geode/RelayHub.kt index 0f67e82a0e..a5cdccd009 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayHub.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay +package com.vitorpamplona.geode import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt similarity index 94% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt rename to geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt index 34943a7b13..bd2d9c55e8 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayInfo.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay +package com.vitorpamplona.geode import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -38,14 +38,14 @@ data class RelayInfo( val json: String by lazy { JsonMapper.toJson(document) } companion object { - const val NAME = "quartz-relay" + const val NAME = "geode" const val DESCRIPTION = "Embedded Nostr relay from the Amethyst quartz library." - const val SOFTWARE = "https://github.com/vitorpamplona/amethyst/tree/main/quartz-relay" + const val SOFTWARE = "https://github.com/vitorpamplona/amethyst/tree/main/geode" const val VERSION = "1.08.0" /** * NIPs this relay implements out of the box. Single source of - * truth — both [default] and [com.vitorpamplona.quartz.relay.config.RelayConfig.resolveInfo] + * truth — both [default] and [com.vitorpamplona.geode.config.RelayConfig.resolveInfo] * consult this list. Add a NIP here when its handler is wired * into [com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession] * (or in this module's policy stack). diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayConfig.kt similarity index 96% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfig.kt rename to geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayConfig.kt index e1f6d83b39..b1b99634dc 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayConfig.kt @@ -18,14 +18,14 @@ * 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 +package com.vitorpamplona.geode.config import cc.ekblad.toml.decode import cc.ekblad.toml.tomlMapper +import com.vitorpamplona.geode.RelayInfo 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 /** @@ -154,8 +154,8 @@ data class RelayConfig( * unset, admin state is in-memory only. * * Convention: place next to the SQLite event-store file — - * e.g. `[database].file = "/var/lib/quartz-relay/events.db"` - * pairs with `[admin].state_file = "/var/lib/quartz-relay/events.db.admin.json"`. + * e.g. `[database].file = "/var/lib/geode/events.db"` + * pairs with `[admin].state_file = "/var/lib/geode/events.db.admin.json"`. */ val state_file: String? = null, ) diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/RelayFixtures.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt similarity index 98% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/RelayFixtures.kt rename to geode/src/main/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt index 2ff341ea1c..7384be0c4a 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/RelayFixtures.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay.fixtures +package com.vitorpamplona.geode.fixtures import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/SyntheticEvents.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/fixtures/SyntheticEvents.kt similarity index 98% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/SyntheticEvents.kt rename to geode/src/main/kotlin/com/vitorpamplona/geode/fixtures/SyntheticEvents.kt index 831c73c3e1..9e73653580 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/fixtures/SyntheticEvents.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/fixtures/SyntheticEvents.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay.fixtures +package com.vitorpamplona.geode.fixtures import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/persistence/RelayStateStore.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/persistence/RelayStateStore.kt similarity index 98% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/persistence/RelayStateStore.kt rename to geode/src/main/kotlin/com/vitorpamplona/geode/persistence/RelayStateStore.kt index bb18dd806e..7bb7f0d496 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/persistence/RelayStateStore.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/persistence/RelayStateStore.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay.persistence +package com.vitorpamplona.geode.persistence import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import kotlinx.serialization.Serializable diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/Nip86HttpRoute.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt similarity index 99% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/Nip86HttpRoute.kt rename to geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt index 43f89e837d..3e1e3534ee 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/Nip86HttpRoute.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay.server +package com.vitorpamplona.geode.server import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/WebSocketSessionPump.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/WebSocketSessionPump.kt similarity index 99% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/WebSocketSessionPump.kt rename to geode/src/main/kotlin/com/vitorpamplona/geode/server/WebSocketSessionPump.kt index 952e6131f5..7cd700a33f 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/WebSocketSessionPump.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/WebSocketSessionPump.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay.server +package com.vitorpamplona.geode.server import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/GracefulShutdownTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/GracefulShutdownTest.kt similarity index 99% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/GracefulShutdownTest.kt rename to geode/src/test/kotlin/com/vitorpamplona/geode/GracefulShutdownTest.kt index 5a3021cbbe..04d55a660f 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/GracefulShutdownTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/GracefulShutdownTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay +package com.vitorpamplona.geode import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/LocalRelayServerTest.kt similarity index 99% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt rename to geode/src/test/kotlin/com/vitorpamplona/geode/LocalRelayServerTest.kt index 5a96efe46d..6489e794f7 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServerTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/LocalRelayServerTest.kt @@ -18,8 +18,9 @@ * 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 +package com.vitorpamplona.geode +import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient @@ -32,7 +33,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -132,7 +132,7 @@ class LocalRelayServerTest { assertEquals(200, it.code) val body = it.body.string() val info = Nip11RelayInformation.fromJson(body) - assertEquals("quartz-relay", info.name) + assertEquals("geode", info.name) assertTrue(info.supported_nips!!.contains("11"), "NIP-11 must be advertised") assertTrue(info.supported_nips!!.contains("1"), "NIP-01 must be advertised") } diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt similarity index 99% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt rename to geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt index b6e1398b23..5f69577ea9 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip01ComplianceTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt @@ -18,8 +18,9 @@ * 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 +package com.vitorpamplona.geode +import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient @@ -29,7 +30,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip09DeletionTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip09DeletionTest.kt similarity index 99% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip09DeletionTest.kt rename to geode/src/test/kotlin/com/vitorpamplona/geode/Nip09DeletionTest.kt index 5bd0a1c770..d721d6686d 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip09DeletionTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip09DeletionTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay +package com.vitorpamplona.geode import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip40ExpirationTest.kt similarity index 99% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt rename to geode/src/test/kotlin/com/vitorpamplona/geode/Nip40ExpirationTest.kt index 6c538241ab..a4dc6c3ef8 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip40ExpirationTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay +package com.vitorpamplona.geode import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip62VanishTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip62VanishTest.kt similarity index 99% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip62VanishTest.kt rename to geode/src/test/kotlin/com/vitorpamplona/geode/Nip62VanishTest.kt index 201f312c9c..620c88a916 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip62VanishTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip62VanishTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay +package com.vitorpamplona.geode import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip77NegentropyTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip77NegentropyTest.kt similarity index 99% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip77NegentropyTest.kt rename to geode/src/test/kotlin/com/vitorpamplona/geode/Nip77NegentropyTest.kt index a66a300001..d399457b02 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip77NegentropyTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip77NegentropyTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay +package com.vitorpamplona.geode import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt similarity index 98% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt rename to geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt index 9659646770..f83ab0d850 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt @@ -18,8 +18,10 @@ * 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.admin +package com.vitorpamplona.geode.admin +import com.vitorpamplona.geode.LocalRelayServer +import com.vitorpamplona.geode.Relay import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient @@ -31,8 +33,6 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent -import com.vitorpamplona.quartz.relay.LocalRelayServer -import com.vitorpamplona.quartz.relay.Relay import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfigTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/config/RelayConfigTest.kt similarity index 98% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfigTest.kt rename to geode/src/test/kotlin/com/vitorpamplona/geode/config/RelayConfigTest.kt index 77b52c0984..166a380508 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfigTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/config/RelayConfigTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.relay.config +package com.vitorpamplona.geode.config import java.io.File import kotlin.test.Test @@ -129,7 +129,7 @@ class RelayConfigTest { val candidates = listOf( File("config.example.toml"), - File("quartz-relay/config.example.toml"), + File("geode/config.example.toml"), ) val example = candidates.firstOrNull { it.exists() } diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/perf/LoadBenchmark.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/perf/LoadBenchmark.kt similarity index 99% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/perf/LoadBenchmark.kt rename to geode/src/test/kotlin/com/vitorpamplona/geode/perf/LoadBenchmark.kt index 4b658bfd0d..a083dd8125 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/perf/LoadBenchmark.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/perf/LoadBenchmark.kt @@ -18,8 +18,10 @@ * 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.perf +package com.vitorpamplona.geode.perf +import com.vitorpamplona.geode.LocalRelayServer +import com.vitorpamplona.geode.Relay import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm @@ -30,8 +32,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.relay.LocalRelayServer -import com.vitorpamplona.quartz.relay.Relay import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/persistence/PersistenceTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/persistence/PersistenceTest.kt similarity index 98% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/persistence/PersistenceTest.kt rename to geode/src/test/kotlin/com/vitorpamplona/geode/persistence/PersistenceTest.kt index 700c1145e6..cf6eacdaa9 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/persistence/PersistenceTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/persistence/PersistenceTest.kt @@ -18,11 +18,11 @@ * 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.persistence +package com.vitorpamplona.geode.persistence +import com.vitorpamplona.geode.Relay import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import com.vitorpamplona.quartz.relay.Relay import java.io.File import java.nio.file.Files import kotlin.test.AfterTest diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/policies/PoliciesIntegrationTest.kt similarity index 97% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt rename to geode/src/test/kotlin/com/vitorpamplona/geode/policies/PoliciesIntegrationTest.kt index 2bb4c48157..0236e2a214 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/policies/PoliciesIntegrationTest.kt @@ -18,8 +18,10 @@ * 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.policies +package com.vitorpamplona.geode.policies +import com.vitorpamplona.geode.RelayHub +import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm @@ -31,8 +33,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyP import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.relay.RelayHub -import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/policies/PoliciesTest.kt similarity index 98% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt rename to geode/src/test/kotlin/com/vitorpamplona/geode/policies/PoliciesTest.kt index 5636e837a2..5e0721b9fc 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/policies/PoliciesTest.kt @@ -18,14 +18,14 @@ * 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.policies +package com.vitorpamplona.geode.policies +import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult 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.relay.fixtures.SyntheticEvents import kotlin.test.Test import kotlin.test.assertTrue import kotlin.test.fail diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 723a757f6e..93df4636b9 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -181,11 +181,11 @@ kotlin { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) - // In-process Nostr relay so JVM/Android host tests don't - // need network access or a Rust toolchain. The - // `relay.fixtures` package carries the test-only event - // generators and corpus loader. - implementation(project(":quartz-relay")) + // In-process Nostr relay (geode) so JVM/Android host + // tests don't need network access or a Rust toolchain. + // The `geode.fixtures` package carries the test-only + // event generators and corpus loader. + implementation(project(":geode")) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt index e8133fe080..242539bdae 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.relay -import com.vitorpamplona.quartz.relay.RelayHub +import com.vitorpamplona.geode.RelayHub /** * Base for tests that drive a real `NostrClient` against an in-process Nostr diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt index b5e0cfccef..58fc091e80 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt @@ -19,6 +19,7 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay +import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient @@ -26,7 +27,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt index 0b42a60668..0459238ed4 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.quartz.nip01Core.relay +import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl -import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt index a63a3e512b..20d25ec249 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt @@ -19,6 +19,7 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay +import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener @@ -29,7 +30,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt index 6f413e5b65..f6383421c8 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.quartz.nip01Core.relay +import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt index 90538ddf89..99e243d8c5 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt @@ -19,12 +19,12 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay +import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt index 3c8c10bff9..a4dad911d9 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt @@ -19,13 +19,13 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay +import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.StaticSubscription import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt index 752d8d21a9..7ab1e1e278 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt @@ -19,12 +19,12 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay +import com.vitorpamplona.geode.fixtures.SyntheticEvents import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/settings.gradle b/settings.gradle index 74317ce69d..6d88537ff4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,7 +34,7 @@ rootProject.name = "Amethyst" include ':amethyst' include ':benchmark' include ':quartz' -include ':quartz-relay' +include ':geode' include ':commons' include ':ammolite' include ':quic' From c19bd4e92ed6d794ff7b10028ee17d2e61a33d89 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 13:42:20 +0000 Subject: [PATCH 16/17] refactor(geode): test fixtures + collectUntilEose helper, move to testFixtures sourceSet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-process relay was usable but every consumer test paid a 10–15 line tax for scope/client setup, manual cleanup inside the test body (leaks on assertion failure), hardcoded "ws://127.0.0.1:7770/" magic strings, and an inline collectUntilEose pattern reinvented per file. Net: ~250 LOC removed, every test gets correct @After cleanup, and the synthetic event builders no longer ship in geode's production jar. - Move geode.fixtures (synthetic event builders) and the new geode.testing package from src/main to a new src/testFixtures source set via the java-test-fixtures plugin. Production geode jar no longer includes them; consumers wire them with testImplementation(testFixtures(project(":geode"))). - Add geode.testing.RelayClientTest open class — owns hub, scope, client, defaultRelay, defaultRelayUrl with @After-driven cleanup. - Add geode.testing.collectUntilEose / collectUntilEoseMulti NostrClient extensions with a shared subId counter and timeout knob. Replaces hand-rolled subscriber loops in 4+ files. - Add RelayHub.DEFAULT_URL constant so tests stop typing "ws://127.0.0.1:7770/" inline. - Migrate the 8 quartz NostrClient*Test files + geode's Nip01ComplianceTest to extend RelayClientTest and (where REQ/EOSE is the pattern) call collectUntilEose. Delete the redundant BaseNostrClientTest wrapper. --- geode/build.gradle.kts | 17 ++ .../com/vitorpamplona/geode/RelayHub.kt | 12 ++ .../geode/Nip01ComplianceTest.kt | 172 ++++-------------- .../geode/fixtures/RelayFixtures.kt | 0 .../geode/fixtures/SyntheticEvents.kt | 0 .../geode/testing/RelayClientTest.kt | 78 ++++++++ .../geode/testing/SubscriptionTesting.kt | 121 ++++++++++++ quartz/build.gradle.kts | 15 +- .../nip01Core/relay/BaseNostrClientTest.kt | 40 ---- .../relay/NostrClientFirstEventTest.kt | 26 +-- .../relay/NostrClientManualSubTest.kt | 40 +--- .../relay/NostrClientQueryCountTest.kt | 39 +--- .../relay/NostrClientRepeatSubTest.kt | 62 +------ .../NostrClientReqBypassingRelayLimitsTest.kt | 51 +----- .../relay/NostrClientSendAndWaitTest.kt | 30 +-- .../NostrClientSubscriptionAsFlowTest.kt | 50 ++--- .../relay/NostrClientSubscriptionTest.kt | 31 +--- ...trClientSubscriptionUntilEoseAsFlowTest.kt | 51 ++---- 18 files changed, 342 insertions(+), 493 deletions(-) rename geode/src/{main => testFixtures}/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt (100%) rename geode/src/{main => testFixtures}/kotlin/com/vitorpamplona/geode/fixtures/SyntheticEvents.kt (100%) create mode 100644 geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/RelayClientTest.kt create mode 100644 geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/SubscriptionTesting.kt delete mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt diff --git a/geode/build.gradle.kts b/geode/build.gradle.kts index 8d2f2dc898..3b05db1cf2 100644 --- a/geode/build.gradle.kts +++ b/geode/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.jetbrainsKotlinJvm) alias(libs.plugins.serialization) application + `java-test-fixtures` } application { @@ -25,6 +26,15 @@ sourceSets { test { kotlin.srcDir("src/test/kotlin") } + // The `java-test-fixtures` plugin auto-creates a `testFixtures` + // source set; we just point it at our Kotlin layout so the + // `geode.fixtures` (synthetic events) and `geode.testing` + // (RelayClientTest, collectUntilEose) packages don't ship in + // production jars but are still usable by every consumer's test + // source via `testImplementation(testFixtures(project(":geode")))`. + named("testFixtures") { + kotlin.srcDir("src/testFixtures/kotlin") + } } tasks.withType().configureEach { @@ -63,6 +73,13 @@ dependencies { // port their configs nearly verbatim. implementation(libs.fourkoma) + // testFixtures: code in src/testFixtures/kotlin (RelayClientTest + + // synthetic event builders). Not shipped in the production jar but + // exposed to consumers via testImplementation(testFixtures(...)). + testFixturesApi(project(":quartz")) + testFixturesApi(libs.junit) + testFixturesImplementation(libs.kotlinx.coroutines.core) + testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.secp256k1.kmp.jni.jvm) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayHub.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayHub.kt index a5cdccd009..6a6866306e 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayHub.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayHub.kt @@ -86,4 +86,16 @@ class RelayHub( relays.values.forEach { runCatching { it.close() } } relays.clear() } + + companion object { + /** + * Default URL for tests that only need one relay. The URL itself + * has no semantic meaning — it's just a stable key into the hub + * — but it normalises through [RelayUrlNormalizer] (loopback) so + * the production [com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer] + * accepts it. Prefer this over typing `"ws://127.0.0.1:7770/"` + * everywhere. + */ + val DEFAULT_URL: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") + } } diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt index 5f69577ea9..7a5a865cc8 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt @@ -21,26 +21,22 @@ package com.vitorpamplona.geode import com.vitorpamplona.geode.fixtures.SyntheticEvents +import com.vitorpamplona.geode.testing.RelayClientTest +import com.vitorpamplona.geode.testing.collectUntilEose +import com.vitorpamplona.geode.testing.collectUntilEoseMulti import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull -import kotlin.test.AfterTest -import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -62,29 +58,9 @@ import kotlin.test.assertTrue * spec-compliant relay (nostr-rs-relay, strfry, khatru, …) — only the * `socketBuilder` and the relay URL would change. */ -class Nip01ComplianceTest { - private lateinit var hub: RelayHub - private lateinit var scope: CoroutineScope - private lateinit var client: NostrClient - - private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") - - @BeforeTest - fun setup() { - hub = RelayHub() - scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - client = NostrClient(hub, scope) - } - - @AfterTest - fun teardown() { - client.disconnect() - scope.cancel() - hub.close() - } - +class Nip01ComplianceTest : RelayClientTest() { private suspend fun preload(vararg events: Event) { - hub.getOrCreate(relayUrl).preload(*events) + defaultRelay.preload(*events) } private fun fakeEvent( @@ -108,7 +84,7 @@ class Nip01ComplianceTest { fakeEvent(3, kind = 1), ) - val (events, eose) = collectUntilEose(Filter(kinds = listOf(1))) + val (events, eose) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(1))) assertEquals(2, events.size) assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet()) @@ -126,7 +102,7 @@ class Nip01ComplianceTest { fakeEvent(4, kind = 1, createdAt = 400), ) - val (events, _) = collectUntilEose(Filter(kinds = listOf(1), limit = 2)) + val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(1), limit = 2)) assertEquals(2, events.size) assertEquals(400L, events[0].createdAt) @@ -145,7 +121,7 @@ class Nip01ComplianceTest { fakeEvent(3, kind = 1, pubKey = alice), ) - val (events, _) = collectUntilEose(Filter(authors = listOf(alice))) + val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(authors = listOf(alice))) assertEquals(2, events.size) assertTrue(events.all { it.pubKey == alice }) @@ -157,7 +133,7 @@ class Nip01ComplianceTest { runBlocking { preload(fakeEvent(1), fakeEvent(2), fakeEvent(3)) - val (events, _) = collectUntilEose(Filter(ids = listOf(SyntheticEvents.hexId(2)))) + val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(ids = listOf(SyntheticEvents.hexId(2)))) assertEquals(1, events.size) assertEquals(SyntheticEvents.hexId(2), events[0].id) @@ -174,7 +150,7 @@ class Nip01ComplianceTest { fakeEvent(4, createdAt = 400), ) - val (events, _) = collectUntilEose(Filter(since = 150L, until = 350L)) + val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(since = 150L, until = 350L)) assertEquals(setOf(200L, 300L), events.map { it.createdAt }.toSet()) } @@ -190,7 +166,7 @@ class Nip01ComplianceTest { fakeEvent(3, tags = arrayOf(arrayOf("e", target), arrayOf("p", SyntheticEvents.hexId(8)))), ) - val (events, _) = collectUntilEose(Filter(tags = mapOf("e" to listOf(target)))) + val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("e" to listOf(target)))) assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet()) } @@ -206,7 +182,7 @@ class Nip01ComplianceTest { fakeEvent(3, tags = arrayOf(arrayOf("p", targetPubkey), arrayOf("e", SyntheticEvents.hexId(99)))), ) - val (events, _) = collectUntilEose(Filter(tags = mapOf("p" to listOf(targetPubkey)))) + val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("p" to listOf(targetPubkey)))) assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet()) } @@ -221,7 +197,7 @@ class Nip01ComplianceTest { fakeEvent(3, tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "kotlin"))), ) - val (events, _) = collectUntilEose(Filter(tags = mapOf("t" to listOf("nostr")))) + val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("t" to listOf("nostr")))) assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet()) } @@ -241,7 +217,7 @@ class Nip01ComplianceTest { fakeEvent(3, tags = arrayOf(arrayOf("e", SyntheticEvents.hexId(999)))), ) - val (events, _) = collectUntilEose(Filter(tags = mapOf("e" to listOf(a, b)))) + val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("e" to listOf(a, b)))) assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(2)), events.map { it.id }.toSet()) } @@ -262,7 +238,8 @@ class Nip01ComplianceTest { ) val (events, _) = - collectUntilEoseMulti( + client.collectUntilEoseMulti( + defaultRelayUrl, listOf( Filter(kinds = listOf(1)), Filter(kinds = listOf(7)), @@ -294,7 +271,7 @@ class Nip01ComplianceTest { client.subscribe( "sub-A", - mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))), object : SubscriptionListener { override fun onEvent( event: Event, @@ -315,7 +292,7 @@ class Nip01ComplianceTest { ) client.subscribe( "sub-B", - mapOf(relayUrl to listOf(Filter(kinds = listOf(4)))), + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(4)))), object : SubscriptionListener { override fun onEvent( event: Event, @@ -362,7 +339,7 @@ class Nip01ComplianceTest { fakeEvent(2, kind = 0, pubKey = pubkey, createdAt = 200, content = "new"), ) - val (events, _) = collectUntilEose(Filter(kinds = listOf(0), authors = listOf(pubkey))) + val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(0), authors = listOf(pubkey))) assertEquals(1, events.size) assertEquals("new", events[0].content) @@ -383,7 +360,11 @@ class Nip01ComplianceTest { val v3 = signer.sign(LongTextNoteEvent.build("list-b", "title", dTag = "list-b", createdAt = 100)) preload(v1, v2, v3) - val (events, _) = collectUntilEose(Filter(kinds = listOf(LongTextNoteEvent.KIND), authors = listOf(signer.pubKey))) + val (events, _) = + client.collectUntilEose( + defaultRelayUrl, + Filter(kinds = listOf(LongTextNoteEvent.KIND), authors = listOf(signer.pubKey)), + ) assertEquals(2, events.size) assertEquals(setOf("new", "list-b"), events.map { it.content }.toSet()) @@ -399,7 +380,7 @@ class Nip01ComplianceTest { val gotEose = Channel(UNLIMITED) client.subscribe( "live-1", - mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))), object : SubscriptionListener { override fun onEvent( event: Event, @@ -423,7 +404,7 @@ class Nip01ComplianceTest { // Inject an event through the wire path (not preload — that bypasses // the live broadcast that subscriptions feed off of). - hub.getOrCreate(relayUrl).publish(fakeEvent(99, kind = 1, content = "live")) + defaultRelay.publish(fakeEvent(99, kind = 1, content = "live")) val received = withTimeout(5000) { ch.receive() } assertEquals("live", received.content) @@ -438,7 +419,7 @@ class Nip01ComplianceTest { val gotEose = Channel(UNLIMITED) client.subscribe( "live-2", - mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))), + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))), object : SubscriptionListener { override fun onEvent( event: Event, @@ -459,7 +440,7 @@ class Nip01ComplianceTest { ) withTimeout(5000) { gotEose.receive() } - hub.getOrCreate(relayUrl).publish(fakeEvent(98, kind = 4, content = "off-topic")) + defaultRelay.publish(fakeEvent(98, kind = 4, content = "off-topic")) val seen = withTimeoutOrNull(500) { ch.receive() } assertNull(seen, "kind 4 should not match a kind-1 subscription") @@ -479,7 +460,7 @@ class Nip01ComplianceTest { val gotEose = Channel(UNLIMITED) client.subscribe( "eph-1", - mapOf(relayUrl to listOf(Filter(kinds = listOf(20_001)))), + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(20_001)))), object : SubscriptionListener { override fun onEvent( event: Event, @@ -500,7 +481,7 @@ class Nip01ComplianceTest { ) withTimeout(5000) { gotEose.receive() } - hub.getOrCreate(relayUrl).publish(fakeEvent(70, kind = 20_001, content = "ephemeral-payload")) + defaultRelay.publish(fakeEvent(70, kind = 20_001, content = "ephemeral-payload")) val received = withTimeout(5000) { ch.receive() } assertEquals("ephemeral-payload", received.content) @@ -516,10 +497,10 @@ class Nip01ComplianceTest { fun ephemeralEventIsNotStoredAndDoesNotShowOnFollowupReq() = runBlocking { // Publish ephemeral first — no live subscriber listening. - hub.getOrCreate(relayUrl).publish(fakeEvent(71, kind = 20_002, content = "vanish")) + defaultRelay.publish(fakeEvent(71, kind = 20_002, content = "vanish")) // Late subscriber: should see EOSE with no events. - val (events, eose) = collectUntilEose(Filter(kinds = listOf(20_002))) + val (events, eose) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(20_002))) assertTrue(eose, "EOSE must fire for an ephemeral kind even if zero events match") assertEquals(0, events.size, "Ephemeral events must not be persisted") } @@ -570,93 +551,4 @@ class Nip01ComplianceTest { assertEquals("from-a", received[relayA]) assertEquals("from-b", received[relayB]) } - - // -- Helpers ------------------------------------------------------------ - - /** Subscribes synchronously and returns the events received before EOSE. */ - private suspend fun collectUntilEose(filter: Filter): Pair, Boolean> { - val ch = Channel(UNLIMITED) - val subId = "sub-${System.nanoTime()}" - client.subscribe( - subId, - mapOf(relayUrl to listOf(filter)), - object : SubscriptionListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - ch.trySend(Either.Ev(event)) - } - - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - ch.trySend(Either.Eose) - } - }, - ) - - val events = mutableListOf() - var eose = false - withTimeout(5000) { - while (!eose) { - when (val msg = ch.receive()) { - is Either.Ev -> events += msg.event - Either.Eose -> eose = true - } - } - } - client.unsubscribe(subId) - return events to eose - } - - /** Variant of [collectUntilEose] that subscribes with multiple filters. */ - private suspend fun collectUntilEoseMulti(filters: List): Pair, Boolean> { - val ch = Channel(UNLIMITED) - val subId = "sub-${System.nanoTime()}" - client.subscribe( - subId, - mapOf(relayUrl to filters), - object : SubscriptionListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - ch.trySend(Either.Ev(event)) - } - - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - ch.trySend(Either.Eose) - } - }, - ) - val events = mutableListOf() - var eose = false - withTimeout(5000) { - while (!eose) { - when (val msg = ch.receive()) { - is Either.Ev -> events += msg.event - Either.Eose -> eose = true - } - } - } - client.unsubscribe(subId) - return events to eose - } - - private sealed interface Either { - data class Ev( - val event: Event, - ) : Either - - object Eose : Either - } } diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt similarity index 100% rename from geode/src/main/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt rename to geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/fixtures/SyntheticEvents.kt b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/SyntheticEvents.kt similarity index 100% rename from geode/src/main/kotlin/com/vitorpamplona/geode/fixtures/SyntheticEvents.kt rename to geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/SyntheticEvents.kt diff --git a/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/RelayClientTest.kt b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/RelayClientTest.kt new file mode 100644 index 0000000000..76112f2b32 --- /dev/null +++ b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/RelayClientTest.kt @@ -0,0 +1,78 @@ +/* + * 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.testing + +import com.vitorpamplona.geode.Relay +import com.vitorpamplona.geode.RelayHub +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After + +/** + * Base class for tests that drive a real [NostrClient] against an + * in-process [RelayHub]. Owns the lifecycle of the four pieces every + * such test needs: + * + * - [hub] — the registry of in-process relays (also serves as + * `WebsocketBuilder` for [NostrClient]). + * - [scope] — application coroutine scope for the client. + * - [client] — a [NostrClient] wired to [hub] and [scope]. + * - [defaultRelay] / [defaultRelayUrl] — convenience handles for the + * single-relay case (the most common in tests). + * + * Cleanup happens in [tearDownRelayClientTest], registered with + * JUnit's [@After][After], so an assertion failure does NOT leak the + * scope, the SQLite event store, or the WebSocket bridge — a recurring + * problem with the previous "clean up at the end of the test body" + * pattern. + * + * Subclasses that need their own setup/teardown should add their own + * `@Before` / `@After` methods; JUnit runs all of them. + * + * Multi-relay tests use [hub] directly: + * ``` + * val relayA = RelayUrlNormalizer.normalize("ws://relay-a/") + * hub.getOrCreate(relayA).preload(eventA) + * hub.getOrCreate(relayB).preload(eventB) + * ``` + */ +open class RelayClientTest { + val hub: RelayHub = RelayHub() + val scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client: NostrClient = NostrClient(hub, scope) + + /** Stable URL for the single-relay case — see [RelayHub.DEFAULT_URL]. */ + val defaultRelayUrl: NormalizedRelayUrl get() = RelayHub.DEFAULT_URL + + /** Lazy handle to the relay at [defaultRelayUrl]. Auto-created on first read. */ + val defaultRelay: Relay get() = hub.getOrCreate(defaultRelayUrl) + + @After + fun tearDownRelayClientTest() { + client.disconnect() + scope.cancel() + hub.close() + } +} diff --git a/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/SubscriptionTesting.kt b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/SubscriptionTesting.kt new file mode 100644 index 0000000000..d2c7ec2d04 --- /dev/null +++ b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/SubscriptionTesting.kt @@ -0,0 +1,121 @@ +/* + * 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.testing + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.withTimeout + +/** Tagged result of [collectUntilEose]: stored events plus whether EOSE actually arrived. */ +data class CollectResult( + val events: List, + val eoseReceived: Boolean, +) + +/** + * Subscribe with [filter] on a single [relay], drain the + * historical-replay phase, and return when EOSE arrives. The + * subscription is closed before this returns. The pattern that 80% of + * REQ-style tests need. + * + * ``` + * val (events, eose) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(1))) + * assertEquals(20, events.size) + * assertTrue(eose) + * ``` + * + * @param timeoutMillis time to wait for EOSE before failing the test. + * Default 5 s — generous for in-process; tighten if needed. + */ +suspend fun NostrClient.collectUntilEose( + relay: NormalizedRelayUrl, + filter: Filter, + timeoutMillis: Long = 5_000, +): CollectResult = collectUntilEoseMulti(relay, listOf(filter), timeoutMillis) + +/** + * Multi-filter variant. NIP-01 allows a REQ to carry several filters + * that the relay OR's together. EOSE fires once after the union of all + * filters has been replayed. + */ +suspend fun NostrClient.collectUntilEoseMulti( + relay: NormalizedRelayUrl, + filters: List, + timeoutMillis: Long = 5_000, +): CollectResult { + val ch = Channel(UNLIMITED) + val subId = "test-sub-${nextSubId()}" + subscribe( + subId, + mapOf(relay to filters), + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Signal.Ev(event)) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Signal.Eose) + } + }, + ) + + val events = mutableListOf() + var eose = false + try { + withTimeout(timeoutMillis) { + while (!eose) { + when (val msg = ch.receive()) { + is Signal.Ev -> events += msg.event + Signal.Eose -> eose = true + } + } + } + } finally { + unsubscribe(subId) + } + return CollectResult(events, eose) +} + +private sealed interface Signal { + data class Ev( + val event: Event, + ) : Signal + + object Eose : Signal +} + +/** Monotonic counter for unique sub-ids inside a JVM. */ +private var subIdSeq: Int = 0 + +private fun nextSubId(): Int = ++subIdSeq diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 93df4636b9..f1b7f6721d 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -183,8 +183,10 @@ kotlin { // In-process Nostr relay (geode) so JVM/Android host // tests don't need network access or a Rust toolchain. - // The `geode.fixtures` package carries the test-only - // event generators and corpus loader. + // testFixtures (RelayClientTest base, fixtures, + // collectUntilEose) are wired below at the top-level + // `dependencies` block — the KMP source-set DSL + // doesn't expose the `testFixtures(...)` consumer. implementation(project(":geode")) } } @@ -347,6 +349,15 @@ kotlin { } } +// testFixtures(...) consumer lives outside the KMP source-set DSL — +// the KMP source-set `dependencies { }` block uses +// `KotlinDependencyHandler`, which does not expose the +// `testFixtures(...)` projection. The standard Gradle dependency +// configuration name (`jvmAndroidTestImplementation`) does work here. +dependencies { + "jvmAndroidTestImplementation"(testFixtures(project(":geode"))) +} + mavenPublishing { // sources publishing is always enabled by the Kotlin Multiplatform plugin configure( diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt deleted file mode 100644 index 242539bdae..0000000000 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt +++ /dev/null @@ -1,40 +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.quartz.nip01Core.relay - -import com.vitorpamplona.geode.RelayHub - -/** - * Base for tests that drive a real `NostrClient` against an in-process Nostr - * relay. Each subclass instance gets its own [RelayHub] so tests can - * preload events and assert deterministic counts without hitting the - * network or relying on production relays. - * - * To replace with the previous behaviour (real OkHttp WebSocket against - * `wss://nos.lol`), instantiate `BasicOkHttpWebSocket.Builder` directly in - * the specific test that needs it. - */ -open class BaseNostrClientTest { - val relayHub: RelayHub = RelayHub() - - /** Plug into `NostrClient(socketBuilder, scope)`. */ - val socketBuilder get() = relayHub -} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt index d5fcae43db..5149c7ad13 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt @@ -20,25 +20,20 @@ */ package com.vitorpamplona.quartz.nip01Core.relay +import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientFirstEventTest : BaseNostrClientTest() { +class NostrClientFirstEventTest : RelayClientTest() { @Test fun testDownloadFirstEvent() = runBlocking { val pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" - val relayUrl = "ws://127.0.0.1:7770/" val seed = Event( @@ -50,25 +45,14 @@ class NostrClientFirstEventTest : BaseNostrClientTest() { content = """{"name":"vitor"}""", sig = "b".repeat(128), ) - relayHub.getOrCreate(relayUrl).preload(seed) - - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(seed) val event = client.fetchFirst( - relay = relayUrl, - filter = - Filter( - kinds = listOf(MetadataEvent.KIND), - authors = listOf(pubKey), - ), + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubKey)), ) - client.disconnect() - appScope.cancel() - relayHub.close() - assertEquals(MetadataEvent.KIND, event?.kind) assertEquals(pubKey, event?.pubKey) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt index 58fc091e80..8a0aad1d60 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt @@ -19,18 +19,14 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + import com.vitorpamplona.geode.fixtures.SyntheticEvents +import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.runBlocking @@ -38,17 +34,11 @@ import kotlinx.coroutines.withTimeoutOrNull import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientManualSubTest : BaseNostrClientTest() { +class NostrClientManualSubTest : RelayClientTest() { @Test fun testEoseAfter100Events() = runBlocking { - val relayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") - relayHub - .getOrCreate(relayUrl) - .preload(SyntheticEvents.batch(150, kind = MetadataEvent.KIND)) - - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(150, kind = MetadataEvent.KIND)) val resultChannel = Channel(UNLIMITED) val events = mutableListOf() @@ -73,18 +63,11 @@ class NostrClientManualSubTest : BaseNostrClientTest() { } } - val filters = - mapOf( - relayUrl to - listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 100, - ), - ), - ) - - client.subscribe(mySubId, filters, listener) + client.subscribe( + mySubId, + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100))), + listener, + ) withTimeoutOrNull(10000) { while (events.size < 101) { @@ -94,12 +77,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() { } resultChannel.close() - client.unsubscribe(mySubId) - client.disconnect() - - appScope.cancel() - relayHub.close() assertEquals(101, events.size) assertEquals(true, events.take(100).all { it.length == 64 }) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt index 0459238ed4..6d0a1248af 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt @@ -21,19 +21,15 @@ package com.vitorpamplona.quartz.nip01Core.relay import com.vitorpamplona.geode.fixtures.SyntheticEvents -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientQueryCountTest : BaseNostrClientTest() { +class NostrClientQueryCountTest : RelayClientTest() { private val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl() private val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl() @@ -44,16 +40,16 @@ class NostrClientQueryCountTest : BaseNostrClientTest() { // 5 metadata + 3 outbox relay events on A, 2 metadata + 7 outbox on B. // Each event needs a distinct (kind, pubkey, dTag) to avoid replaceable-event collisions. fun pk(seed: Int) = SyntheticEvents.hexId(seed) - relayHub.getOrCreate(relayA).preload( + hub.getOrCreate(relayA).preload( (1..5).map { SyntheticEvents.fakeEvent(idSeed = it, kind = 0, pubKey = pk(it)) }, ) - relayHub.getOrCreate(relayA).preload( + hub.getOrCreate(relayA).preload( (1..3).map { SyntheticEvents.fakeEvent(idSeed = 1000 + it, kind = 10002, pubKey = pk(1000 + it)) }, ) - relayHub.getOrCreate(relayB).preload( + hub.getOrCreate(relayB).preload( (1..2).map { SyntheticEvents.fakeEvent(idSeed = 2000 + it, kind = 0, pubKey = pk(2000 + it)) }, ) - relayHub.getOrCreate(relayB).preload( + hub.getOrCreate(relayB).preload( (1..7).map { SyntheticEvents.fakeEvent(idSeed = 3000 + it, kind = 10002, pubKey = pk(3000 + it)) }, ) } @@ -62,41 +58,22 @@ class NostrClientQueryCountTest : BaseNostrClientTest() { fun testQueryCountSuspend() = runBlocking { seed() - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) - val result = client.count(relayA, metadata) - assertEquals(5, result?.count) - - client.disconnect() - appScope.cancel() - relayHub.close() } @Test fun testQueryCountSuspendAllEvents() = runBlocking { seed() - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) - val result = client.count(relayA, Filter()) - assertEquals(8, result?.count) - - client.disconnect() - appScope.cancel() - relayHub.close() } @Test fun testQueryCountSuspendMultipleRelays() = runBlocking { seed() - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) - val results = client.count( mapOf( @@ -107,9 +84,5 @@ class NostrClientQueryCountTest : BaseNostrClientTest() { assertEquals(8, results[relayA]?.count) assertEquals(9, results[relayB]?.count) - - client.disconnect() - appScope.cancel() - relayHub.close() } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt index 20d25ec249..21876a67d8 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt @@ -19,22 +19,18 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + import com.vitorpamplona.geode.fixtures.SyntheticEvents +import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.coroutineScope @@ -44,12 +40,12 @@ import kotlinx.coroutines.withTimeoutOrNull import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientRepeatSubTest : BaseNostrClientTest() { +class NostrClientRepeatSubTest : RelayClientTest() { @Test fun testRepeatSubEvents() = runBlocking { // Each replaceable kind needs unique pubkeys. - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( + defaultRelay.preload( (1..150).map { SyntheticEvents.fakeEvent( idSeed = it, @@ -58,7 +54,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { ) }, ) - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( + defaultRelay.preload( (1..50).map { SyntheticEvents.fakeEvent( idSeed = 100_000 + it, @@ -68,9 +64,6 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { }, ) - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) - val resultChannel = Channel(UNLIMITED) val events = mutableListOf() val mySubId = "test-sub-id-2" @@ -101,38 +94,11 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { client.addConnectionListener(listener) - val filters = - mapOf( - RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to - listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 100, - ), - ), - ) - + val filters = mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100))) val filtersShouldIgnore = - mapOf( - RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to - listOf( - Filter( - kinds = listOf(AdvertisedRelayListEvent.KIND), - limit = 500, - ), - ), - ) - + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 500))) val filtersShouldSendAfterEOSE = - mapOf( - RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to - listOf( - Filter( - kinds = listOf(AdvertisedRelayListEvent.KIND), - limit = 10, - ), - ), - ) + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 10))) coroutineScope { launch { @@ -161,30 +127,18 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { client.unsubscribe(mySubId) client.removeConnectionListener(listener) - client.disconnect() - - appScope.cancel() - relayHub.close() // The relay may return up to limit events before EOSE; some relays return // one extra past the requested limit, so don't assert on the exact count. - // First sub: <= 100 metadata events, then EOSE. - // Second sub: <= 10 advertised relay list events, then EOSE. val firstEose = events.indexOf("EOSE") val lastEose = events.lastIndexOf("EOSE") - // both EOSEs must be present and distinct assertEquals(true, firstEose >= 0) assertEquals(true, lastEose > firstEose) - // last entry is the second EOSE (loop stops on it) assertEquals(events.size - 1, lastEose) - // first sub stays within its limit (allow +1 for relay quirks) assertEquals(true, firstEose in 1..101) - // second sub stays within its limit (allow +1 for relay quirks) assertEquals(true, (lastEose - firstEose - 1) in 1..11) - // everything before the first EOSE is an event id assertEquals(true, events.take(firstEose).all { it.length == 64 }) - // everything between the two EOSEs is an event id assertEquals(true, events.subList(firstEose + 1, lastEose).all { it.length == 64 }) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt index f6383421c8..9b961b7ff7 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt @@ -21,22 +21,17 @@ package com.vitorpamplona.quartz.nip01Core.relay import com.vitorpamplona.geode.fixtures.SyntheticEvents +import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { +class NostrClientReqBypassingRelayLimitsTest : RelayClientTest() { @Test fun testDownloadFromRelayReturnsMetadataEvents() = runBlocking { @@ -50,32 +45,18 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { pubKey = SyntheticEvents.hexId(it), ) } - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload(corpus) - - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(corpus) val events = mutableListOf() val totalFound = client.fetchAllPages( - relay = "ws://127.0.0.1:7770/", - filters = - listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 1000, - ), - ), + relay = defaultRelayUrl, + filters = listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 1000)), ) { event -> events.add(event) } - client.disconnect() - delay(500) - appScope.cancel() - relayHub.close() - assertEquals(1000, totalFound) assertEquals(1000, events.size) events.forEach { event -> @@ -102,27 +83,18 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { pubKey = SyntheticEvents.hexId(100_000 + it), ) } - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload(metadata + contacts) - - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(metadata + contacts) val metadataEvents = mutableListOf() val contactListEvents = mutableListOf() val totalFound = client.fetchAllPages( - relay = "ws://127.0.0.1:7770/", + relay = defaultRelayUrl, filters = listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 1000, - ), - Filter( - kinds = listOf(ContactListEvent.KIND), - limit = 1500, - ), + Filter(kinds = listOf(MetadataEvent.KIND), limit = 1000), + Filter(kinds = listOf(ContactListEvent.KIND), limit = 1500), ), ) { event -> if (event.kind == MetadataEvent.KIND) { @@ -133,11 +105,6 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { } } - client.disconnect() - delay(500) - appScope.cancel() - relayHub.close() - assertEquals(2500, totalFound) assertEquals(1000, metadataEvents.size) assertEquals(1500, contactListEvents.size) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt index ae28b862ec..fc9acdc6e6 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt @@ -19,49 +19,29 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + +import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientSendAndWaitTest : BaseNostrClientTest() { +class NostrClientSendAndWaitTest : RelayClientTest() { @Test fun testSendAndWaitForResponse() = runBlocking { - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) - val randomSigner = NostrSignerInternal(KeyPair()) - val event = randomSigner.sign(TextNoteEvent.build("Hello World")) val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl() val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl() - val resultA = - client.publishAndConfirm( - event = event, - relayList = setOf(relayA), - ) - - val resultB = - client.publishAndConfirm( - event = event, - relayList = setOf(relayB), - ) - - client.disconnect() - appScope.cancel() - relayHub.close() + val resultA = client.publishAndConfirm(event = event, relayList = setOf(relayA)) + val resultB = client.publishAndConfirm(event = event, relayList = setOf(relayB)) assertEquals(true, resultA) assertEquals(true, resultB) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt index 99e243d8c5..b749a7d90f 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt @@ -19,19 +19,16 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + import com.vitorpamplona.geode.fixtures.SyntheticEvents +import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle @@ -39,7 +36,7 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { +class NostrClientSubscriptionAsFlowTest : RelayClientTest() { fun List.printDates(): String { val starting = this[0].createdAt return joinToString { (it.createdAt - starting).toString() } @@ -49,20 +46,12 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { @Test fun testNostrClientSubscriptionAsFlow() = runTest { - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( - SyntheticEvents.batch(20, kind = MetadataEvent.KIND), - ) - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND)) val flow = client.subscribeAsFlow( - relay = "ws://127.0.0.1:7770/", - filter = - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 10, - ), + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10), ) var feedStates = listOf() @@ -79,11 +68,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { advanceUntilIdle() } - job.cancel() // Cancel the collection job - - client.disconnect() - appScope.cancel() - relayHub.close() + job.cancel() assertEquals(10, feedStates.size) } @@ -92,20 +77,12 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { @Test fun testNostrClientSubscriptionAsFlowDebouncing() = runTest { - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( - SyntheticEvents.batch(20, kind = MetadataEvent.KIND), - ) - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND)) val flow = client.subscribeAsFlow( - relay = "ws://127.0.0.1:7770/", - filter = - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 10, - ), + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10), ) var feedStates = listOf() @@ -117,16 +94,11 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { } } - // Advance the test dispatcher to ensure emissions are processed while (feedStates.size < 10) { advanceUntilIdle() } - job.cancel() // Cancel the collection job - - client.disconnect() - appScope.cancel() - relayHub.close() + job.cancel() assertEquals(10, feedStates.size) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt index a4dad911d9..9ec9bfc540 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt @@ -19,17 +19,13 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + import com.vitorpamplona.geode.fixtures.SyntheticEvents +import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.StaticSubscription import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.runBlocking @@ -37,15 +33,11 @@ import kotlinx.coroutines.withTimeoutOrNull import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientSubscriptionTest : BaseNostrClientTest() { +class NostrClientSubscriptionTest : RelayClientTest() { @Test fun testNostrClientSubscription() = runBlocking { - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( - SyntheticEvents.batch(150, kind = MetadataEvent.KIND), - ) - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(150, kind = MetadataEvent.KIND)) val resultChannel = Channel(UNLIMITED) val events = mutableSetOf() @@ -53,15 +45,7 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { val sub = StaticSubscription( client, - mapOf( - RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") to - listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 100, - ), - ), - ), + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100))), ) { event -> assertEquals(MetadataEvent.KIND, event.kind) resultChannel.trySend(event) @@ -75,13 +59,8 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { } resultChannel.close() - sub.close() - client.disconnect() - appScope.cancel() - relayHub.close() - assertEquals(100, events.size) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt index 7ab1e1e278..9f8ce103c5 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt @@ -19,19 +19,16 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + import com.vitorpamplona.geode.fixtures.SyntheticEvents +import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle @@ -39,7 +36,7 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { +class NostrClientSubscriptionUntilEoseAsFlowTest : RelayClientTest() { fun List.printDates(): String { val starting = this[0].createdAt return joinToString { (it.createdAt - starting).toString() } @@ -49,20 +46,12 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { @Test fun testNostrClientSubscriptionUntilEoseAsFlow() = runTest { - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( - SyntheticEvents.batch(20, kind = MetadataEvent.KIND), - ) - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND)) val flow = client.fetchAsFlow( - relay = "ws://127.0.0.1:7770/", - filter = - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 10, - ), + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10), ) var feedStates = listOf() @@ -74,16 +63,11 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { } } - // Advance the test dispatcher to ensure emissions are processed while (feedStates.size < 10) { advanceUntilIdle() } - job.cancel() // Cancel the collection job - - client.disconnect() - appScope.cancel() - relayHub.close() + job.cancel() assertEquals(10, feedStates.size) } @@ -92,20 +76,12 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { @Test fun testNostrClientSubscriptionUntilEoseAsFlowDebouncing() = runTest { - relayHub.getOrCreate("ws://127.0.0.1:7770/").preload( - SyntheticEvents.batch(20, kind = MetadataEvent.KIND), - ) - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND)) val flow = client.fetchAsFlow( - relay = "ws://127.0.0.1:7770/", - filter = - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 10, - ), + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10), ) var feedStates = listOf() @@ -117,16 +93,11 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { } } - // Advance the test dispatcher to ensure emissions are processed while (feedStates.size < 10) { advanceUntilIdle() } - job.cancel() // Cancel the collection job - - client.disconnect() - appScope.cancel() - relayHub.close() + job.cancel() assertEquals(10, feedStates.size) } From 2c0ad4fbf5de967b7078e04b4431525c937be7d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 14:05:11 +0000 Subject: [PATCH 17/17] docs(geode): performance plans for future work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four sketches, queued by impact, each grounded in current code paths and observed benchmark numbers: - event-ingestion-batching: SQLite group commit + EVENT pipelining + off-thread Schnorr verify. Targets 5–10× EPS on a fast SSD. - live-broadcast-fanout-index: indexed filter matching to replace the O(N_subs × N_filters) per-event walk in LiveEventStore. Targets flat fanout p99 up to high subscriber counts. - connection-scaling: shrink the per-session outQueue footprint (currently the dominant per-conn cost), tune Ktor CIO group sizes, reduce JSON parse allocations. Targets 10 000+ concurrent conns. - negentropy-large-corpus: id-and-time-only snapshot path so NEG-OPEN on a 5M-event store doesn't materialise full Event objects, plus bounded-window defaults and concurrent-session caps. Each plan names the verification benchmark to add. Plans are queued, not committed work — README orders them by expected impact. --- geode/plans/2026-05-07-connection-scaling.md | 93 ++++++++++++++++ .../2026-05-07-event-ingestion-batching.md | 91 ++++++++++++++++ .../2026-05-07-live-broadcast-fanout-index.md | 100 ++++++++++++++++++ .../2026-05-07-negentropy-large-corpus.md | 100 ++++++++++++++++++ geode/plans/README.md | 19 ++++ 5 files changed, 403 insertions(+) create mode 100644 geode/plans/2026-05-07-connection-scaling.md create mode 100644 geode/plans/2026-05-07-event-ingestion-batching.md create mode 100644 geode/plans/2026-05-07-live-broadcast-fanout-index.md create mode 100644 geode/plans/2026-05-07-negentropy-large-corpus.md create mode 100644 geode/plans/README.md diff --git a/geode/plans/2026-05-07-connection-scaling.md b/geode/plans/2026-05-07-connection-scaling.md new file mode 100644 index 0000000000..fb1f8b3537 --- /dev/null +++ b/geode/plans/2026-05-07-connection-scaling.md @@ -0,0 +1,93 @@ +# Connection scaling: pushing past 2 000 + +## Problem + +Current measurement (`LoadBenchmark.connectionsHeldOpen`): **~2 000 +concurrent connections** before file-descriptor pressure / Ktor CIO +event-loop saturation. Real-world relays (e.g. nostr.wine, nos.lol) +sustain 10–30k. Geode shouldn't be the bottleneck for an Amethyst- +adjacent operator who scales beyond a thousand-user community. + +## What's spending memory per connection today + +| Cost | Per connection | At 5 000 conns | +| ----------------------- | -------------------------------------------------------- | -------------- | +| `outQueue` Channel | 8 192 string slots × ~8 b ref | ~320 MB pinned | +| `RelaySession` | `LargeCache` for subs (likely 1–10 entries) | ~negligible | +| `NegSessionRegistry` | `HashMap` — usually 0 | ~negligible | +| Ktor CIO buffers | TCP read + write buffers | ~10 MB | +| Per-session writer Job | one coroutine | ~few KB | + +The `outQueue` reservation is the dominant cost. The 8 192 was sized +for a worst case "thousands of subscriptions, one event matches all" — +but at 5 000 connections we've over-provisioned by ~300 MB just on +the channel array, even though most connections never fan out. + +## Sketch + +### A — adaptive outQueue capacity + +Start every connection with `INITIAL_OUTGOING_BUFFER = 64`. When the +producer side trySends and we observe queue depth crossing a high-water +mark (e.g. 75% full), grow the channel up to `MAX_OUTGOING_BUFFER = +8192`. This is not how `kotlinx.coroutines.channels.Channel` is +structured (capacity is fixed at construction), so the implementation +is "swap in a wider channel under a per-session lock when watermark +trips" — drains the old, then routes new sends through the new. + +Expected: 90% of connections never fan out, so they stay at 64 slots +× ~512 B per ref ≈ 32 KB. At 5 000 conns that's ~160 MB → ~5 MB. +Hot-fanout connections still get the 2 MB cap. + +### B — per-relay event-loop pool sizing + +Ktor CIO defaults to one event-loop thread per available CPU. +Beyond a few thousand connections, this becomes the bottleneck — and +none of geode's per-connection work is CPU-bound (it's mostly waiting +on incoming frames). Tune CIO via: + +```kotlin +embeddedServer(CIO, ...) { + connectionGroupSize = max(2, Runtime.getRuntime().availableProcessors() / 2) + workerGroupSize = max(4, Runtime.getRuntime().availableProcessors()) + callGroupSize = max(8, Runtime.getRuntime().availableProcessors() * 4) +} +``` + +Expose these through `RelayConfig.NetworkSection` so an operator on a +big VM can lift them. + +### C — reduce per-message JSON allocations + +`OptimizedJsonMapper.fromJsonToCommand` allocates a `JsonNode` tree per +incoming frame. At 10k connections with 1 msg/s each that's 10k tree +allocations/sec. Investigate streaming Jackson + reusing `ObjectMapper` +per session, or using kotlinx-serialization's lower-overhead path. + +This is more of a quartz-level change than geode-specific, but +geode's load benchmark is the right place to measure it. + +## How to verify + +Add to `geode.perf.LoadBenchmark`: + +- `connectionsHeldOpen10k` — opens 10 000 idle WebSocket connections; + asserts no FD exhaustion + RSS stays under 1 GB. +- `connectionsHeldOpenWithFanout` — 5 000 idle subscribers, + 10 EPS published; measures p99 fanout latency at scale. + +The current `connectionsHeldOpen` benchmark stays as the baseline +floor (~2 000 conns). + +## Risks + +- **Adaptive channel swap is fiddly**: drains under the producer's nose + must preserve OK ordering. A simpler alternative: keep capacity fixed, + but lazily allocate a small `ArrayDeque` only when the first + message is sent. Channels in kotlinx.coroutines do allocate up-front. +- **Bumping CIO group sizes can hurt**: more threads can mean worse + L1/L2 locality. Always benchmark before/after, don't trust + intuitive sizing. +- **OS-level FD limit**: per-process FD limit on Linux defaults to + 1024 in many environments. Document the `ulimit -n` requirement + for operators targeting >1k connections. diff --git a/geode/plans/2026-05-07-event-ingestion-batching.md b/geode/plans/2026-05-07-event-ingestion-batching.md new file mode 100644 index 0000000000..e6df99ebf4 --- /dev/null +++ b/geode/plans/2026-05-07-event-ingestion-batching.md @@ -0,0 +1,91 @@ +# Event ingestion: write batching + pipelined OK + +## Problem + +EVENT acceptance is the hot path on a busy relay — every published note, +every reaction, every DM lands here. Today the per-event flow is fully +serial: + +1. `RelaySession.handleEvent` (`quartz/nip01Core/relay/server/RelaySession.kt:131`) + awaits `policy.accept(cmd)` (Schnorr verify if `VerifyPolicy` is in + the stack — ~0.1 ms on JVM). +2. Awaits `store.insert(cmd.event)` — a single SQLite write, guarded by + the connection-pool writer mutex (`SQLiteConnectionPool`). +3. Sends `OkMessage` back through the writer coroutine. + +`LoadBenchmark.publishThroughputSingleClient` measured **~760 EPS**; +the concurrent variant **~2000 EPS** (limited by SQLite writer mutex +contention, not WS throughput). + +## Constraints we must keep + +- **OK ordering**: NIP-01 requires the OK reply to follow its EVENT. + We cannot reply OK before the insert decision (the OK carries + accepted/rejected + reason). +- **Durability semantics**: clients reasonably assume `OK true` means + "stored." Batching must not make us reply OK before fsync. +- **Per-connection FIFO**: a publisher that sends three EVENTs in a + row expects three OKs in that order. Reordering across connections + is fine. + +## Sketch + +### Tier 1 — SQLite WAL + group commit (cheap win) + +Confirm `PRAGMA journal_mode=WAL` + `PRAGMA synchronous=NORMAL` on the +event-store DB; group commits across the writer mutex's hold window. +Today each insert is its own transaction. Wrap N inserts (or a 5 ms +budget, whichever first) in a single transaction managed by the writer +coroutine. On commit, fan back N OK replies. + +Implementation lives in quartz's `EventStore` / `SQLiteConnectionPool`, +not geode — but geode owns the benchmark and validates the gain. + +Expected: **~5–10× write throughput** on a fast SSD. SQLite group +commit is well-trodden territory (nostr-rs-relay, strfry both do it). + +### Tier 2 — pipelined OK over multiple in-flight EVENTs + +`RelaySession.receive` is currently single-flight: one EVENT in, +process, OK out, next EVENT. Allow a connection to push N EVENTs +concurrently, dispatch them to a per-connection ingest pipeline, and +serialise OKs back in arrival order via a small commit log. + +A `Channel with capacity = INGEST_PIPELINE_DEPTH` per +connection, drained by a coroutine that batches into the group-commit +above. OK responses are written to an `outQueue.send()` already — so +the pipeline just needs to record arrival order and emit OKs in that +order after each batch commits. + +Expected: hides the verify+insert latency behind another EVENT's +parse, gets us closer to network-bound throughput. + +### Tier 3 — eager Schnorr verify off the writer thread + +`VerifyPolicy` is in the policy stack and runs synchronously on +`receive`. Move it into the ingest pipeline so verification of EVENT N+1 +runs concurrently with the SQLite commit of EVENT N. secp256k1 verify +is parallelisable; the writer should never block on it. + +## How to verify + +Add to `geode.perf.LoadBenchmark`: + +- `publishGroupCommitSingleClient` — same workload as the current + single-client benchmark, asserts >5000 EPS. +- `publishPipelinedSingleClient` — sends 100 EVENTs without awaiting + intermediate OKs; measures end-to-end and OK-ordering correctness. + +Existing benchmarks stay as the regression floor. + +## Risks + +- **Group commit windows**: if a single bad event in the batch fails + validation, we must not roll back the good ones. The batch needs + per-row commit semantics (row-level errors → row-level OK false). +- **Backpressure on slow disks**: deeper pipelines on slow storage + amplify out-of-memory pressure. Cap the in-flight queue depth and + apply existing slow-client backpressure if it fills. +- **Replay protection**: the existing dedupe table needs to see the + event before commit, not after — keep that check inside the writer + coroutine. diff --git a/geode/plans/2026-05-07-live-broadcast-fanout-index.md b/geode/plans/2026-05-07-live-broadcast-fanout-index.md new file mode 100644 index 0000000000..9a0227a08b --- /dev/null +++ b/geode/plans/2026-05-07-live-broadcast-fanout-index.md @@ -0,0 +1,100 @@ +# Live broadcast: indexed filter matching for fanout + +## Problem + +Every accepted EVENT runs through `LiveEventStore.newEventStream` +(`quartz/nip01Core/relay/server/LiveEventStore.kt:43`) — a +`MutableSharedFlow` that every active subscription collects. +Each subscriber's collector then calls: + +```kotlin +if (filters.any { it.match(newEvent) }) onEach(newEvent) +``` + +That's **O(N_subscribers × N_filters_per_sub)** per published event. +With 5k connections × ~3 filters average that's 15k Filter.match +calls per EVENT — and each `Filter.match` itself walks `kinds`, +`authors`, tag prefixes, since/until, etc. At 2k EPS ingest that's +~30M comparisons/sec. + +Two specific cost shapes: + +1. **Filters that almost never match.** Most subscriptions are scoped + to a small author list. Today every published EVENT walks every + such subscription to learn that. A `HashMap>` + keyed by author would cut this to O(1) average for the dominant case. +2. **Pseudo-broadcast filters** (`{kinds: [1]}` with no other + constraint) match almost everything. There's no avoiding the + per-subscriber notification, but at least the index lookup is + cheap. + +`LoadBenchmark.fanoutLatency` already measures this — current +results are not yet noted in tree, but back-of-envelope says fanout +becomes the dominant cost above ~2k subscribers. + +## Sketch + +A new `LiveBroadcastIndex` inside `LiveEventStore`: + +```kotlin +private val byAuthor = ConcurrentHashMap>() +private val byKind = ConcurrentHashMap>() +private val byTag = ConcurrentHashMap>() +private val unindexed = CopyOnWriteArraySet() // subs with no + // narrowing field +``` + +Each `RelaySession.handleReq` registers its `Subscription` (a tuple of +filters + the existing `EventMessage` send callback) into whichever +buckets each filter narrows on. A filter with `kinds=[1] and +authors=[a,b]` registers into `byKind[1]` AND `byAuthor[a]`, +`byAuthor[b]` — broadcast unions the resulting candidate sets. + +On EVENT arrival: + +1. Build the candidate set: union of `byAuthor[event.pubkey]`, + `byKind[event.kind]`, every `byTag[(letter, value)]` for the + event's single-letter tags, plus `unindexed`. +2. Run the existing `Filter.match` on each candidate to handle + negative constraints (`since`, `until`, `limit` already-reached, + composite predicates). +3. Send. + +Expected: **>10× speedup** on fanout for realistic subscriptions. +Worst case (all filters in `unindexed`) degrades to current behaviour. + +## Where it lives + +`quartz/nip01Core/relay/server/LiveBroadcastIndex.kt` — protocol-level, +reusable by any relay embed. `RelaySession.handleReq` registers/ +unregisters; `LiveEventStore.insert` calls +`index.candidatesFor(event)`. + +## How to verify + +Add `geode.perf.LoadBenchmark.fanoutScaling`: + +- N connections, each subscribes to `{authors: [pk_i], kinds: [1]}`. +- Publish 10k EVENTs from a producer connection; each event matches + exactly one subscriber. +- Measure end-to-end latency p50/p99 for N ∈ {100, 1000, 5000}. + +Without the index, p99 grows roughly linearly with N. With the +index, p99 should be flat up to a much higher N. + +## Risks + +- **Subscription churn**: re-subscribing on every page (the way some + client features work) means many index insert/remove operations. + `ConcurrentHashMap` value-set operations need to be lock-free or + finely locked; benchmark this path explicitly. +- **Tag explosion**: an EVENT with many `e`/`p` tags hits many tag + buckets. Cap candidate-set union work or short-circuit when the + union saturates. +- **Memory**: the index is a per-bucket set of subscription handles. + At 5k subs × average 3 narrowing fields, ~15k entries — negligible. +- **Correctness fence**: the index must see new subscriptions before + the next EVENT broadcast. Today `RelaySession.handleReq` writes its + `Job` into a `LargeCache` then launches the collector. Order of + operations needs to be revisited so the index is updated atomically + with the collector being ready. diff --git a/geode/plans/2026-05-07-negentropy-large-corpus.md b/geode/plans/2026-05-07-negentropy-large-corpus.md new file mode 100644 index 0000000000..0cb31ccd9b --- /dev/null +++ b/geode/plans/2026-05-07-negentropy-large-corpus.md @@ -0,0 +1,100 @@ +# NIP-77 negentropy at scale: snapshot memory + chunked replay + +## Problem + +`RelaySession` delegates NEG-OPEN to `NegSessionRegistry.open` +(`quartz/nip01Core/relay/server/NegSessionRegistry.kt`), which calls +`store.snapshotQuery(filters)` and feeds the **entire** result list +into `NegentropyServerSession`. For a relay holding 5 M events that +match a broad NEG-OPEN filter (`{kinds: [1, 7]}`), this is 5 M +`Event` objects materialised in memory before the first NEG-MSG goes +out. + +The negentropy library itself is fine — it pivots into a sealed +`StorageVector` (id + createdAt only, ~40 bytes/entry). But the +`store.query(f)` step that produces the input materialises full +`Event` objects with content, tags, sig — call it ~1 KB/event. 5 M × +1 KB = 5 GB transient pressure per concurrent NEG-OPEN. + +Two operator-visible symptoms: + +1. NEG-OPEN with a broad filter spikes JVM heap; under load, GC pause + stalls every other handler on the same process. +2. NEG-OPEN latency before the first NEG-MSG response is O(N) — for + large stores the client waits seconds for what should be a + millisecond round-trip. + +## Sketch + +### A — id-and-time-only snapshot path + +Negentropy only needs `(createdAt, id)` pairs. Add a streaming +`IEventStore.queryIdAndTime(filter)` that returns +`Sequence>` (or a `Flow` of small chunks) — +no content/tags/sig, no Event allocation. SQLite path is a SELECT +on `event_headers` (the `created_at`, `id` columns are already +indexed for query plans). + +```kotlin +suspend fun snapshotIdsForNegentropy(filter: Filter): IdTimeStream +``` + +`NegentropyServerSession` is rewritten to take that stream and feed +it directly into the `StorageVector`. Memory drops from O(N × 1 KB) +to O(N × 40 B) — a 25× reduction; for 5 M events, ~200 MB instead +of 5 GB. + +### B — bounded-window subscriptions + +Most NEG-OPENs from real Nostr clients want the last 30 days, not +"everything." If the client doesn't supply `since`, the server can +default to a configurable horizon (e.g. 90 days) and surface this in +the NIP-11 `limitation.negentropy_max_lookback_seconds` field. +Operators can lift the cap; clients reading the doc know the bound. + +This is a NIP-spec-adjacent question more than a code change — needs +a comment on whether the spec allows it. nostr-rs-relay does this +already. + +### C — frame-size cap on NEG-MSG + +`NegentropyServerSession` is constructed with `frameSizeLimit = 0` +(no limit). At very large reconciliations the message can grow large. +Set a default `frameSizeLimit = 64 * 1024` (matching the typical WS +frame budget) so NEG-MSGs don't blow past `[limits].max_ws_frame_bytes`. + +The library already supports this — pure config change in +`NegSessionRegistry.open`. + +### D — concurrent NEG-OPEN cap + +A NEG-OPEN holds session state until NEG-CLOSE (or connection close). +Today nothing caps the number of concurrent open negentropy sessions +per connection. A misbehaving (or hostile) client could open thousands +and pin RAM. Add `MAX_NEG_SESSIONS_PER_CONNECTION = 16`, send NEG-ERR +on overflow. + +## How to verify + +Add to `geode.perf.LoadBenchmark`: + +- `negentropyOpenLatencyLargeCorpus` — preload 1 M events (use + fixtures), measure NEG-OPEN → first NEG-MSG latency. Target <100 ms. +- `negentropyMemoryPressure` — open 10 concurrent NEG-OPENs on the + same large corpus; measure RSS delta, target <500 MB. + +## Risks + +- **`Sequence`/`Flow` over SQLite cursor**: holding a cursor open + across the full sync is fragile if the client stalls. Materialise + to a smaller in-memory list (just (id, createdAt)) once, reuse for + the lifetime of the session. Memory bound is the same. +- **Defaulting `since` is a behaviour change**: existing clients that + expect "everything" silently get a bounded window. Either (a) make + it opt-in via `RelayConfig.NegentropySection.default_lookback_seconds + = null`, (b) advertise the cap in NIP-11 so well-behaved clients + read it. +- **Frame-size cap can break older clients**: the NIP-77 reference + implementation (kmp-negentropy) handles this gracefully — multi-frame + reconciliation is in spec — but field-test against a known-working + client (e.g. nstart, primal-cache) before flipping the default. diff --git a/geode/plans/README.md b/geode/plans/README.md new file mode 100644 index 0000000000..6524503b3b --- /dev/null +++ b/geode/plans/README.md @@ -0,0 +1,19 @@ +# geode plans + +Performance-focused design docs for future work. Each file is a +self-contained sketch — problem statement, observed numbers, proposed +fix, how to verify, risks. None of these are committed work; they're +the queue. + +Ordered roughly by expected impact: + +| Plan | Headline gain | +| ---- | ------------- | +| [2026-05-07-event-ingestion-batching.md](2026-05-07-event-ingestion-batching.md) | 5–10× write EPS via SQLite group commit + ingest pipelining | +| [2026-05-07-live-broadcast-fanout-index.md](2026-05-07-live-broadcast-fanout-index.md) | >10× fanout speedup at >2 000 subscribers | +| [2026-05-07-connection-scaling.md](2026-05-07-connection-scaling.md) | 2 000 → 10 000+ concurrent connections | +| [2026-05-07-negentropy-large-corpus.md](2026-05-07-negentropy-large-corpus.md) | 25× lower memory + faster NEG-OPEN on M-event corpora | + +Verification target for each plan is a new method on +`geode.perf.LoadBenchmark` (gated by `-DrunLoadBenchmark=true`) so +regressions show up in the regular CI matrix once they're enabled.