- NostrServer and IEventStore implement AutoCloseable for .use {} support
- Add NostrServer.serve() to handle session lifecycle automatically
- IRelayPolicy + operator now returns PolicyStack instead of List
- Deprecate shutdown() in favor of close()
- Update RELAY.md guide to use simplified API patterns
https://claude.ai/code/session_013oL9PkQaFyNQHKVg2vw9qs
18 KiB
Building a Nostr Relay with Quartz
This guide walks you through building a fully functional Nostr relay using Quartz's NostrServer, the SQLite EventStore, and Ktor as the WebSocket transport layer.
Overview
Quartz provides a complete, transport-agnostic relay engine:
| Component | Class | Role |
|---|---|---|
| NostrServer | com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer |
Coordinates connections, sessions, and event routing |
| EventStore | com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore |
SQLite-backed persistent event storage |
| RelaySession | com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession |
Manages a single client connection and its subscriptions |
| IRelayPolicy | com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy |
Pluggable access control and validation |
NostrServer doesn't know about HTTP or WebSockets. You provide a send callback per connection, and it gives you a RelaySession that accepts raw JSON strings. This makes it trivial to plug into Ktor (or any other transport).
Both NostrServer and EventStore implement AutoCloseable, so you can use Kotlin's .use {} for automatic resource cleanup.
Quick Start
1. Add Dependencies
In your build.gradle.kts:
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")
// Quartz (use your local module or published artifact)
implementation(project(":quartz"))
}
2. Create the Relay Server
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
fun main() {
// 1. Create the event store (SQLite, persisted to disk)
val store = EventStore(
dbName = "relay-events.db",
)
// 2. Create the NostrServer with the default VerifyPolicy
val server = NostrServer(store)
// 3. Start Ktor with WebSocket support
embeddedServer(Netty, port = 7777) {
install(WebSockets)
routing {
webSocket("/") {
// 4. Serve this WebSocket connection (auto-cleanup on disconnect)
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.
The serve method creates a RelaySession, passes it to your block, and automatically closes the session when the block completes (normally or via exception). No try/finally needed.
Tip: If you need direct control over the session lifecycle, use
connect()with.use {}:server.connect { json -> launch { send(Frame.Text(json)) } }.use { session -> for (frame in incoming) { if (frame is Frame.Text) session.receive(frame.readText()) } }
How It Works
Connection Lifecycle
Client connects via WebSocket
│
▼
server.serve(send) { session -> ... }
│
▼
RelaySession created
(policy.onConnect called, e.g. AUTH challenge sent)
│
▼
┌───────────────────────────────┐
│ For each incoming text frame │
│ session.receive(jsonString) │──► Parses NIP-01 command
│ │──► Validates via policy
│ │──► Dispatches to handler
└───────────────────────────────┘
│
▼
Client disconnects → session auto-closed
│
▼
All subscriptions cancelled
Supported NIP-01 Commands
| Client Command | Handler | Description |
|---|---|---|
["EVENT", <event>] |
handleEvent |
Publishes an event. Policy validates, store persists, OK response sent. |
["REQ", <sub_id>, <filter>...] |
handleReq |
Opens a subscription. Returns matching historical events, EOSE, then streams live matches. |
["CLOSE", <sub_id>] |
handleClose |
Cancels a subscription. |
["COUNT", <query_id>, <filter>...] |
handleCount |
Returns the count of matching events (NIP-45). |
["AUTH", <event>] |
handleAuth |
Authenticates the client (NIP-42). |
Live Subscriptions
When a client sends REQ, the relay:
- Queries the
EventStorefor all matching historical events - Sends each as an
EVENTmessage - Sends
EOSE(End of Stored Events) - Keeps the subscription open - any new event inserted by any client that matches the filter is automatically pushed
This is handled by LiveEventStore, which wraps the store with a SharedFlow that broadcasts new events to all active subscriptions.
Event Store
SQLite (Persistent)
val store = EventStore(
dbName = "relay-events.db",
)
The SQLite store uses androidx.sqlite with the bundled driver (no native SQLite dependency needed). It features:
- WAL journal mode for concurrent reads/writes
- 32 MB memory cache for fast queries
- Modular architecture with pluggable processing modules:
| Module | NIP | Purpose |
|---|---|---|
SeedModule |
NIP-01 | Core event storage and retrieval |
EventIndexesModule |
NIP-01 | Indexes for efficient filtering |
ReplaceableModule |
NIP-16 | Replaces older versions of replaceable events |
AddressableModule |
NIP-33 | Handles parameterized replaceable events |
EphemeralModule |
NIP-01 | Rejects ephemeral events from persistence |
DeletionRequestModule |
NIP-09 | Processes deletion requests |
ExpirationModule |
NIP-40 | Handles event expiration timestamps |
RightToVanishModule |
— | Cleans up ephemeral data |
FullTextSearchModule |
NIP-50 | Full-text search on event content |
In-Memory (Testing)
Pass null as the database name for a purely in-memory store (no disk I/O):
val store = EventStore(null)
Indexing Strategy
Control which indexes are created via IndexingStrategy:
val store = EventStore(
dbName = "relay-events.db",
indexStrategy = DefaultIndexingStrategy(
// Enable if you receive many filter-by-time-only queries
indexEventsByCreatedAtAlone = false,
// Enable if you receive many tag-only queries without kind
indexTagsByCreatedAtAlone = false,
// Enable for queries combining tags + kind + author
indexTagsWithKindAndPubkey = false,
// Enable for spec-compliant ordering when created_at collides
useAndIndexIdOnOrderBy = false,
),
)
By default, all single-letter tags with values are indexed (e.g., #e, #p, #t). Override shouldIndex(kind, tag) for custom behavior.
Tip: More indexes = faster queries but larger database. Only enable what your relay's query patterns actually need.
Policies
Policies control what clients can do. They validate commands and can rewrite filters before execution.
Built-in Policies
VerifyPolicy (default)
Verifies event signatures and IDs. Rejects malformed events. Allows all REQ and COUNT commands.
val server = NostrServer(store) // Uses VerifyPolicy by default
EmptyPolicy
Accepts everything without any validation. Useful for testing or trusted environments.
val server = NostrServer(store, policyBuilder = { EmptyPolicy })
FullAuthPolicy
Requires NIP-42 authentication before accepting any EVENT, REQ, or COUNT commands. Sends an AUTH challenge on connect.
val server = NostrServer(
store = store,
policyBuilder = {
FullAuthPolicy(relay = "wss://myrelay.example.com/".normalizeRelayUrl()!!)
},
)
Validates:
- Challenge string matches
- Relay URL matches
- Timestamp is within 10 minutes
- Event signature is valid
Composing Policies
Chain multiple policies using the + operator or PolicyStack. All must approve; the first rejection wins. Policies run in order and can rewrite commands for downstream policies.
// Using the + operator
val server = NostrServer(
store = store,
policyBuilder = {
VerifyPolicy + FullAuthPolicy(relay = "wss://myrelay.example.com/".normalizeRelayUrl()!!)
},
)
// Or using PolicyStack for three or more policies
val server = NostrServer(
store = store,
policyBuilder = {
PolicyStack(
VerifyPolicy,
FullAuthPolicy(relay = "wss://myrelay.example.com/".normalizeRelayUrl()!!),
MyCustomRateLimitPolicy(),
)
},
)
Writing a Custom Policy
Implement IRelayPolicy:
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
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:
val server = NostrServer(
store = store,
policyBuilder = {
VerifyPolicy + KindWhitelistPolicy(allowedKinds = setOf(0, 1, 3, 7, 30023))
},
)
Policy interface methods:
| Method | When Called | Return |
|---|---|---|
onConnect(send) |
New client connects | Send welcome messages (e.g., AUTH challenge) |
accept(EventCmd) |
Client publishes an event | Accepted or Rejected("reason") |
accept(ReqCmd) |
Client opens a subscription | Accepted (may rewrite filters) or Rejected |
accept(CountCmd) |
Client requests a count | Accepted or Rejected |
accept(AuthCmd) |
Client authenticates | Accepted or Rejected |
canSendToSession(event) |
Live event about to be forwarded | true to deliver, false to suppress |
Full Example: Production-Ready Relay
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.launch
fun main() {
val store = EventStore(
dbName = "relay-events.db",
relay = "wss://myrelay.example.com/".normalizeRelayUrl(),
indexStrategy = DefaultIndexingStrategy(
indexEventsByCreatedAtAlone = true,
),
)
// NostrServer implements AutoCloseable — .use {} ensures clean shutdown
NostrServer(
store = store,
policyBuilder = { VerifyPolicy },
).use { server ->
embeddedServer(Netty, port = 7777) {
install(WebSockets) {
pingPeriodMillis = 30_000
timeoutMillis = 60_000
maxFrameSize = Long.MAX_VALUE
}
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)
}
}
Architecture Diagram
┌─────────────────────────────────────────────────┐
│ Ktor Server │
│ │
│ WebSocket("/") │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ NostrServer │ │
│ │ │ │
│ │ serve(send) { session -> ... } │ │
│ │ │ │ │
│ │ ├─ IRelayPolicy │ │
│ │ │ (validate) │ │
│ │ │ │ │
│ │ ├─ LiveEventStore │ │
│ │ │ (query + live) │ │
│ │ │ │ │
│ │ └─ Subscriptions │ │
│ │ (per client) │ │
│ └────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ EventStore (SQLite) │ │
│ │ │ │
│ │ insert / query / count / delete │ │
│ │ │ │
│ │ Modules: │ │
│ │ Seed ─ Replaceable ─ Addressable │ │
│ │ Deletion ─ Expiration ─ FTS │ │
│ └────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
Testing
Use the in-memory store and UnconfinedTestDispatcher for deterministic tests:
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class MyRelayTest {
@Test
fun clientCanPublishAndSubscribe() = runTest {
val dispatcher = UnconfinedTestDispatcher(testScheduler)
NostrServer(
store = EventStore(null),
policyBuilder = { EmptyPolicy },
parentContext = dispatcher,
).use { server ->
// Collect messages sent to this client
val messages = mutableListOf<String>()
val session = server.connect { messages.add(it) }
// Publish an event
session.receive("""["EVENT",{"id":"${"0".repeat(64)}","pubkey":"${"a".repeat(64)}","created_at":1000,"kind":1,"tags":[],"content":"hello","sig":"${"b".repeat(128)}"}]""")
// Verify OK response
assertTrue(messages.any { it.contains("OK") })
// Subscribe to kind 1
session.receive("""["REQ","sub1",{"kinds":[1]}]""")
// Verify we get the event back + EOSE
assertTrue(messages.any { it.contains("EVENT") })
assertTrue(messages.any { it.contains("EOSE") })
}
}
}
NIP Support
The relay engine and SQLite store support the following NIPs out of the box:
| NIP | Feature | Component |
|---|---|---|
| NIP-01 | Basic protocol (EVENT, REQ, CLOSE, EOSE, OK, NOTICE) | NostrServer, RelaySession |
| NIP-09 | Event deletion | DeletionRequestModule |
| NIP-16 | Replaceable events | ReplaceableModule |
| NIP-33 | Parameterized replaceable events | AddressableModule |
| NIP-40 | Expiration timestamp | ExpirationModule |
| NIP-42 | Authentication | FullAuthPolicy |
| NIP-45 | Event counts | RelaySession.handleCount |
| NIP-50 | Search | FullTextSearchModule |
Key Source Files
All relay infrastructure lives in the quartz module:
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