feat(quartz-relay): rename + promote to a real Nostr relay

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 <file>, --db <file>, --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.
This commit is contained in:
Claude
2026-05-07 00:56:57 +00:00
parent aefecf71d8
commit eb80dbcd15
22 changed files with 616 additions and 37 deletions
+4
View File
@@ -81,6 +81,7 @@ kotlinTest = "2.3.21"
core = "1.7.0" core = "1.7.0"
mavenPublish = "0.36.0" mavenPublish = "0.36.0"
sqlite = "2.6.2" sqlite = "2.6.2"
ktor = "3.4.1"
[libraries] [libraries]
abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" } abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" }
@@ -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" } 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" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", 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-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
@@ -2,6 +2,12 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.jetbrainsKotlinJvm) alias(libs.plugins.jetbrainsKotlinJvm)
application
}
application {
mainClass.set("com.vitorpamplona.quartz.relay.MainKt")
applicationName = "quartz-relay"
} }
kotlin { kotlin {
@@ -26,10 +32,18 @@ dependencies {
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.jackson.module.kotlin) 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) 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.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm) testImplementation(libs.secp256k1.kmp.jni.jvm)
testImplementation(libs.okhttp)
} }
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.testrelay package com.vitorpamplona.quartz.relay
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
@@ -32,7 +32,7 @@ import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.launch 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 * touching the network. Each instance opens one [RelaySession] on
* [connect] and routes: * [connect] and routes:
* *
@@ -42,7 +42,7 @@ import kotlinx.coroutines.launch
* - Server-side `send` callbacks [WebSocketListener.onMessage]. * - Server-side `send` callbacks [WebSocketListener.onMessage].
*/ */
class InProcessWebSocket( class InProcessWebSocket(
private val relay: TestRelay, private val relay: Relay,
private val out: WebSocketListener, private val out: WebSocketListener,
) : WebSocket { ) : WebSocket {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -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
}
}
@@ -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 <addr> bind address (default 0.0.0.0)
* --port <n> tcp port (default 7447, 0 to autobind)
* --path <p> ws path (default /)
* --info <file> NIP-11 doc file (default: built-in)
* --db <file> 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<String>) {
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<String, String>,
private val flags: Set<String>,
) {
fun opt(k: String) = opts[k]
fun flag(k: String) = k in flags
}
private fun parseArgs(args: Array<String>): Args {
val opts = mutableMapOf<String, String>()
val flags = mutableSetOf<String>()
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)
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.testrelay package com.vitorpamplona.quartz.relay
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
@@ -33,15 +33,25 @@ import kotlinx.coroutines.SupervisorJob
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
/** /**
* A self-contained, in-memory Nostr relay scoped to a single URL. Wraps a * A self-contained Nostr relay scoped to a single URL. Wraps a [NostrServer]
* [NostrServer] over an [EventStore] backed by an in-memory SQLite database. * over an [EventStore] (defaults to an in-memory SQLite database).
* *
* Use [TestRelayHub] to register relays under URLs the production * Speaks NIP-01 (REQ/EVENT/EOSE/CLOSE), NIP-11 (relay info via [info]),
* [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] can subscribe to. * 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 url: NormalizedRelayUrl,
val store: IEventStore = EventStore(dbName = null, relay = url), val store: IEventStore = EventStore(dbName = null, relay = url),
val info: RelayInfo = RelayInfo.default(url),
policyBuilder: () -> IRelayPolicy = { EmptyPolicy }, policyBuilder: () -> IRelayPolicy = { EmptyPolicy },
parentContext: CoroutineContext = SupervisorJob(), parentContext: CoroutineContext = SupervisorJob(),
) : AutoCloseable { ) : AutoCloseable {
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.testrelay package com.vitorpamplona.quartz.relay
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer 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 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 * [WebsocketBuilder] so it can be plugged into
* [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] in place of * [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] in place of
* `BasicOkHttpWebSocket.Builder` to redirect every outbound connection to an * `BasicOkHttpWebSocket.Builder` to redirect every outbound connection to an
@@ -38,7 +38,7 @@ import java.util.concurrent.ConcurrentHashMap
* *
* Usage: * Usage:
* ``` * ```
* val hub = TestRelayHub() * val hub = RelayHub()
* val relay = hub.getOrCreate("ws://test.relay/") * val relay = hub.getOrCreate("ws://test.relay/")
* runBlocking { relay.preload(listOf(event1, event2)) } * runBlocking { relay.preload(listOf(event1, event2)) }
* val client = NostrClient(hub, scope) * 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 * Unknown URLs auto-create an empty relay so a single hub can transparently
* back any number of test endpoints. * back any number of test endpoints.
*/ */
class TestRelayHub( class RelayHub(
private val defaultPolicy: () -> IRelayPolicy = { EmptyPolicy }, private val defaultPolicy: () -> IRelayPolicy = { EmptyPolicy },
) : WebsocketBuilder, ) : WebsocketBuilder,
AutoCloseable { AutoCloseable {
private val relays = ConcurrentHashMap<NormalizedRelayUrl, TestRelay>() private val relays = ConcurrentHashMap<NormalizedRelayUrl, Relay>()
fun getOrCreate(url: NormalizedRelayUrl): TestRelay = fun getOrCreate(url: NormalizedRelayUrl): Relay =
relays.getOrPut(url) { 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<NormalizedRelayUrl> = relays.keys.toSet() fun urls(): Set<NormalizedRelayUrl> = relays.keys.toSet()
@@ -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))
}
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.testrelay package com.vitorpamplona.quartz.relay.fixtures
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.testrelay package com.vitorpamplona.quartz.relay.fixtures
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -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()
}
}
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.testrelay package com.vitorpamplona.quartz.relay
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair 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.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -62,7 +63,7 @@ import kotlin.test.assertTrue
* `socketBuilder` and the relay URL would change. * `socketBuilder` and the relay URL would change.
*/ */
class Nip01ComplianceTest { class Nip01ComplianceTest {
private lateinit var hub: TestRelayHub private lateinit var hub: RelayHub
private lateinit var scope: CoroutineScope private lateinit var scope: CoroutineScope
private lateinit var client: NostrClient private lateinit var client: NostrClient
@@ -70,7 +71,7 @@ class Nip01ComplianceTest {
@BeforeTest @BeforeTest
fun setup() { fun setup() {
hub = TestRelayHub() hub = RelayHub()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
client = NostrClient(hub, scope) client = NostrClient(hub, scope)
} }
+4 -2
View File
@@ -182,8 +182,10 @@ kotlin {
implementation(libs.kotlinx.coroutines.test) implementation(libs.kotlinx.coroutines.test)
// In-process Nostr relay so JVM/Android host tests don't // In-process Nostr relay so JVM/Android host tests don't
// need network access or a Rust toolchain. // need network access or a Rust toolchain. The
implementation(project(":quartz-test-relay")) // `relay.fixtures` package carries the test-only event
// generators and corpus loader.
implementation(project(":quartz-relay"))
} }
} }
@@ -20,11 +20,11 @@
*/ */
package com.vitorpamplona.quartz.nip01Core.relay 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 * 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 * preload events and assert deterministic counts without hitting the
* network or relying on production relays. * network or relying on production relays.
* *
@@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.testrelay.TestRelayHub
* the specific test that needs it. * the specific test that needs it.
*/ */
open class BaseNostrClientTest { open class BaseNostrClientTest {
val relayHub: TestRelayHub = TestRelayHub() val relayHub: RelayHub = RelayHub()
/** Plug into `NostrClient(socketBuilder, scope)`. */ /** Plug into `NostrClient(socketBuilder, scope)`. */
val socketBuilder get() = relayHub val socketBuilder get() = relayHub
@@ -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.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -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.client.accessories.count
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -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.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent 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 com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -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.client.accessories.fetchAllPages
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -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.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter 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 com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -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.client.reqs.StaticSubscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -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.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter 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 com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
+1 -1
View File
@@ -34,7 +34,7 @@ rootProject.name = "Amethyst"
include ':amethyst' include ':amethyst'
include ':benchmark' include ':benchmark'
include ':quartz' include ':quartz'
include ':quartz-test-relay' include ':quartz-relay'
include ':commons' include ':commons'
include ':ammolite' include ':ammolite'
include ':quic' include ':quic'