Merge branch 'main-upstream' into chess-enhancements-and-bug-fixes

This commit is contained in:
davotoula
2026-03-30 17:40:40 +02:00
34 changed files with 2045 additions and 192 deletions
+562
View File
@@ -0,0 +1,562 @@
# Building a Nostr Client with Quartz
This guide walks you through building a Nostr client using Quartz's `NostrClient` and [Ktor](https://ktor.io/) as the WebSocket transport layer.
## Overview
Quartz provides a complete, transport-agnostic client engine:
| Component | Class | Role |
|-----------|-------|------|
| **NostrClient** | `com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient` | Manages relay connections, subscriptions, and event publishing |
| **WebsocketBuilder** | `com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder` | Factory interface for pluggable WebSocket transports |
| **Filter** | `com.vitorpamplona.quartz.nip01Core.relay.filters.Filter` | NIP-01 subscription filters |
| **KeyPair** | `com.vitorpamplona.quartz.nip01Core.crypto.KeyPair` | Schnorr key pair for signing events |
| **NostrSignerInternal** | `com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal` | Signs events with a local private key |
`NostrClient` doesn't know about HTTP or WebSockets. You provide a `WebsocketBuilder` that creates transport connections, and the client handles relay pooling, subscription management, reconnection, and event delivery.
## Quick Start
### 1. Add Dependencies
In your `build.gradle.kts`:
```kotlin
dependencies {
// Ktor WebSocket client
implementation("io.ktor:ktor-client-core:3.1.1")
implementation("io.ktor:ktor-client-cio:3.1.1")
implementation("io.ktor:ktor-client-websockets:3.1.1")
// Quartz (use your local module or published artifact)
implementation(project(":quartz"))
}
```
### 2. Implement the Ktor WebSocket Transport
Quartz uses two interfaces for WebSocket connectivity:
```kotlin
// WebSocket — a single connection to a relay
interface WebSocket {
fun needsReconnect(): Boolean
fun connect()
fun disconnect()
fun send(msg: String): Boolean
}
// WebsocketBuilder — factory that creates WebSocket instances
interface WebsocketBuilder {
fun build(url: NormalizedRelayUrl, out: WebSocketListener): WebSocket
}
// WebSocketListener — callbacks from the transport layer
interface WebSocketListener {
fun onOpen(pingMillis: Int, compression: Boolean)
fun onMessage(text: String) // must deliver messages in order
fun onClosed(code: Int, reason: String)
fun onFailure(t: Throwable, code: Int?, response: String?)
}
```
Here's a complete Ktor implementation:
```kotlin
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
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 io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.*
class KtorWebSocket(
private val url: NormalizedRelayUrl,
private val client: HttpClient,
private val out: WebSocketListener,
) : WebSocket {
private var session: DefaultClientWebSocketSession? = null
private var job: Job? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun needsReconnect() = session == null
override fun connect() {
job = scope.launch {
try {
client.webSocket(url.url) {
session = this
out.onOpen(0, false)
try {
for (frame in incoming) {
if (frame is Frame.Text) {
out.onMessage(frame.readText())
}
}
} finally {
val reason = closeReason.await()
session = null
out.onClosed(
reason?.code?.toInt() ?: 1000,
reason?.message ?: "Connection closed",
)
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
session = null
out.onFailure(e, null, e.message)
}
}
}
override fun disconnect() {
job?.cancel()
job = null
session = null
}
override fun send(msg: String): Boolean {
val currentSession = session ?: return false
return try {
runBlocking { currentSession.send(Frame.Text(msg)) }
true
} catch (e: Exception) {
false
}
}
class Builder(
private val client: HttpClient,
) : WebsocketBuilder {
override fun build(
url: NormalizedRelayUrl,
out: WebSocketListener,
) = KtorWebSocket(url, client, out)
}
}
```
### 3. Subscribe to Events (Flow API)
The simplest way to receive events — `subscribeAsFlow` returns a `Flow<List<Event>>` that accumulates events as they arrive:
```kotlin
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 io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.websocket.*
import kotlinx.coroutines.*
fun main() = runBlocking {
// 1. Create the Ktor HTTP client with WebSocket support
val httpClient = HttpClient(CIO) {
install(WebSockets)
}
// 2. Create the NostrClient
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(KtorWebSocket.Builder(httpClient), scope)
// 3. Subscribe to text notes (kind 1) from a relay
val flow = client.subscribeAsFlow(
relay = "wss://nos.lol",
filter = Filter(kinds = listOf(1), limit = 20),
)
// 4. Collect events as they arrive
val job = launch {
flow.collect { events ->
println("Got ${events.size} events so far")
events.forEach { event ->
println(" [${event.pubKey.take(8)}...] ${event.content.take(80)}")
}
}
}
// Let it run for 10 seconds
delay(10_000)
job.cancel()
// 5. Clean up
client.disconnect()
scope.cancel()
httpClient.close()
}
```
### 4. Subscribe to Events (Callback API)
For more control, use the manual subscription API with `SubscriptionListener`:
```kotlin
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 com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.*
fun main() = runBlocking {
val httpClient = HttpClient(CIO) { install(WebSockets) }
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(KtorWebSocket.Builder(httpClient), scope)
// Define a listener for subscription events
val listener = object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
val label = if (isLive) "LIVE" else "STORED"
println("[$label] ${event.pubKey.take(8)}...: ${event.content.take(80)}")
}
override fun onEose(relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
println("--- End of stored events from $relay ---")
}
}
// Build filters per relay
val filters = mapOf(
RelayUrlNormalizer.normalize("wss://nos.lol") to listOf(
Filter(kinds = listOf(1), limit = 50),
),
)
// Subscribe
client.subscribe("my-feed", filters, listener)
delay(15_000)
// Unsubscribe and clean up
client.unsubscribe("my-feed")
client.disconnect()
scope.cancel()
httpClient.close()
}
```
### 5. Publish Events
Create a key pair, sign an event, and publish it to relays:
```kotlin
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.*
fun main() = runBlocking {
val httpClient = HttpClient(CIO) { install(WebSockets) }
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(KtorWebSocket.Builder(httpClient), scope)
// 1. Create a key pair (generates random keys)
val signer = NostrSignerInternal(KeyPair())
println("Public key: ${signer.pubKey}")
// 2. Build and sign a text note (kind 1)
val event = signer.sign(TextNoteEvent.build("Hello from Quartz!"))
println("Event ID: ${event.id}")
// 3. Publish and wait for relay confirmation
val accepted = client.publishAndConfirm(
event = event,
relayList = setOf(
"wss://nos.lol".normalizeRelayUrl(),
"wss://relay.damus.io".normalizeRelayUrl(),
),
)
println("Published: $accepted")
// 4. Clean up
client.disconnect()
scope.cancel()
httpClient.close()
}
```
For detailed per-relay results, use `publishAndConfirmDetailed`:
```kotlin
val results = client.publishAndConfirmDetailed(event, relayList)
results.forEach { (relay, success) ->
println(" $relay: ${if (success) "accepted" else "rejected"}")
}
```
## How It Works
### Architecture
```
┌──────────────────────────────────────────────┐
│ Your Application │
│ │
│ subscribeAsFlow() / subscribe() / publish()│
└─────────────────────┬────────────────────────┘
┌──────────────────────────────────────────────┐
│ NostrClient │
│ │
│ PoolRequests (active subscriptions) │
│ PoolCounts (count queries) │
│ PoolEventOutbox (pending publishes) │
└─────────────────────┬────────────────────────┘
┌──────────────────────────────────────────────┐
│ RelayPool │
│ │
│ Manages BasicRelayClient per relay URL │
│ Auto-connects/disconnects as needed │
│ Tracks connected/available relay state │
└─────────────────────┬────────────────────────┘
┌──────────────────────────────────────────────┐
│ BasicRelayClient (per relay) │
│ │
│ Connection lifecycle + reconnection │
│ NIP-01 message parsing │
│ Exponential backoff on failures │
└─────────────────────┬────────────────────────┘
┌──────────────────────────────────────────────┐
│ WebsocketBuilder → WebSocket │
│ │
│ KtorWebSocket (this guide) │
│ BasicOkHttpWebSocket (built-in for JVM) │
└──────────────────────────────────────────────┘
```
### Connection Lifecycle
1. You call `subscribe()` or `publish()` with relay URLs
2. `NostrClient` tells `RelayPool` which relays are needed
3. `RelayPool` creates a `BasicRelayClient` for each new relay
4. `BasicRelayClient` uses `WebsocketBuilder` to create and open a `WebSocket`
5. On connection, all pending subscriptions and events are synced automatically
6. On disconnection, exponential backoff retries the connection
7. On reconnection, filters are re-sent and events re-delivered
### Subscription Flow
```
client.subscribe("my-feed", filters, listener)
NostrClient stores filters in PoolRequests
RelayPool connects to needed relays
BasicRelayClient sends ["REQ", "my-feed", <filter>...]
Relay responds with:
["EVENT", "my-feed", <event>] → listener.onEvent(event, isLive=false)
["EVENT", "my-feed", <event>] → listener.onEvent(event, isLive=false)
["EOSE", "my-feed"] → listener.onEose(relay)
["EVENT", "my-feed", <event>] → listener.onEvent(event, isLive=true) ← new events
```
### NIP-01 Commands
| Direction | Command | Description |
|-----------|---------|-------------|
| Client → Relay | `["REQ", subId, filter...]` | Open a subscription |
| Client → Relay | `["CLOSE", subId]` | Close a subscription |
| Client → Relay | `["EVENT", event]` | Publish an event |
| Relay → Client | `["EVENT", subId, event]` | Deliver a matching event |
| Relay → Client | `["EOSE", subId]` | End of stored events |
| Relay → Client | `["OK", eventId, success, message]` | Publish acknowledgement |
| Relay → Client | `["NOTICE", message]` | Relay notice |
## Filters
Filters define what events a subscription matches. All fields are optional — omitted fields match everything.
```kotlin
// Latest 20 text notes
Filter(kinds = listOf(1), limit = 20)
// Metadata for specific authors
Filter(
kinds = listOf(0),
authors = listOf(
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
),
)
// Events since a timestamp
Filter(kinds = listOf(1), since = 1700000000)
// Events tagged with a specific pubkey
Filter(
kinds = listOf(1),
tags = mapOf("p" to listOf("32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")),
)
// Full-text search (NIP-50, relay must support it)
Filter(kinds = listOf(1), search = "bitcoin", limit = 10)
```
### Filter Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `ids` | `List<HexKey>?` | Match specific event IDs (64-char hex) |
| `authors` | `List<HexKey>?` | Match specific author pubkeys (64-char hex) |
| `kinds` | `List<Int>?` | Match event kinds (0=metadata, 1=text note, 3=contacts, etc.) |
| `tags` | `Map<String, List<String>>?` | Match events with any of the listed tag values |
| `since` | `Long?` | Events with `created_at` >= this Unix timestamp |
| `until` | `Long?` | Events with `created_at` <= this Unix timestamp |
| `limit` | `Int?` | Maximum number of events to return |
| `search` | `String?` | Full-text search (NIP-50) |
## Key Management
```kotlin
// Generate a new random key pair
val keyPair = KeyPair()
// Import an existing private key (32 bytes)
val keyPair = KeyPair(privKey = hexToByteArray("your-64-char-hex-private-key"))
// Read-only (public key only, cannot sign)
val keyPair = KeyPair(pubKey = hexToByteArray("your-64-char-hex-public-key"))
// Create a signer for signing events
val signer = NostrSignerInternal(keyPair)
```
## Event Kinds
Common event kinds you'll work with:
| Kind | NIP | Class | Description |
|------|-----|-------|-------------|
| 0 | NIP-01 | `MetadataEvent` | User metadata (name, picture, about) |
| 1 | NIP-01 | `TextNoteEvent` | Text note (short post) |
| 3 | NIP-02 | `ContactListEvent` | Contact list / follow list |
| 4 | NIP-04 | `PrivateDmEvent` | Encrypted direct message |
| 7 | NIP-25 | `ReactionEvent` | Reaction (like, emoji) |
| 30023 | NIP-23 | `LongTextNoteEvent` | Long-form content (articles) |
## Multi-Relay Subscriptions
Subscribe to different filters on different relays:
```kotlin
val filters = mapOf(
RelayUrlNormalizer.normalize("wss://nos.lol") to listOf(
Filter(kinds = listOf(1), limit = 50),
),
RelayUrlNormalizer.normalize("wss://relay.damus.io") to listOf(
Filter(kinds = listOf(0, 3), authors = listOf(myPubKey)),
),
)
client.subscribe("multi-relay-feed", filters, listener)
```
## Using OkHttp Instead of Ktor
Quartz ships with a built-in OkHttp WebSocket implementation for JVM/Android:
```kotlin
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import okhttp3.OkHttpClient
val httpClient = OkHttpClient.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> httpClient }
val client = NostrClient(socketBuilder, scope)
```
## Full Example: Simple Feed Reader
```kotlin
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 io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.websocket.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.take
fun main() = runBlocking {
val httpClient = HttpClient(CIO) { install(WebSockets) }
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(KtorWebSocket.Builder(httpClient), scope)
println("Connecting to relay...")
client.subscribeAsFlow(
relay = "wss://nos.lol",
filter = Filter(kinds = listOf(1), limit = 10),
).take(1).collect { events ->
println("\n=== Latest ${events.size} text notes ===\n")
events.forEach { event ->
println("Author: ${event.pubKey.take(16)}...")
println("Content: ${event.content.take(120)}")
println("---")
}
}
client.disconnect()
scope.cancel()
httpClient.close()
}
```
## Key Source Files
All client infrastructure lives in the `quartz` module:
```
quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/
├── relay/client/
│ ├── NostrClient.kt # Main client (manages relay pool + subscriptions)
│ ├── INostrClient.kt # Client interface
│ ├── reqs/
│ │ ├── SubscriptionListener.kt # Callback interface for subscriptions
│ │ └── NostrClientSubscribeAsFlowExt.kt # Flow-based subscription API
│ └── accessories/
│ └── NostrClientPublishExt.kt # publishAndConfirm / publishAndConfirmDetailed
├── relay/sockets/
│ ├── WebSocket.kt # Transport interface
│ ├── WebSocketListener.kt # Transport callback interface
│ └── WebsocketBuilder.kt # Transport factory interface
├── relay/filters/
│ └── Filter.kt # NIP-01 subscription filters
├── crypto/
│ ├── KeyPair.kt # Schnorr key pair
│ └── Nip01Crypto.kt # Low-level crypto operations
└── signers/
├── NostrSigner.kt # Abstract signer
└── NostrSignerInternal.kt # Signs with local private key
```
+212
View File
@@ -0,0 +1,212 @@
# Building a Nostr Relay with Quartz
Quartz provides a transport-agnostic relay engine. You provide a `send` callback per connection, and it gives you a `RelaySession` that accepts raw JSON strings. Plug it into Ktor or any WebSocket transport.
Both `NostrServer` and `EventStore` implement `AutoCloseable`.
## Quick Start
### 1. Add Dependencies
```kotlin
dependencies {
implementation("io.ktor:ktor-server-core:3.1.1")
implementation("io.ktor:ktor-server-netty:3.1.1")
implementation("io.ktor:ktor-server-websockets:3.1.1")
implementation(project(":quartz"))
}
```
### 2. Create the Relay Server
```kotlin
fun main() {
val store = EventStore(dbName = "relay-events.db")
val server = NostrServer(store)
embeddedServer(Netty, port = 7777) {
install(WebSockets)
routing {
webSocket("/") {
server.serve(
send = { json -> launch { send(Frame.Text(json)) } },
) { session ->
for (frame in incoming) {
if (frame is Frame.Text) {
session.receive(frame.readText())
}
}
}
}
}
}.start(wait = true)
}
```
That's it. You now have a NIP-01 compliant relay running on `ws://localhost:7777`.
## Event Store
### SQLite (Persistent)
```kotlin
val store = EventStore(dbName = "relay-events.db")
```
Uses `androidx.sqlite` with WAL journal mode and a 32 MB memory cache.
### In-Memory (Testing)
```kotlin
val store = EventStore(null)
```
### Indexing Strategy
Control which indexes are created:
```kotlin
val store = EventStore(
dbName = "relay-events.db",
indexStrategy = DefaultIndexingStrategy(
indexEventsByCreatedAtAlone = false,
indexTagsByCreatedAtAlone = false,
indexTagsWithKindAndPubkey = false,
useAndIndexIdOnOrderBy = false,
),
)
```
By default, all single-letter tags with values are indexed. Override `shouldIndex(kind, tag)` for custom behavior. More indexes = faster queries but larger database.
## Policies
Policies control what clients can do. They validate commands and can rewrite filters.
### Built-in Policies
**`VerifyPolicy`** (default) — Verifies event signatures and IDs. Rejects malformed events.
```kotlin
val server = NostrServer(store) // Uses VerifyPolicy by default
```
**`EmptyPolicy`** — Accepts everything. Useful for testing.
```kotlin
val server = NostrServer(store, policyBuilder = { EmptyPolicy })
```
**`FullAuthPolicy`** — Requires NIP-42 authentication before accepting any command.
```kotlin
val server = NostrServer(
store = store,
policyBuilder = {
FullAuthPolicy(relay = "wss://myrelay.example.com/".normalizeRelayUrl()!!)
},
)
```
### Composing Policies
Chain policies with `+` or `PolicyStack`. All must approve; first rejection wins.
```kotlin
val server = NostrServer(
store = store,
policyBuilder = {
VerifyPolicy + FullAuthPolicy(relay = "wss://myrelay.example.com/".normalizeRelayUrl()!!)
},
)
```
### Writing a Custom Policy
Implement `IRelayPolicy`:
```kotlin
class KindWhitelistPolicy(
private val allowedKinds: Set<Int>,
) : IRelayPolicy {
override fun onConnect(send: (Message) -> Unit) { }
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> =
if (cmd.event.kind in allowedKinds) {
PolicyResult.Accepted(cmd)
} else {
PolicyResult.Rejected("blocked: kind ${cmd.event.kind} not allowed")
}
override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd)
override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd)
override fun accept(cmd: AuthCmd) = PolicyResult.Accepted(cmd)
}
```
Use it:
```kotlin
val server = NostrServer(
store = store,
policyBuilder = {
VerifyPolicy + KindWhitelistPolicy(allowedKinds = setOf(0, 1, 3, 7, 30023))
},
)
```
## Testing
```kotlin
@OptIn(ExperimentalCoroutinesApi::class)
class MyRelayTest {
@Test
fun clientCanPublishAndSubscribe() = runTest {
val dispatcher = UnconfinedTestDispatcher(testScheduler)
NostrServer(
store = EventStore(null),
policyBuilder = { EmptyPolicy },
parentContext = dispatcher,
).use { server ->
val messages = mutableListOf<String>()
val session = server.connect { messages.add(it) }
session.receive("""["EVENT",{"id":"${"0".repeat(64)}","pubkey":"${"a".repeat(64)}","created_at":1000,"kind":1,"tags":[],"content":"hello","sig":"${"b".repeat(128)}"}]""")
assertTrue(messages.any { it.contains("OK") })
session.receive("""["REQ","sub1",{"kinds":[1]}]""")
assertTrue(messages.any { it.contains("EVENT") })
assertTrue(messages.any { it.contains("EOSE") })
}
}
}
```
## Key Source Files
```
quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/
├── relay/server/
│ ├── NostrServer.kt # Main entry point
│ ├── RelaySession.kt # Per-connection handler
│ ├── LiveEventStore.kt # Reactive event streaming
│ ├── IRelayPolicy.kt # Policy interface + PolicyResult
│ └── policies/
│ ├── EmptyPolicy.kt # Accept everything
│ ├── VerifyPolicy.kt # Signature verification (default)
│ ├── FullAuthPolicy.kt # NIP-42 auth required
│ └── PolicyStack.kt # Chain multiple policies
├── store/
│ ├── IEventStore.kt # Storage interface
│ └── sqlite/
│ ├── EventStore.kt # Public SQLite store wrapper
│ ├── SQLiteEventStore.kt # Full implementation
│ └── IndexingStrategy.kt # Index configuration
└── relay/filters/
├── Filter.kt # NIP-01 subscription filters
└── FilterMatcher.kt # Event-to-filter matching
```
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
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.policies.PolicyStack
/**
* Defines custom behavior for this relay.
@@ -76,7 +77,7 @@ interface IRelayPolicy {
*/
fun canSendToSession(event: Event): Boolean = true
operator fun plus(other: IRelayPolicy) = listOf(this, other)
operator fun plus(other: IRelayPolicy): IRelayPolicy = PolicyStack(this, other)
}
sealed interface PolicyResult<T : Command> {
@@ -41,7 +41,7 @@ class NostrServer(
private val store: IEventStore,
private val policyBuilder: () -> IRelayPolicy = { VerifyPolicy },
private val parentContext: CoroutineContext = SupervisorJob(),
) {
) : AutoCloseable {
private val subStore = LiveEventStore(store)
/** Scope for all subscriptions. */
@@ -70,11 +70,38 @@ class NostrServer(
}
/**
* Shuts down the server, cancelling all subscriptions and sessions.
* Registers a new client connection and serves it for the duration of
* [incoming]. The session is automatically closed when [incoming] returns.
*
* @param send Callback the server uses to send JSON messages to this client.
* @param incoming Suspend block that yields raw JSON strings from the client
* (e.g., reading WebSocket text frames in a loop).
*/
fun shutdown() {
suspend fun serve(
send: (String) -> Unit,
incoming: suspend (RelaySession) -> Unit,
) {
val session = connect(send)
try {
incoming(session)
} finally {
session.close()
}
}
/**
* Shuts down the server, cancelling all subscriptions and closing the store.
*/
override fun close() {
connections.forEach { _, session -> session.cancelAllSubscriptions() }
connections.clear()
scope.cancel()
store.close()
}
/**
* Shuts down the server, cancelling all subscriptions and closing the store.
*/
@Deprecated("Use close() instead", replaceWith = ReplaceWith("close()"))
fun shutdown() = close()
}
@@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.store
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
interface IEventStore {
interface IEventStore : AutoCloseable {
fun insert(event: Event)
interface ITransaction {
@@ -56,5 +56,5 @@ interface IEventStore {
fun deleteExpiredEvents()
fun close()
override fun close()
}
@@ -0,0 +1,23 @@
/*
* 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.nip89AppHandlers.definition
fun AppDefinitionEvent.platformLinks() = tags.platformLinks()
@@ -0,0 +1,26 @@
/*
* 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.nip89AppHandlers.definition
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.PlatformLinkTag
fun TagArray.platformLinks() = this.mapNotNull(PlatformLinkTag::parse)
@@ -41,7 +41,7 @@ class PlatformLinkTag(
}
fun parse(tag: Tag): PlatformLinkTag? {
if (match(tag)) return PlatformLinkTag(tag[1], tag[2], tag.getOrNull(3))
if (match(tag)) return PlatformLinkTag(tag[0], tag[1], tag.getOrNull(2))
return null
}
@@ -0,0 +1,28 @@
/*
* 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.nip89AppHandlers.recommendation
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.tags.RecommendationTag
fun TagArray.recommendations() = this.mapNotNull(RecommendationTag::parse)
fun TagArray.recommendationAddresses() = this.mapNotNull(RecommendationTag::parseAddressId)
@@ -142,7 +142,7 @@ class NostrServerAuthTest {
assertEquals(1, collector.messages.size)
assertTrue(collector.messages[0].contains("\"AUTH\""))
server.shutdown()
server.close()
}
@Test
@@ -167,7 +167,7 @@ class NostrServerAuthTest {
assertTrue((session.policy as FullAuthPolicy).isAuthenticated())
assertTrue(session.policy.authenticatedUsers.contains(pubkey))
server.shutdown()
server.close()
}
@Test
@@ -188,7 +188,7 @@ class NostrServerAuthTest {
assertTrue(okMessages[0].contains("challenge"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
server.shutdown()
server.close()
}
@Test
@@ -213,7 +213,7 @@ class NostrServerAuthTest {
assertTrue(okMessages[0].contains("relay url"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
server.shutdown()
server.close()
}
@Test
@@ -242,7 +242,7 @@ class NostrServerAuthTest {
assertTrue(okMessages[0].contains("created_at"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
server.shutdown()
server.close()
}
@Test
@@ -281,7 +281,7 @@ class NostrServerAuthTest {
assertTrue(okMessages[0].contains("could not parse message"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
server.shutdown()
server.close()
}
@Test
@@ -315,7 +315,7 @@ class NostrServerAuthTest {
assertTrue(authedPubkeys.contains(pubkey))
assertTrue(authedPubkeys.contains(pubkey2))
server.shutdown()
server.close()
}
// -- NIP-42: requireAuth ---------------------------------------------------
@@ -337,7 +337,7 @@ class NostrServerAuthTest {
assertTrue(okMessages[0].contains("\"false\""))
assertTrue(okMessages[0].contains("auth-required:"))
server.shutdown()
server.close()
}
@Test
@@ -354,7 +354,7 @@ class NostrServerAuthTest {
assertEquals(1, closedMessages.size)
assertTrue(closedMessages[0].contains("auth-required:"))
server.shutdown()
server.close()
}
@Test
@@ -371,7 +371,7 @@ class NostrServerAuthTest {
assertEquals(1, closedMessages.size)
assertTrue(closedMessages[0].contains("auth-required:"))
server.shutdown()
server.close()
}
@Test
@@ -410,7 +410,7 @@ class NostrServerAuthTest {
val eoseMessages = collector.rawMessagesContaining("EOSE")
assertTrue(eoseMessages.isNotEmpty())
server.shutdown()
server.close()
}
@Test
@@ -430,7 +430,7 @@ class NostrServerAuthTest {
assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"true\""))
server.shutdown()
server.close()
}
// -- Custom AuthPolicy tests -----------------------------------------------
@@ -471,7 +471,7 @@ class NostrServerAuthTest {
assertTrue(okMessages[1].contains("\"false\""))
assertTrue(okMessages[1].contains("auth-required:"))
server.shutdown()
server.close()
}
@Test
@@ -531,6 +531,6 @@ class NostrServerAuthTest {
assertEquals(1, events.size)
assertEquals(pubkey, events[0].event.pubKey)
server.shutdown()
server.close()
}
}
@@ -115,7 +115,7 @@ class NostrServerTest {
val stored = store.query<Event>(Filter(ids = listOf(event.id)))
assertEquals(1, stored.size)
server.shutdown()
server.close()
}
@Test
@@ -138,7 +138,7 @@ class NostrServerTest {
assertTrue(okMessages[0].contains("\"true\""))
assertTrue(okMessages[1].contains("\"false\""))
server.shutdown()
server.close()
}
// -- REQ command -----------------------------------------------------------
@@ -172,7 +172,7 @@ class NostrServerTest {
// Events should be newest first
assertTrue(events[0].event.createdAt >= events[1].event.createdAt)
server.shutdown()
server.close()
}
@Test
@@ -195,7 +195,7 @@ class NostrServerTest {
val events = collector.parsedEventMessages().filterIsInstance<EventMessage>()
assertEquals(3, events.size)
server.shutdown()
server.close()
}
// -- Live subscription -----------------------------------------------------
@@ -226,7 +226,7 @@ class NostrServerTest {
assertTrue(newMessages.isNotEmpty())
assertTrue(newMessages[0].contains("\"EVENT\""))
server.shutdown()
server.close()
}
@Test
@@ -251,7 +251,7 @@ class NostrServerTest {
assertEquals(countAfterEose, collector1.messages.size)
server.shutdown()
server.close()
}
// -- CLOSE command ---------------------------------------------------------
@@ -282,7 +282,7 @@ class NostrServerTest {
assertEquals(countAfterClose, collector1.messages.size)
server.shutdown()
server.close()
}
@Test
@@ -318,7 +318,7 @@ class NostrServerTest {
assertTrue(newMessages[0].contains("\"EVENT\""))
assertTrue(newMessages[0].contains(hexId(2)))
server.shutdown()
server.close()
}
// -- COUNT command (NIP-45) ------------------------------------------------
@@ -344,7 +344,7 @@ class NostrServerTest {
assertEquals(1, countMessages.size)
assertTrue(countMessages[0].contains("\"count\":2"))
server.shutdown()
server.close()
}
// -- Disconnect ------------------------------------------------------------
@@ -374,7 +374,7 @@ class NostrServerTest {
assertEquals(countAfterDisconnect, collector1.messages.size)
assertEquals(2, collector2.messages.size)
server.shutdown()
server.close()
}
// -- Invalid messages ------------------------------------------------------
@@ -392,6 +392,6 @@ class NostrServerTest {
assertEquals(1, collector.messages.size)
assertTrue(collector.messages[0].contains("NOTICE"))
server.shutdown()
server.close()
}
}
@@ -0,0 +1,97 @@
/*
* 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.nip89AppHandlers.definition
import com.vitorpamplona.quartz.nip89AppHandlers.definition.tags.PlatformLinkTag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class PlatformLinkTagTest {
@Test
fun parsesWebPlatformLinkWithEntityType() {
val tag = arrayOf("web", "https://example.com/a/<bech32>", "nevent")
val result = PlatformLinkTag.parse(tag)
assertNotNull(result)
assertEquals("web", result.platform)
assertEquals("https://example.com/a/<bech32>", result.uri)
assertEquals("nevent", result.entityType)
}
@Test
fun parsesAndroidPlatformLink() {
val tag = arrayOf("android", "amethyst://note/<bech32>", "note")
val result = PlatformLinkTag.parse(tag)
assertNotNull(result)
assertEquals("android", result.platform)
assertEquals("amethyst://note/<bech32>", result.uri)
assertEquals("note", result.entityType)
}
@Test
fun parsesIosPlatformLink() {
val tag = arrayOf("ios", "damus://note/<bech32>", "naddr")
val result = PlatformLinkTag.parse(tag)
assertNotNull(result)
assertEquals("ios", result.platform)
assertEquals("damus://note/<bech32>", result.uri)
assertEquals("naddr", result.entityType)
}
@Test
fun rejectsTagWithOnlyOneElement() {
val tag = arrayOf("web")
assertNull(PlatformLinkTag.parse(tag))
}
@Test
fun rejectsUnknownPlatform() {
val tag = arrayOf("windows", "https://example.com", "note")
assertNull(PlatformLinkTag.parse(tag))
}
@Test
fun roundTripPreservesValues() {
val original = PlatformLinkTag("web", "https://example.com/e/<bech32>", "nevent")
val tagArray = original.toTagArray()
val parsed = PlatformLinkTag.parse(tagArray)
assertNotNull(parsed)
assertEquals(original.platform, parsed.platform)
assertEquals(original.uri, parsed.uri)
assertEquals(original.entityType, parsed.entityType)
}
@Test
fun roundTripWithoutEntityType() {
val original = PlatformLinkTag("android", "amethyst://open/<bech32>", null)
val tagArray = original.toTagArray()
val parsed = PlatformLinkTag.parse(tagArray)
// Without entityType, tag only has 2 elements, so match requires has(2) which means 3 elements
// This is expected behavior per NIP-89 spec (entityType is specified)
assertNull(parsed)
}
}