Merge pull request #2757 from vitorpamplona/claude/local-test-relays-wjY3g

test(quartz): add :quartz-test-relay for in-process Nostr relay testing
This commit is contained in:
Vitor Pamplona
2026-05-07 10:14:54 -04:00
committed by GitHub
69 changed files with 7418 additions and 416 deletions
+87
View File
@@ -0,0 +1,87 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.jetbrainsKotlinJvm)
alias(libs.plugins.serialization)
application
`java-test-fixtures`
}
application {
mainClass.set("com.vitorpamplona.geode.MainKt")
applicationName = "geode"
}
kotlin {
jvmToolchain(21)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
sourceSets {
main {
kotlin.srcDir("src/main/kotlin")
}
test {
kotlin.srcDir("src/test/kotlin")
}
// The `java-test-fixtures` plugin auto-creates a `testFixtures`
// source set; we just point it at our Kotlin layout so the
// `geode.fixtures` (synthetic events) and `geode.testing`
// (RelayClientTest, collectUntilEose) packages don't ship in
// production jars but are still usable by every consumer's test
// source via `testImplementation(testFixtures(project(":geode")))`.
named("testFixtures") {
kotlin.srcDir("src/testFixtures/kotlin")
}
}
tasks.withType<Test>().configureEach {
// Forward `-DrunLoadBenchmark=true` to the test JVM so the
// perf.LoadBenchmark tests opt in. Off by default — load tests
// are noisy and slow.
systemProperty("runLoadBenchmark", System.getProperty("runLoadBenchmark") ?: "false")
// Show println output from test JVM so the benchmark numbers are
// actually visible without grepping the report XML.
testLogging {
showStandardStreams =
(System.getProperty("runLoadBenchmark") == "true")
events("standard_out")
}
}
dependencies {
api(project(":quartz"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.jackson.module.kotlin)
implementation(libs.kotlinx.serialization.json)
// Bundled SQLite driver — Relay's default in-memory EventStore creates
// an in-memory DB at runtime.
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)
// TOML parsing for the operator config file. Mirrors the section
// layout of nostr-rs-relay's config.toml so existing operators can
// port their configs nearly verbatim.
implementation(libs.fourkoma)
// testFixtures: code in src/testFixtures/kotlin (RelayClientTest +
// synthetic event builders). Not shipped in the production jar but
// exposed to consumers via testImplementation(testFixtures(...)).
testFixturesApi(project(":quartz"))
testFixturesApi(libs.junit)
testFixturesImplementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm)
testImplementation(libs.okhttp)
}
+82
View File
@@ -0,0 +1,82 @@
# Example config for geode. Section layout mirrors
# nostr-rs-relay's config.toml so existing operators can port across.
#
# Run with:
# ./gradlew :geode:run --args="--config /etc/geode.toml"
#
# CLI flags override individual values: e.g. `--port 8888` wins over
# `[network].port`.
[info]
# The wss:// URL clients use to reach this relay (mandatory for NIP-42
# AUTH challenges). If not set, the relay synthesises one from the
# [network] section.
relay_url = "wss://relay.example.com/"
name = "Example Geode"
description = "A geode deployment."
contact = "admin@example.com"
# Operator pubkey (NIP-11). Optional.
# pubkey = "..."
# Override the supported NIPs advertised on the NIP-11 endpoint. If
# omitted, the relay advertises the NIPs it actually implements.
# supported_nips = [1, 9, 11, 40, 42, 45, 50, 62]
[network]
host = "0.0.0.0"
port = 7447
path = "/"
[database]
# True keeps an in-memory SQLite db (events vanish on restart). Useful
# for tests; set false + `file = "..."` for persistent storage.
in_memory = false
file = "/var/lib/geode/events.db"
[options]
# Drop events whose Schnorr signature does not verify. Strongly
# recommended for any relay accepting traffic from real clients.
# Verify Schnorr signatures on every EVENT. Default: true. Disable
# only for trusted-input scenarios (test fixtures, mirror replays).
# verify_signatures = true
# Require clients to NIP-42 AUTH before REQ/EVENT/COUNT.
require_auth = false
# Reject events whose `created_at` is more than this many seconds in
# the future. Enforced by RejectFutureEventsPolicy.
# reject_future_seconds = 1800
[limits]
# Maximum WebSocket frame size. Frames larger than this are dropped at
# the WS layer. (max_ws_message_bytes maps to the same setting since
# Ktor's WebSockets plugin only exposes per-frame caps.)
# max_ws_message_bytes = 1048576
# max_ws_frame_bytes = 1048576
[authorization]
# Allow / deny lists. Allow is a permissive ceiling; deny still
# removes specific entries inside it. Enforced by Pubkey/KindAllowDenyPolicy.
# pubkey_whitelist = ["abcdef...64hex..."]
# pubkey_blacklist = []
# kind_whitelist = [0, 1, 3, 7, 1059, 30023]
# kind_blacklist = [4]
[admin]
# NIP-86 relay management API. When `pubkeys` is non-empty, the relay
# accepts HTTP POST application/nostr+json+rpc on the same URL,
# authenticated with NIP-98 HTTP-Auth. Only events signed by one of
# the listed pubkeys can run admin RPCs (banpubkey / banevent /
# changerelayname / …). Empty (the default) disables the endpoint.
# pubkeys = ["abcdef...64hex..."]
#
# Canonical URL the relay is reachable at, e.g. behind a reverse proxy.
# NIP-98 binds requests to this URL via the `u` tag. **Required** in
# any production deployment — without it, an attacker can spoof the
# Host header to bypass URL binding.
# public_url = "https://relay.example.com/"
# Path for the JSON snapshot that persists NIP-86 admin state (ban
# lists + the live NIP-11 doc) across restarts. When unset, admin
# state is in-memory only and forgotten on every restart. Convention
# is to place this next to the SQLite event-store file.
# state_file = "/var/lib/geode/events.db.admin.json"
@@ -0,0 +1,93 @@
# Connection scaling: pushing past 2 000
## Problem
Current measurement (`LoadBenchmark.connectionsHeldOpen`): **~2 000
concurrent connections** before file-descriptor pressure / Ktor CIO
event-loop saturation. Real-world relays (e.g. nostr.wine, nos.lol)
sustain 1030k. Geode shouldn't be the bottleneck for an Amethyst-
adjacent operator who scales beyond a thousand-user community.
## What's spending memory per connection today
| Cost | Per connection | At 5 000 conns |
| ----------------------- | -------------------------------------------------------- | -------------- |
| `outQueue` Channel | 8 192 string slots × ~8 b ref | ~320 MB pinned |
| `RelaySession` | `LargeCache<String, Job>` for subs (likely 110 entries) | ~negligible |
| `NegSessionRegistry` | `HashMap<String, NegentropyServerSession>` — usually 0 | ~negligible |
| Ktor CIO buffers | TCP read + write buffers | ~10 MB |
| Per-session writer Job | one coroutine | ~few KB |
The `outQueue` reservation is the dominant cost. The 8 192 was sized
for a worst case "thousands of subscriptions, one event matches all" —
but at 5 000 connections we've over-provisioned by ~300 MB just on
the channel array, even though most connections never fan out.
## Sketch
### A — adaptive outQueue capacity
Start every connection with `INITIAL_OUTGOING_BUFFER = 64`. When the
producer side trySends and we observe queue depth crossing a high-water
mark (e.g. 75% full), grow the channel up to `MAX_OUTGOING_BUFFER =
8192`. This is not how `kotlinx.coroutines.channels.Channel` is
structured (capacity is fixed at construction), so the implementation
is "swap in a wider channel under a per-session lock when watermark
trips" — drains the old, then routes new sends through the new.
Expected: 90% of connections never fan out, so they stay at 64 slots
× ~512 B per ref ≈ 32 KB. At 5 000 conns that's ~160 MB → ~5 MB.
Hot-fanout connections still get the 2 MB cap.
### B — per-relay event-loop pool sizing
Ktor CIO defaults to one event-loop thread per available CPU.
Beyond a few thousand connections, this becomes the bottleneck — and
none of geode's per-connection work is CPU-bound (it's mostly waiting
on incoming frames). Tune CIO via:
```kotlin
embeddedServer(CIO, ...) {
connectionGroupSize = max(2, Runtime.getRuntime().availableProcessors() / 2)
workerGroupSize = max(4, Runtime.getRuntime().availableProcessors())
callGroupSize = max(8, Runtime.getRuntime().availableProcessors() * 4)
}
```
Expose these through `RelayConfig.NetworkSection` so an operator on a
big VM can lift them.
### C — reduce per-message JSON allocations
`OptimizedJsonMapper.fromJsonToCommand` allocates a `JsonNode` tree per
incoming frame. At 10k connections with 1 msg/s each that's 10k tree
allocations/sec. Investigate streaming Jackson + reusing `ObjectMapper`
per session, or using kotlinx-serialization's lower-overhead path.
This is more of a quartz-level change than geode-specific, but
geode's load benchmark is the right place to measure it.
## How to verify
Add to `geode.perf.LoadBenchmark`:
- `connectionsHeldOpen10k` — opens 10 000 idle WebSocket connections;
asserts no FD exhaustion + RSS stays under 1 GB.
- `connectionsHeldOpenWithFanout` — 5 000 idle subscribers,
10 EPS published; measures p99 fanout latency at scale.
The current `connectionsHeldOpen` benchmark stays as the baseline
floor (~2 000 conns).
## Risks
- **Adaptive channel swap is fiddly**: drains under the producer's nose
must preserve OK ordering. A simpler alternative: keep capacity fixed,
but lazily allocate a small `ArrayDeque<String>` only when the first
message is sent. Channels in kotlinx.coroutines do allocate up-front.
- **Bumping CIO group sizes can hurt**: more threads can mean worse
L1/L2 locality. Always benchmark before/after, don't trust
intuitive sizing.
- **OS-level FD limit**: per-process FD limit on Linux defaults to
1024 in many environments. Document the `ulimit -n` requirement
for operators targeting >1k connections.
@@ -0,0 +1,91 @@
# Event ingestion: write batching + pipelined OK
## Problem
EVENT acceptance is the hot path on a busy relay — every published note,
every reaction, every DM lands here. Today the per-event flow is fully
serial:
1. `RelaySession.handleEvent` (`quartz/nip01Core/relay/server/RelaySession.kt:131`)
awaits `policy.accept(cmd)` (Schnorr verify if `VerifyPolicy` is in
the stack — ~0.1 ms on JVM).
2. Awaits `store.insert(cmd.event)` — a single SQLite write, guarded by
the connection-pool writer mutex (`SQLiteConnectionPool`).
3. Sends `OkMessage` back through the writer coroutine.
`LoadBenchmark.publishThroughputSingleClient` measured **~760 EPS**;
the concurrent variant **~2000 EPS** (limited by SQLite writer mutex
contention, not WS throughput).
## Constraints we must keep
- **OK ordering**: NIP-01 requires the OK reply to follow its EVENT.
We cannot reply OK before the insert decision (the OK carries
accepted/rejected + reason).
- **Durability semantics**: clients reasonably assume `OK true` means
"stored." Batching must not make us reply OK before fsync.
- **Per-connection FIFO**: a publisher that sends three EVENTs in a
row expects three OKs in that order. Reordering across connections
is fine.
## Sketch
### Tier 1 — SQLite WAL + group commit (cheap win)
Confirm `PRAGMA journal_mode=WAL` + `PRAGMA synchronous=NORMAL` on the
event-store DB; group commits across the writer mutex's hold window.
Today each insert is its own transaction. Wrap N inserts (or a 5 ms
budget, whichever first) in a single transaction managed by the writer
coroutine. On commit, fan back N OK replies.
Implementation lives in quartz's `EventStore` / `SQLiteConnectionPool`,
not geode — but geode owns the benchmark and validates the gain.
Expected: **~510× write throughput** on a fast SSD. SQLite group
commit is well-trodden territory (nostr-rs-relay, strfry both do it).
### Tier 2 — pipelined OK over multiple in-flight EVENTs
`RelaySession.receive` is currently single-flight: one EVENT in,
process, OK out, next EVENT. Allow a connection to push N EVENTs
concurrently, dispatch them to a per-connection ingest pipeline, and
serialise OKs back in arrival order via a small commit log.
A `Channel<EventCmd> with capacity = INGEST_PIPELINE_DEPTH` per
connection, drained by a coroutine that batches into the group-commit
above. OK responses are written to an `outQueue.send()` already — so
the pipeline just needs to record arrival order and emit OKs in that
order after each batch commits.
Expected: hides the verify+insert latency behind another EVENT's
parse, gets us closer to network-bound throughput.
### Tier 3 — eager Schnorr verify off the writer thread
`VerifyPolicy` is in the policy stack and runs synchronously on
`receive`. Move it into the ingest pipeline so verification of EVENT N+1
runs concurrently with the SQLite commit of EVENT N. secp256k1 verify
is parallelisable; the writer should never block on it.
## How to verify
Add to `geode.perf.LoadBenchmark`:
- `publishGroupCommitSingleClient` — same workload as the current
single-client benchmark, asserts >5000 EPS.
- `publishPipelinedSingleClient` — sends 100 EVENTs without awaiting
intermediate OKs; measures end-to-end and OK-ordering correctness.
Existing benchmarks stay as the regression floor.
## Risks
- **Group commit windows**: if a single bad event in the batch fails
validation, we must not roll back the good ones. The batch needs
per-row commit semantics (row-level errors → row-level OK false).
- **Backpressure on slow disks**: deeper pipelines on slow storage
amplify out-of-memory pressure. Cap the in-flight queue depth and
apply existing slow-client backpressure if it fills.
- **Replay protection**: the existing dedupe table needs to see the
event before commit, not after — keep that check inside the writer
coroutine.
@@ -0,0 +1,100 @@
# Live broadcast: indexed filter matching for fanout
## Problem
Every accepted EVENT runs through `LiveEventStore.newEventStream`
(`quartz/nip01Core/relay/server/LiveEventStore.kt:43`) — a
`MutableSharedFlow<Event>` that every active subscription collects.
Each subscriber's collector then calls:
```kotlin
if (filters.any { it.match(newEvent) }) onEach(newEvent)
```
That's **O(N_subscribers × N_filters_per_sub)** per published event.
With 5k connections × ~3 filters average that's 15k Filter.match
calls per EVENT — and each `Filter.match` itself walks `kinds`,
`authors`, tag prefixes, since/until, etc. At 2k EPS ingest that's
~30M comparisons/sec.
Two specific cost shapes:
1. **Filters that almost never match.** Most subscriptions are scoped
to a small author list. Today every published EVENT walks every
such subscription to learn that. A `HashMap<HexKey, MutableList<Sub>>`
keyed by author would cut this to O(1) average for the dominant case.
2. **Pseudo-broadcast filters** (`{kinds: [1]}` with no other
constraint) match almost everything. There's no avoiding the
per-subscriber notification, but at least the index lookup is
cheap.
`LoadBenchmark.fanoutLatency` already measures this — current
results are not yet noted in tree, but back-of-envelope says fanout
becomes the dominant cost above ~2k subscribers.
## Sketch
A new `LiveBroadcastIndex` inside `LiveEventStore`:
```kotlin
private val byAuthor = ConcurrentHashMap<HexKey, MutableSet<Subscription>>()
private val byKind = ConcurrentHashMap<Int, MutableSet<Subscription>>()
private val byTag = ConcurrentHashMap<TagKey, MutableSet<Subscription>>()
private val unindexed = CopyOnWriteArraySet<Subscription>() // subs with no
// narrowing field
```
Each `RelaySession.handleReq` registers its `Subscription` (a tuple of
filters + the existing `EventMessage` send callback) into whichever
buckets each filter narrows on. A filter with `kinds=[1] and
authors=[a,b]` registers into `byKind[1]` AND `byAuthor[a]`,
`byAuthor[b]` — broadcast unions the resulting candidate sets.
On EVENT arrival:
1. Build the candidate set: union of `byAuthor[event.pubkey]`,
`byKind[event.kind]`, every `byTag[(letter, value)]` for the
event's single-letter tags, plus `unindexed`.
2. Run the existing `Filter.match` on each candidate to handle
negative constraints (`since`, `until`, `limit` already-reached,
composite predicates).
3. Send.
Expected: **>10× speedup** on fanout for realistic subscriptions.
Worst case (all filters in `unindexed`) degrades to current behaviour.
## Where it lives
`quartz/nip01Core/relay/server/LiveBroadcastIndex.kt` — protocol-level,
reusable by any relay embed. `RelaySession.handleReq` registers/
unregisters; `LiveEventStore.insert` calls
`index.candidatesFor(event)`.
## How to verify
Add `geode.perf.LoadBenchmark.fanoutScaling`:
- N connections, each subscribes to `{authors: [pk_i], kinds: [1]}`.
- Publish 10k EVENTs from a producer connection; each event matches
exactly one subscriber.
- Measure end-to-end latency p50/p99 for N ∈ {100, 1000, 5000}.
Without the index, p99 grows roughly linearly with N. With the
index, p99 should be flat up to a much higher N.
## Risks
- **Subscription churn**: re-subscribing on every page (the way some
client features work) means many index insert/remove operations.
`ConcurrentHashMap` value-set operations need to be lock-free or
finely locked; benchmark this path explicitly.
- **Tag explosion**: an EVENT with many `e`/`p` tags hits many tag
buckets. Cap candidate-set union work or short-circuit when the
union saturates.
- **Memory**: the index is a per-bucket set of subscription handles.
At 5k subs × average 3 narrowing fields, ~15k entries — negligible.
- **Correctness fence**: the index must see new subscriptions before
the next EVENT broadcast. Today `RelaySession.handleReq` writes its
`Job` into a `LargeCache` then launches the collector. Order of
operations needs to be revisited so the index is updated atomically
with the collector being ready.
@@ -0,0 +1,100 @@
# NIP-77 negentropy at scale: snapshot memory + chunked replay
## Problem
`RelaySession` delegates NEG-OPEN to `NegSessionRegistry.open`
(`quartz/nip01Core/relay/server/NegSessionRegistry.kt`), which calls
`store.snapshotQuery(filters)` and feeds the **entire** result list
into `NegentropyServerSession`. For a relay holding 5 M events that
match a broad NEG-OPEN filter (`{kinds: [1, 7]}`), this is 5 M
`Event` objects materialised in memory before the first NEG-MSG goes
out.
The negentropy library itself is fine — it pivots into a sealed
`StorageVector` (id + createdAt only, ~40 bytes/entry). But the
`store.query<Event>(f)` step that produces the input materialises full
`Event` objects with content, tags, sig — call it ~1 KB/event. 5 M ×
1 KB = 5 GB transient pressure per concurrent NEG-OPEN.
Two operator-visible symptoms:
1. NEG-OPEN with a broad filter spikes JVM heap; under load, GC pause
stalls every other handler on the same process.
2. NEG-OPEN latency before the first NEG-MSG response is O(N) — for
large stores the client waits seconds for what should be a
millisecond round-trip.
## Sketch
### A — id-and-time-only snapshot path
Negentropy only needs `(createdAt, id)` pairs. Add a streaming
`IEventStore.queryIdAndTime(filter)` that returns
`Sequence<Pair<Long, ByteArray>>` (or a `Flow` of small chunks) —
no content/tags/sig, no Event allocation. SQLite path is a SELECT
on `event_headers` (the `created_at`, `id` columns are already
indexed for query plans).
```kotlin
suspend fun snapshotIdsForNegentropy(filter: Filter): IdTimeStream
```
`NegentropyServerSession` is rewritten to take that stream and feed
it directly into the `StorageVector`. Memory drops from O(N × 1 KB)
to O(N × 40 B) — a 25× reduction; for 5 M events, ~200 MB instead
of 5 GB.
### B — bounded-window subscriptions
Most NEG-OPENs from real Nostr clients want the last 30 days, not
"everything." If the client doesn't supply `since`, the server can
default to a configurable horizon (e.g. 90 days) and surface this in
the NIP-11 `limitation.negentropy_max_lookback_seconds` field.
Operators can lift the cap; clients reading the doc know the bound.
This is a NIP-spec-adjacent question more than a code change — needs
a comment on whether the spec allows it. nostr-rs-relay does this
already.
### C — frame-size cap on NEG-MSG
`NegentropyServerSession` is constructed with `frameSizeLimit = 0`
(no limit). At very large reconciliations the message can grow large.
Set a default `frameSizeLimit = 64 * 1024` (matching the typical WS
frame budget) so NEG-MSGs don't blow past `[limits].max_ws_frame_bytes`.
The library already supports this — pure config change in
`NegSessionRegistry.open`.
### D — concurrent NEG-OPEN cap
A NEG-OPEN holds session state until NEG-CLOSE (or connection close).
Today nothing caps the number of concurrent open negentropy sessions
per connection. A misbehaving (or hostile) client could open thousands
and pin RAM. Add `MAX_NEG_SESSIONS_PER_CONNECTION = 16`, send NEG-ERR
on overflow.
## How to verify
Add to `geode.perf.LoadBenchmark`:
- `negentropyOpenLatencyLargeCorpus` — preload 1 M events (use
fixtures), measure NEG-OPEN → first NEG-MSG latency. Target <100 ms.
- `negentropyMemoryPressure` — open 10 concurrent NEG-OPENs on the
same large corpus; measure RSS delta, target <500 MB.
## Risks
- **`Sequence`/`Flow` over SQLite cursor**: holding a cursor open
across the full sync is fragile if the client stalls. Materialise
to a smaller in-memory list (just (id, createdAt)) once, reuse for
the lifetime of the session. Memory bound is the same.
- **Defaulting `since` is a behaviour change**: existing clients that
expect "everything" silently get a bounded window. Either (a) make
it opt-in via `RelayConfig.NegentropySection.default_lookback_seconds
= null`, (b) advertise the cap in NIP-11 so well-behaved clients
read it.
- **Frame-size cap can break older clients**: the NIP-77 reference
implementation (kmp-negentropy) handles this gracefully — multi-frame
reconciliation is in spec — but field-test against a known-working
client (e.g. nstart, primal-cache) before flipping the default.
+19
View File
@@ -0,0 +1,19 @@
# geode plans
Performance-focused design docs for future work. Each file is a
self-contained sketch — problem statement, observed numbers, proposed
fix, how to verify, risks. None of these are committed work; they're
the queue.
Ordered roughly by expected impact:
| Plan | Headline gain |
| ---- | ------------- |
| [2026-05-07-event-ingestion-batching.md](2026-05-07-event-ingestion-batching.md) | 510× write EPS via SQLite group commit + ingest pipelining |
| [2026-05-07-live-broadcast-fanout-index.md](2026-05-07-live-broadcast-fanout-index.md) | >10× fanout speedup at >2 000 subscribers |
| [2026-05-07-connection-scaling.md](2026-05-07-connection-scaling.md) | 2 000 → 10 000+ concurrent connections |
| [2026-05-07-negentropy-large-corpus.md](2026-05-07-negentropy-large-corpus.md) | 25× lower memory + faster NEG-OPEN on M-event corpora |
Verification target for each plan is a new method on
`geode.perf.LoadBenchmark` (gated by `-DrunLoadBenchmark=true`) so
regressions show up in the regular CI matrix once they're enabled.
@@ -0,0 +1,275 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode
import com.vitorpamplona.geode.server.Nip86HttpRoute
import com.vitorpamplona.geode.server.WebSocketSessionPump
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
import 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.post
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import kotlinx.coroutines.runBlocking
import java.util.concurrent.ConcurrentHashMap
/**
* Hosts a [Relay] over a real `ws://` endpoint backed by Ktor + CIO.
*
* Use this when something other than the in-process
* [com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket]
* needs to talk to the relay — Android instrumented tests, the `cli`
* tooling, external clients, or a standalone "run a Nostr relay"
* process.
*
* For unit-test wiring inside a single JVM, prefer [RelayHub] + the
* in-process socket — 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 = "/",
/**
* Per-frame size cap; mirrors `[limits].max_ws_frame_bytes` in the
* config. Frames larger than this are rejected at the WebSocket
* layer, which is the only layer that sees the raw bytes. `null`
* uses Ktor's default (~1 MiB).
*/
val maxFrameBytes: Long? = null,
/**
* Pubkeys allowed to call NIP-86 admin RPCs. Empty (the default)
* disables the admin endpoint entirely — POSTs return 403.
* Otherwise: HTTP POSTs to [path] with `Content-Type:
* application/nostr+json+rpc` are dispatched to [Nip86Server],
* gated by NIP-98 HTTP-Auth membership in this set.
*/
val adminPubkeys: Set<HexKey> = emptySet(),
/**
* Canonical public URL the relay is reachable at, e.g.
* `https://relay.example.com/`. NIP-98 admin requests must sign
* the **same** URL string they're sending to. When the relay sits
* behind TLS termination or a reverse proxy, the `Host` header
* the relay sees does not match what the client signs, so the
* verifier must compare against this configured value.
*
* `null` (the default) falls back to the request's `Host` header
* with `http://` — fine for local-loopback unit tests, **NOT
* SAFE** in a public deployment because an attacker can spoof
* `Host` and bind their signature to any URL.
*/
val publicUrl: String? = null,
/**
* Maximum body size accepted on the NIP-86 POST endpoint, in
* bytes. Bounded *before* auth verification because we read the
* body to compute its sha256 for NIP-98's payload binding —
* unbounded reads would let an unauthenticated attacker stream
* gigabytes and OOM the relay. 1 MiB easily fits any plausible
* RPC payload.
*/
val maxAdminBodyBytes: Int = 1 shl 20,
) {
private val infoHolder =
object : Nip86Server.InfoHolder {
override fun get(): Nip11RelayInformation = relay.info.document
override fun set(info: Nip11RelayInformation) {
relay.updateInfo { info }
}
}
private val nip86Server = Nip86Server(banStore = relay.banStore, infoHolder = infoHolder, store = relay.store)
private val nip86Route =
Nip86HttpRoute(
server = nip86Server,
verifier = Nip98AuthVerifier(),
allowList = adminPubkeys.mapTo(HashSet()) { it.lowercase() },
maxBodyBytes = maxAdminBodyBytes,
signedUrlFor = { call ->
publicUrl ?: ("http://" + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path)
},
)
private var engine: CIOApplicationEngine? = null
private var resolvedPort: Int = -1
/**
* Set when [stop] begins. Once true, the WebSocket handler refuses
* new upgrades — Ktor's `engine.stop` will eventually do this too,
* but Ktor's grace window means new connections can land between
* `notifyShutdown` and the actual port-close, missing the NOTICE
* we just sent to existing clients.
*/
@Volatile
private var shuttingDown: Boolean = false
/**
* Active client sessions, registered when their WebSocket handler
* runs and removed on disconnect. Exposed (read-only) so [stop] can
* NOTICE every connected client during graceful drain, and so tests
* can assert lifecycle bookkeeping.
*/
private val activeSessions: MutableSet<RelaySession> = ConcurrentHashMap.newKeySet()
/** Number of WebSocket sessions currently connected to the server. */
val activeSessionCount: Int get() = activeSessions.size
/** `ws://host:port/path` — only valid after [start]. */
val url: String
get() {
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) {
maxFrameBytes?.let { maxFrameSize = it }
}
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,
)
}
}
// NIP-86: POST application/nostr+json+rpc with a NIP-98
// signed Authorization header → JSON-RPC dispatch.
post(path) {
nip86Route.handle(call)
}
webSocket(path) {
if (shuttingDown) {
// Just return — Ktor closes the WS for us.
return@webSocket
}
WebSocketSessionPump(this).pump(
server = relay.server,
registerSession = activeSessions::add,
unregisterSession = activeSessions::remove,
)
}
}
}
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
}
/**
* Graceful shutdown. Safe to call multiple times.
*
* 1. Sends a NOTICE("closing: …") to every currently-connected
* client so well-behaved clients know to reconnect later.
* 2. Stops the Ktor engine: rejects new connections immediately,
* then waits up to [gracePeriodMillis] for active WebSocket
* handlers to finish whatever they're processing (so an in-flight
* `EVENT` lands its `OK` reply before the socket dies). After
* the grace window, in-progress handlers are cancelled and the
* engine waits up to [timeoutMillis] - [gracePeriodMillis] for
* that cancellation to complete.
*
* Defaults to 5 s grace / 10 s total — generous enough that a
* SQLite write + reply round-trip can land for typical event
* sizes. Override either with a tighter budget if your operator
* knows their workload.
*/
fun stop(
gracePeriodMillis: Long = 5_000,
timeoutMillis: Long = 10_000,
) {
val e = engine ?: return
// Order: (1) refuse new connections so they don't slip in and
// miss the NOTICE; (2) NOTICE every existing session so
// well-behaved clients reconnect later; (3) hand off to Ktor
// for the grace + timeout dance.
shuttingDown = true
notifyShutdown()
e.stop(gracePeriodMillis, timeoutMillis)
engine = null
resolvedPort = -1
}
/**
* Best-effort NOTICE to every active client. Failures are
* swallowed — a flaky socket on its way out is exactly the case
* where a NOTICE will fail anyway, and the client's read of the
* close frame is the authoritative shutdown signal.
*/
private fun notifyShutdown() {
val notice = NoticeMessage("closing: relay is shutting down — please reconnect later")
activeSessions.forEach { session ->
runCatching { session.send(notice) }
}
}
}
@@ -0,0 +1,218 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode
import com.vitorpamplona.geode.config.RelayConfig
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import java.io.File
/**
* Standalone entry point.
*
* Run with:
* ./gradlew :geode:run --args="--config /etc/geode.toml"
* or
* java -cp ... com.vitorpamplona.geode.MainKt --port 7447 --verify
*
* Configuration precedence (highest to lowest):
* 1. CLI flags (`--host`, `--port`, …)
* 2. TOML file passed via `--config <path>`
* 3. Built-in defaults (host=0.0.0.0, port=7447, in-memory db, …)
*
* Every section is enforced: `[info]` populates the NIP-11 doc,
* `[network]` controls the bind, `[database]` chooses the SQLite path,
* `[options]` toggles AUTH/verify/future-skew, `[limits]` and
* `[authorization]` plug into the relay's policy stack.
*
* CLI flags:
* --config <file> TOML config (see config.example.toml)
* --host <addr> bind address (default from config or 0.0.0.0)
* --port <n> tcp port (default from config or 7447, 0 to autobind)
* --path <p> ws path (default from config or /)
* --info <file> NIP-11 doc file (overrides [info] section)
* --db <file> sqlite db path (overrides [database].file)
* --auth require NIP-42 AUTH (sets options.require_auth = true)
* --no-verify DO NOT verify event signatures (off by default
* verify is on; use only for trusted-input
* scenarios like fixture replay).
*/
fun main(args: Array<String>) {
val a = parseArgs(args)
val config: RelayConfig =
a
.opt("--config")
?.let { RelayConfig.fromFile(File(it)) }
?: RelayConfig()
val host = a.opt("--host") ?: config.network.host
val port = a.opt("--port")?.toInt() ?: config.network.port
val path = a.opt("--path") ?: config.network.path
val cliInfoFile = a.opt("--info")?.let { File(it) }
val dbFile = a.opt("--db") ?: config.database.file?.takeUnless { config.database.in_memory }
val requireAuth = a.flag("--auth") || config.options.require_auth
// Verify is on by default; only disable when the operator explicitly
// opts out (CLI `--no-verify` or `[options].verify_signatures = false`
// in the config).
val verifySigs = !a.flag("--no-verify") && config.options.verify_signatures
// Advertised URL: explicit `info.relay_url` wins, then build from
// host/port/path. 0.0.0.0 bind → 127.0.0.1 in the URL so NIP-42
// challenges are well-formed.
val advertisedHost = if (host == "0.0.0.0") "127.0.0.1" else host
val advertisedUrl =
(config.info.relay_url ?: "ws://$advertisedHost:$port$path").normalizeRelayUrl()
val info =
cliInfoFile?.let { RelayInfo.fromFile(it) }
?: config.resolveInfo(advertisedUrl)
val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl)
val policyBuilder: () -> IRelayPolicy = {
composePolicy(config, advertisedUrl, requireAuth, verifySigs)
}
val stateFile = config.admin.state_file?.let { File(it) }
val relay = Relay(advertisedUrl, store, info, policyBuilder, stateFile = stateFile)
// Frame cap honors max_ws_frame_bytes when set; max_ws_message_bytes
// is treated as the same cap (Ktor's WebSockets plugin only exposes
// a single per-frame limit; multi-frame messages remain unbounded).
val frameLimit =
(config.limits.max_ws_frame_bytes ?: config.limits.max_ws_message_bytes)?.toLong()
val server =
LocalRelayServer(
relay,
host = host,
port = port,
path = path,
maxFrameBytes = frameLimit,
adminPubkeys = config.admin.pubkeys.toSet(),
publicUrl = config.admin.public_url,
).start()
Runtime.getRuntime().addShutdownHook(
Thread {
// Each step wrapped so a throw in `server.stop()` doesn't
// skip `relay.close()` (which closes the SQLite store).
runCatching { server.stop() }
runCatching { relay.close() }
},
)
println("geode listening on ${server.url}")
println("NIP-11 info doc: curl -H 'Accept: application/nostr+json' http://$advertisedHost:$port$path")
// Park the main thread; shutdown hook handles teardown.
Thread.currentThread().join()
}
/**
* Builds the policy stack for one connection from the config.
*
* Order matters — cheap rejection paths run before expensive ones:
* 1. AUTH (drops everything if not authenticated)
* 2. Future-timestamp + allow/deny lists
* 3. Signature verification (most expensive — Schnorr verify)
*/
private fun composePolicy(
config: RelayConfig,
advertisedUrl: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
requireAuth: Boolean,
verifySigs: Boolean,
): IRelayPolicy {
val pieces = mutableListOf<IRelayPolicy>()
if (requireAuth) {
pieces += FullAuthPolicy(advertisedUrl)
}
config.options.reject_future_seconds?.let { secs ->
pieces += RejectFutureEventsPolicy(secs)
}
val auth = config.authorization
if (auth.kind_whitelist.isNotEmpty() || auth.kind_blacklist.isNotEmpty()) {
pieces += KindAllowDenyPolicy(auth.kind_whitelist.toSet(), auth.kind_blacklist.toSet())
}
if (auth.pubkey_whitelist.isNotEmpty() || auth.pubkey_blacklist.isNotEmpty()) {
pieces += PubkeyAllowDenyPolicy(auth.pubkey_whitelist.toSet(), auth.pubkey_blacklist.toSet())
}
if (verifySigs) {
pieces += VerifyPolicy
}
return pieces.fold<IRelayPolicy, IRelayPolicy>(EmptyPolicy) { acc, p ->
if (acc === EmptyPolicy) p else acc + p
}
}
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("--")) {
// Support both `--key value` and `--key=value`. Splitting
// on the first `=` lets operators paste config values that
// happen to contain `=` (e.g. NIP-11 contact emails) by
// using the space-separated form.
val eq = a.indexOf('=')
if (eq > 0) {
opts[a.substring(0, eq)] = a.substring(eq + 1)
i += 1
} else {
val next = args.getOrNull(i + 1)
if (next != null && !next.startsWith("--")) {
opts[a] = next
i += 2
} else {
flags += a
i += 1
}
}
} else {
i += 1
}
}
return Args(opts, flags)
}
@@ -0,0 +1,192 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode
import com.vitorpamplona.geode.persistence.BannedEntry
import com.vitorpamplona.geode.persistence.RelayPersistedState
import com.vitorpamplona.geode.persistence.RelayStateStore
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanListPolicy
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore
import kotlinx.coroutines.SupervisorJob
import java.io.File
import kotlin.coroutines.CoroutineContext
/**
* A self-contained Nostr relay scoped to a single URL. Wraps a [NostrServer]
* over an [EventStore] (defaults to an in-memory SQLite database).
*
* Speaks NIP-01 (REQ/EVENT/EOSE/CLOSE), NIP-11 (relay info via [info]),
* NIP-42 (AUTH — supply [policyBuilder] = `{ FullAuthPolicy(url) }` or
* stack one with [com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy.plus]),
* NIP-45 (COUNT) and NIP-50 (search via the SQLite FTS index).
*
* Two transports:
* - [com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket] /
* [RelayHub] — no socket, fastest path, ideal
* for unit tests inside one JVM.
* - [LocalRelayServer] — Ktor `embeddedServer` listening on a real port.
* Use when external clients need to connect (`cli`, instrumented tests,
* standalone deployment).
*/
class Relay(
val url: NormalizedRelayUrl,
val store: IEventStore = EventStore(dbName = null, relay = url),
info: RelayInfo = RelayInfo.default(url),
policyBuilder: () -> IRelayPolicy = { EmptyPolicy },
parentContext: CoroutineContext = SupervisorJob(),
/**
* Optional path for the operator-state JSON snapshot. When set,
* the file is loaded at boot to seed [info] and [banStore], and
* rewritten atomically on every NIP-86 mutation and
* [updateInfo] call so admin actions survive restarts.
*
* Convention: place next to the SQLite event-store file
* (e.g. `events.db` → `events.db.admin.json`). `null` keeps
* everything in memory only — fine for tests.
*/
stateFile: File? = null,
) : AutoCloseable {
private val stateStore: RelayStateStore? = stateFile?.let { RelayStateStore(it) }
/**
* NIP-11 doc. Mutable so NIP-86 admin RPCs (`changerelayname`,
* `changerelaydescription`, `changerelayicon`) can swap the doc
* atomically. Readers (the NIP-11 GET endpoint) re-read on every
* request so changes are visible immediately, no restart needed.
*
* If a [RelayStateStore] is configured and the snapshot exists,
* the persisted info doc takes precedence over the constructor
* default — operators expect their last `changerelayname` to
* survive a restart.
*/
@Volatile
var info: RelayInfo =
stateStore?.load()?.info?.let { RelayInfo(it) } ?: info
private set
/** Mutates the live NIP-11 doc. Called by [Nip86Server]. */
fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
info = RelayInfo(transform(info.document))
snapshot()
}
/**
* Runtime-mutable ban / allow lists. NIP-86 RPC handlers in
* [Nip86Server] mutate this; the policy stack consults it on
* every accept call via [BanListPolicy].
*/
val banStore: BanStore = BanStore(onMutation = { snapshot() })
init {
// Seed the in-memory ban state from disk *without* triggering
// [snapshot] on every entry — the snapshot is exactly what we
// just loaded.
stateStore?.load()?.let { snap ->
banStore.seedFromSnapshot(
bannedPubkeys = snap.bannedPubkeys.map { it.key to it.reason },
allowedPubkeys = snap.allowedPubkeys.map { it.key to it.reason },
bannedEvents = snap.bannedEvents.map { it.key to it.reason },
allowedKinds = snap.allowedKinds,
disallowedKinds = snap.disallowedKinds,
)
}
}
/**
* Writes the current state (NIP-11 doc + ban lists) to disk.
* No-op when no `stateFile` was configured.
*
* Best-effort: any I/O failure is logged to stderr and swallowed
* so an unwritable disk doesn't take the relay down. Operators
* monitor for missing snapshots out-of-band.
*/
fun snapshot() {
val s = stateStore ?: return
runCatching {
s.save(
RelayPersistedState(
info = info.document,
bannedPubkeys = banStore.listBannedPubkeys().map { (k, r) -> BannedEntry(k, r) },
allowedPubkeys = banStore.listAllowedPubkeys().map { (k, r) -> BannedEntry(k, r) },
bannedEvents = banStore.listBannedEvents().map { (k, r) -> BannedEntry(k, r) },
allowedKinds = banStore.listAllowedKinds(),
disallowedKinds = banStore.listDisallowedKinds(),
),
)
}.onFailure {
System.err.println("warning: failed to write relay state file: ${it.message}")
}
}
val server =
NostrServer(
store,
// Always prepend a BanListPolicy so NIP-86 admin actions
// bite. When the operator-supplied builder returns
// [EmptyPolicy] we use the dynamic policy alone; otherwise
// we stack them so both layers must accept.
policyBuilder = {
val user = policyBuilder()
if (user === EmptyPolicy) BanListPolicy(banStore) else user + BanListPolicy(banStore)
},
parentContext,
)
/**
* Inserts events directly into the underlying store, bypassing the wire protocol.
*
* Use this for **pre-test setup** — events that exist before any client connects.
* It does NOT broadcast to active subscriptions. For sending events that should
* fan out to live subscribers (post-EOSE), use [publish] instead.
*/
suspend fun preload(events: Iterable<Event>) {
events.forEach { store.insert(it) }
}
/** @see preload(Iterable) */
suspend fun preload(vararg events: Event) = preload(events.toList())
/**
* Publishes an event through the relay's session machinery so it both lands
* in the store and fans out to active subscriptions matching its filters
* (mirrors what a real client would do via an `EVENT` command).
*/
suspend fun publish(event: Event) {
val session = server.connect { /* ignore OK echo */ }
try {
session.receive(OptimizedJsonMapper.toJson(EventCmd(event)))
} finally {
session.close()
}
}
override fun close() = server.close()
}
@@ -0,0 +1,101 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import java.util.concurrent.ConcurrentHashMap
/**
* Registry of [Relay] instances keyed by relay URL. Implements
* [WebsocketBuilder] so it can be plugged into
* [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] in place of
* `BasicOkHttpWebSocket.Builder` to redirect every outbound connection to an
* in-memory relay.
*
* Usage:
* ```
* val hub = RelayHub()
* val relay = hub.getOrCreate("ws://test.relay/")
* runBlocking { relay.preload(listOf(event1, event2)) }
* val client = NostrClient(hub, scope)
* ```
*
* Unknown URLs auto-create an empty relay so a single hub can transparently
* back any number of test endpoints.
*/
class RelayHub(
private val defaultPolicy: () -> IRelayPolicy = { EmptyPolicy },
) : WebsocketBuilder,
AutoCloseable {
private val relays = ConcurrentHashMap<NormalizedRelayUrl, Relay>()
@Volatile
private var closed = false
fun getOrCreate(url: NormalizedRelayUrl): Relay {
check(!closed) { "RelayHub has been closed" }
return relays.getOrPut(url) {
Relay(url = url, policyBuilder = defaultPolicy)
}
}
fun getOrCreate(url: String): Relay = getOrCreate(RelayUrlNormalizer.normalize(url))
fun get(url: NormalizedRelayUrl): Relay? = relays[url]
fun urls(): Set<NormalizedRelayUrl> = relays.keys.toSet()
override fun build(
url: NormalizedRelayUrl,
out: WebSocketListener,
): WebSocket = InProcessWebSocket(getOrCreate(url).server, out)
/**
* Idempotent. Sets the closed flag first so concurrent
* `getOrCreate` calls fail-fast — otherwise a relay created
* between iteration and clear would leak (its store would never
* be closed).
*/
override fun close() {
closed = true
relays.values.forEach { runCatching { it.close() } }
relays.clear()
}
companion object {
/**
* Default URL for tests that only need one relay. The URL itself
* has no semantic meaning — it's just a stable key into the hub
* — but it normalises through [RelayUrlNormalizer] (loopback) so
* the production [com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer]
* accepts it. Prefer this over typing `"ws://127.0.0.1:7770/"`
* everywhere.
*/
val DEFAULT_URL: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
}
}
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode
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 {
const val NAME = "geode"
const val DESCRIPTION = "Embedded Nostr relay from the Amethyst quartz library."
const val SOFTWARE = "https://github.com/vitorpamplona/amethyst/tree/main/geode"
const val VERSION = "1.08.0"
/**
* NIPs this relay implements out of the box. Single source of
* truth — both [default] and [com.vitorpamplona.geode.config.RelayConfig.resolveInfo]
* consult this list. Add a NIP here when its handler is wired
* into [com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession]
* (or in this module's policy stack).
*
* Currently:
* - 1 NIP-01 basic
* - 9 NIP-09 deletion (DeletionRequestModule)
* - 11 NIP-11 this doc
* - 40 NIP-40 expiration (ExpirationModule)
* - 42 NIP-42 AUTH (when policy enables)
* - 45 NIP-45 COUNT
* - 50 NIP-50 search (SQLite FTS)
* - 62 NIP-62 right to vanish
* - 77 NIP-77 negentropy reconciliation
* - 86 NIP-86 relay management API (when admin pubkeys configured)
*/
val SUPPORTED_NIPS: List<String> =
listOf("1", "9", "11", "40", "42", "45", "50", "62", "77", "86")
/** Pre-built default for `Relay(url = ...)` — advertises the supported NIPs. */
fun default(url: NormalizedRelayUrl): RelayInfo =
RelayInfo(
Nip11RelayInformation(
name = NAME,
description = DESCRIPTION,
software = SOFTWARE,
version = VERSION,
supported_nips = SUPPORTED_NIPS,
),
)
/** 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))
}
}
@@ -0,0 +1,190 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.config
import cc.ekblad.toml.decode
import cc.ekblad.toml.tomlMapper
import com.vitorpamplona.geode.RelayInfo
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import java.io.File
/**
* Operator-facing configuration. Section layout matches nostr-rs-relay's
* `config.toml` so existing configs can be ported with little churn.
*
* Every section is optional; values not set fall back to sensible
* defaults (or, for fields also exposed on the CLI, the CLI value wins).
*/
data class RelayConfig(
val info: InfoSection = InfoSection(),
val network: NetworkSection = NetworkSection(),
val database: DatabaseSection = DatabaseSection(),
val options: OptionsSection = OptionsSection(),
val limits: LimitsSection = LimitsSection(),
val authorization: AuthorizationSection = AuthorizationSection(),
val admin: AdminSection = AdminSection(),
) {
/**
* Maps the `[info]` section into a [RelayInfo] used by the NIP-11
* endpoint. `relay_url` and CLI overrides take precedence.
*/
fun resolveInfo(advertisedUrl: NormalizedRelayUrl): RelayInfo =
RelayInfo(
Nip11RelayInformation(
name = info.name ?: RelayInfo.NAME,
description = info.description ?: RelayInfo.DESCRIPTION,
pubkey = info.pubkey,
contact = info.contact,
icon = info.icon,
software = info.software ?: RelayInfo.SOFTWARE,
version = info.version ?: RelayInfo.VERSION,
supported_nips =
info.supported_nips?.map(Int::toString)
?: RelayInfo.SUPPORTED_NIPS,
privacy_policy = info.privacy_policy,
terms_of_service = info.terms_of_service,
relay_countries = info.relay_countries,
language_tags = info.language_tags,
tags = info.tags,
),
).also {
// Touch [advertisedUrl] so the parameter isn't unused — we keep
// it in the signature because future fields (e.g. self-pubkey
// selection, fee URLs) will want it.
advertisedUrl.url
}
data class InfoSection(
val relay_url: String? = null,
val name: String? = null,
val description: String? = null,
val pubkey: String? = null,
val contact: String? = null,
val icon: String? = null,
val software: String? = null,
val version: String? = null,
/** NIP numbers as ints (e.g. `[1, 9, 11]`). Stringified at render time. */
val supported_nips: List<Int>? = null,
val privacy_policy: String? = null,
val terms_of_service: String? = null,
val relay_countries: List<String>? = null,
val language_tags: List<String>? = null,
val tags: List<String>? = null,
)
data class NetworkSection(
val host: String = "0.0.0.0",
val port: Int = 7447,
val path: String = "/",
)
data class DatabaseSection(
/** True keeps an in-memory SQLite db (default — events vanish on restart). */
val in_memory: Boolean = true,
/** Filesystem path for a persistent SQLite db. Ignored when [in_memory] is true. */
val file: String? = null,
)
data class OptionsSection(
/** Reject events whose `created_at` is more than this many seconds in the future. */
val reject_future_seconds: Int? = null,
/** Require NIP-42 AUTH for REQ/EVENT/COUNT. */
val require_auth: Boolean = false,
/**
* Drop events whose Schnorr signature does not verify. **Defaults
* to `true`**: any relay accepting traffic from real clients
* should verify signatures, and verifying-by-default closes the
* footgun of forgetting the flag. Set explicitly to `false` only
* for trusted-input scenarios (test fixtures, mirror replays).
*/
val verify_signatures: Boolean = true,
)
data class LimitsSection(
val max_ws_message_bytes: Int? = null,
val max_ws_frame_bytes: Int? = null,
)
data class AuthorizationSection(
val pubkey_whitelist: List<String> = emptyList(),
val pubkey_blacklist: List<String> = emptyList(),
val kind_whitelist: List<Int> = emptyList(),
val kind_blacklist: List<Int> = emptyList(),
)
/**
* NIP-86 relay management API. When [pubkeys] is non-empty,
* `LocalRelayServer` exposes a POST endpoint at the relay path
* that accepts JSON-RPC admin requests authenticated via NIP-98
* HTTP-Auth. Only requests signed by one of these pubkeys are
* dispatched.
*
* [public_url] is the canonical URL the relay is reachable at,
* e.g. `https://relay.example.com/`. NIP-98's URL binding compares
* the signed `u` tag against this — without it, an attacker can
* spoof the `Host` header to bind their signature to any URL.
* Required when running behind TLS termination or a reverse proxy.
*/
data class AdminSection(
val pubkeys: List<String> = emptyList(),
val public_url: String? = null,
/**
* Path for the JSON snapshot that persists NIP-86 admin state
* (ban lists + the live NIP-11 doc) across restarts. When
* unset, admin state is in-memory only.
*
* Convention: place next to the SQLite event-store file —
* e.g. `[database].file = "/var/lib/geode/events.db"`
* pairs with `[admin].state_file = "/var/lib/geode/events.db.admin.json"`.
*/
val state_file: String? = null,
)
companion object {
private val mapper = tomlMapper { }
/** Parse a TOML string. */
fun fromToml(toml: String): RelayConfig = mapper.decode<RelayConfig>(toml)
/** Load a TOML config file. */
fun fromFile(file: File): RelayConfig = mapper.decode<RelayConfig>(file.toPath())
/**
* Returns the URL the relay advertises in NIP-11 and NIP-42
* challenges. Picks (in order):
* 1. `info.relay_url` from the config
* 2. The `network` section's host/port/path (with 0.0.0.0 → 127.0.0.1)
* 3. The CLI override (handled in `Main.kt`).
*/
fun advertisedUrl(config: RelayConfig): NormalizedRelayUrl =
(
config.info.relay_url
?: defaultUrl(config.network)
).normalizeRelayUrl()
private fun defaultUrl(net: NetworkSection): String {
val host = if (net.host == "0.0.0.0") "127.0.0.1" else net.host
return "ws://$host:${net.port}${net.path}"
}
}
}
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.persistence
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
/**
* On-disk snapshot of the relay's *operator-mutable* state — the
* NIP-11 info doc (so `changerelayname/description/icon` survive a
* restart) and the NIP-86 ban / allow / kind lists.
*
* One JSON file per relay. Lives next to the SQLite event store by
* convention, but the path is configurable independently. Atomic
* write via temp + atomic rename so a crash mid-save can never leave
* the file half-written.
*
* The schema below intentionally mirrors NIP-86 list responses
* (`pubkey + reason`, `id + reason`) so a future operator-tools CLI
* can read these straight from disk without translation.
*/
class RelayStateStore(
val file: File,
) {
/** Load the snapshot from disk, or `null` if the file does not yet exist. */
@Synchronized
fun load(): RelayPersistedState? {
if (!file.exists()) return null
return try {
json.decodeFromString(RelayPersistedState.serializer(), file.readText())
} catch (e: Exception) {
// Corrupt file — log to stderr and refuse to overwrite. The
// operator chooses whether to fix or delete; we don't blow
// away their state silently.
System.err.println("warning: failed to read relay state file ${file.absolutePath}: ${e.message}")
null
}
}
/** Atomically write the snapshot. */
@Synchronized
fun save(state: RelayPersistedState) {
file.parentFile?.let { if (!it.exists()) it.mkdirs() }
val tmp = File(file.parentFile ?: file.absoluteFile.parentFile, "${file.name}.tmp")
tmp.writeText(json.encodeToString(RelayPersistedState.serializer(), state))
Files.move(
tmp.toPath(),
file.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE,
)
}
private val json =
Json {
prettyPrint = true
encodeDefaults = false
ignoreUnknownKeys = true
}
}
@Serializable
data class RelayPersistedState(
val info: Nip11RelayInformation? = null,
val bannedPubkeys: List<BannedEntry> = emptyList(),
val allowedPubkeys: List<BannedEntry> = emptyList(),
val bannedEvents: List<BannedEntry> = emptyList(),
val allowedKinds: List<Int> = emptyList(),
val disallowedKinds: List<Int> = emptyList(),
)
@Serializable
data class BannedEntry(
val key: String,
val reason: String? = null,
)
@@ -0,0 +1,181 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.server
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.header
import io.ktor.server.request.receiveChannel
import io.ktor.server.response.respondText
import io.ktor.utils.io.readAvailable
/**
* NIP-86 admin POST handler. Owns the gating order:
* 1. 403 if no admin pubkey list is configured (endpoint disabled).
* 2. 413 if body exceeds [maxBodyBytes] (declared or actual).
* 3. 401 if the NIP-98 Authorization header is missing/invalid.
* 4. 403 if the verified pubkey isn't in [allowList].
* 5. 400 if the body isn't a valid Nip86Request.
* 6. 200 with a Nip86Response JSON body otherwise.
*
* The [signedUrlFor] callback resolves what URL the client must have
* signed in their NIP-98 token. Operators configure the canonical
* `publicUrl`; loopback tests fall back to the request's `Host`
* header. We pass it as a callback rather than a string so the route
* doesn't need to know about Ktor request internals.
*/
internal class Nip86HttpRoute(
private val server: Nip86Server,
private val verifier: Nip98AuthVerifier,
private val allowList: Set<HexKey>,
private val maxBodyBytes: Int,
private val signedUrlFor: (ApplicationCall) -> String,
) {
suspend fun handle(call: ApplicationCall) {
if (allowList.isEmpty()) {
call.respondText(
"NIP-86 management API is not enabled on this relay.",
ContentType.Text.Plain,
HttpStatusCode.Forbidden,
)
return
}
val body = readBoundedBody(call) ?: return
val pubkey = verifyAuth(call, body) ?: return
if (pubkey.lowercase() !in allowList) {
call.respondText(
"pubkey is not on the admin list",
ContentType.Text.Plain,
HttpStatusCode.Forbidden,
)
return
}
val req =
try {
JsonMapper.fromJson<Nip86Request>(body.decodeToString())
} catch (e: Exception) {
call.respondText(
"invalid Nip86Request: ${e.message ?: e::class.simpleName}",
ContentType.Text.Plain,
HttpStatusCode.BadRequest,
)
return
}
val response: Nip86Response = server.dispatch(req)
audit(pubkey, req, response)
call.respondText(
JsonMapper.toJson(response),
ContentType.parse("application/nostr+json+rpc"),
HttpStatusCode.OK,
)
}
private suspend fun readBoundedBody(call: ApplicationCall): ByteArray? {
val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull()
if (declared != null && declared > maxBodyBytes) {
call.respondText(
"request body exceeds $maxBodyBytes-byte cap",
ContentType.Text.Plain,
HttpStatusCode.PayloadTooLarge,
)
return null
}
val ch = call.receiveChannel()
val buf = ByteArray(maxBodyBytes + 1)
var pos = 0
while (pos <= maxBodyBytes) {
val read = ch.readAvailable(buf, pos, buf.size - pos)
if (read <= 0) break
pos += read
}
if (pos > maxBodyBytes) {
call.respondText(
"request body exceeds $maxBodyBytes-byte cap",
ContentType.Text.Plain,
HttpStatusCode.PayloadTooLarge,
)
return null
}
return buf.copyOfRange(0, pos)
}
private suspend fun verifyAuth(
call: ApplicationCall,
body: ByteArray,
): HexKey? {
val header = call.request.header(HttpHeaders.Authorization)
val verification = verifier.verify(header, method = "POST", url = signedUrlFor(call), body = body)
return when (verification) {
is Nip98AuthVerifier.Result.Verified -> {
verification.pubkey
}
Nip98AuthVerifier.Result.Missing -> {
call.response.headers.append(HttpHeaders.WWWAuthenticate, Nip98AuthVerifier.SCHEME.trim())
call.respondText(
"missing Authorization header (NIP-98)",
ContentType.Text.Plain,
HttpStatusCode.Unauthorized,
)
null
}
is Nip98AuthVerifier.Result.Malformed -> {
call.respondText(
"invalid NIP-98 Authorization: ${verification.reason}",
ContentType.Text.Plain,
HttpStatusCode.Unauthorized,
)
null
}
}
}
/**
* Audit log: structured single line so an operator can grep
* "nip86" / pubkey / method without a logging framework
* dependency. Best-effort — a missing log line shouldn't fail
* the response.
*/
private fun audit(
pubkey: HexKey,
req: Nip86Request,
response: Nip86Response,
) {
runCatching {
System.err.println(
"nip86 audit pubkey=$pubkey method=${req.method} ok=${response.error == null}" +
(response.error?.let { " error=$it" } ?: ""),
)
}
}
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.server
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedSendChannelException
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.launch
/**
* Per-WebSocket pump that owns the bounded outbound queue and the
* writer coroutine. Pulled out of `LocalRelayServer` so that file
* stays focused on Ktor wiring; the slow-client / backpressure
* policy now lives next to the data structures it manages.
*
* Lifecycle:
* 1. `connect(server, registerSession)` opens a [RelaySession],
* registers it with the supplied callback, and starts the
* writer coroutine that drains [outQueue] into [outgoing].
* 2. `pump()` reads inbound frames until the socket closes.
* 3. `finally`-style teardown closes the queue, cancels the
* writer, unregisters the session, and closes it.
*
* Slow-client policy: when [outQueue] fills, [SESSION_OUTGOING_BUFFER]
* frames behind, the connection is dropped rather than silently
* losing EVENT/EOSE — silent drop would corrupt NIP-01.
*/
internal class WebSocketSessionPump(
private val ws: DefaultWebSocketServerSession,
) {
private val outQueue = Channel<String>(capacity = SESSION_OUTGOING_BUFFER)
private var droppedForBackpressure = false
suspend fun pump(
server: NostrServer,
registerSession: (RelaySession) -> Unit,
unregisterSession: (RelaySession) -> Unit,
) {
val writerJob =
ws.launch {
try {
for (json in outQueue) {
ws.outgoing.send(Frame.Text(json))
}
} catch (_: ClosedSendChannelException) {
// socket closed — outer handler runs normal teardown.
}
}
val session =
server.connect { json ->
val res = outQueue.trySend(json)
if (!res.isSuccess && !res.isClosed) {
// Buffer is full → slow client. Mark + close the
// queue; the writer drains, then the outer handler
// closes the WS session.
droppedForBackpressure = true
outQueue.close()
}
}
registerSession(session)
try {
ws.incoming.consumeEach { frame ->
if (droppedForBackpressure) return@consumeEach
if (frame is Frame.Text) {
session.receive(frame.readText())
}
}
} finally {
outQueue.close()
writerJob.cancel()
unregisterSession(session)
session.close()
}
}
companion object {
/**
* Per-session outbound buffer size. When a slow client falls
* this many frames behind, we close their connection rather
* than silently dropping further frames (which would corrupt
* NIP-01 by missing EVENT/EOSE messages).
*
* Sized to hold fan-out for a connection holding several
* thousand subscriptions when one event matches all of them
* — the realistic upper bound for a relay client. At ~250B
* per frame this caps per-session memory at ~2 MiB before
* we drop the connection.
*/
const val SESSION_OUTGOING_BUFFER: Int = 8192
}
}
@@ -0,0 +1,224 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Tests [LocalRelayServer.stop] honours the graceful-shutdown contract:
* 1. Active clients receive a `NOTICE` warning of imminent shutdown.
* 2. The active session counter accurately tracks open WS sessions.
* 3. After `stop()` returns, no sessions remain registered.
*/
class GracefulShutdownTest {
private lateinit var relay: Relay
private lateinit var server: LocalRelayServer
private lateinit var scope: CoroutineScope
private lateinit var client: NostrClient
private val httpClient = OkHttpClient.Builder().build()
@BeforeTest
fun setup() {
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
relay = Relay(url = placeholder)
server = LocalRelayServer(relay, host = "127.0.0.1", port = 0).start()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient }
client = NostrClient(builder, scope)
}
@AfterTest
fun teardown() {
client.disconnect()
scope.cancel()
// server may already be stopped by the test; calling stop()
// again is a no-op.
server.stop(gracePeriodMillis = 200, timeoutMillis = 500)
relay.close()
}
@Test
fun activeSessionCountTracksConnectAndDisconnect() =
runBlocking {
assertEquals(0, server.activeSessionCount, "no clients yet")
// Open a connection by subscribing — wait for EOSE so we
// know the WebSocket handshake completed and the relay
// session has registered.
val gotEose = Channel<Unit>(UNLIMITED)
val relayUrl = server.url.normalizeRelayUrl()
client.subscribe(
"track-1",
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))),
object : SubscriptionListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gotEose.trySend(Unit)
}
},
)
withTimeout(5000) { gotEose.receive() }
assertEquals(1, server.activeSessionCount, "one connected session")
client.unsubscribe("track-1")
client.disconnect()
// Disconnect happens asynchronously on the relay side; allow
// a short window for the handler's `finally` block to run.
withTimeoutOrNull(2000) {
while (server.activeSessionCount > 0) kotlinx.coroutines.delay(10)
}
assertEquals(0, server.activeSessionCount, "session must be removed after disconnect")
}
@Test
fun stopSendsShutdownNoticeToActiveClients() =
runBlocking {
val noticeChannel = Channel<NoticeMessage>(UNLIMITED)
val gotEose = Channel<Unit>(UNLIMITED)
val listener =
object : RelayConnectionListener {
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
if (msg is NoticeMessage) noticeChannel.trySend(msg)
}
}
client.addConnectionListener(listener)
val relayUrl = server.url.normalizeRelayUrl()
client.subscribe(
"notice-watch",
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))),
object : SubscriptionListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gotEose.trySend(Unit)
}
},
)
withTimeout(5000) { gotEose.receive() }
assertEquals(1, server.activeSessionCount)
// Trigger graceful shutdown.
server.stop(gracePeriodMillis = 1_000, timeoutMillis = 2_000)
val notice = withTimeout(5000) { noticeChannel.receive() }
assertNotNull(notice)
assertTrue(
notice.message.startsWith("closing:"),
"expected NOTICE to start with 'closing:', got '${notice.message}'",
)
}
@Test
fun stopIsIdempotent() {
// First call shuts the engine down.
server.stop(gracePeriodMillis = 100, timeoutMillis = 500)
// Second call must be a safe no-op (no exception).
server.stop(gracePeriodMillis = 100, timeoutMillis = 500)
}
/**
* Sanity check on the grace window: a bare-bones ws client that
* connects and never sends anything should *receive* the shutdown
* NOTICE before the server fully closes the socket. Uses Ktor's
* client-agnostic OkHttp transport directly so we can observe the
* raw frames.
*/
@Test
fun rawWsClientObservesNoticeBeforeServerCloses() =
runBlocking {
val httpUrl =
server.url
.replace("ws://", "http://")
val request =
okhttp3.Request
.Builder()
.url(httpUrl)
.build()
val frames = Channel<String>(UNLIMITED)
val socket =
httpClient.newWebSocket(
request,
object : okhttp3.WebSocketListener() {
override fun onMessage(
ws: okhttp3.WebSocket,
text: String,
) {
frames.trySend(text)
}
},
)
try {
// Wait until the relay sees the connection.
withTimeoutOrNull(2000) {
while (server.activeSessionCount == 0) kotlinx.coroutines.delay(10)
}
assertEquals(1, server.activeSessionCount)
server.stop(gracePeriodMillis = 1_000, timeoutMillis = 2_000)
val text = withTimeout(3000) { frames.receive() }
assertTrue(
text.contains("\"NOTICE\"") && text.contains("closing"),
"expected a NOTICE frame, got: $text",
)
} finally {
socket.cancel()
}
}
}
@@ -0,0 +1,411 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
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 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("geode", info.name)
assertTrue(info.supported_nips!!.contains("11"), "NIP-11 must be advertised")
assertTrue(info.supported_nips!!.contains("1"), "NIP-01 must be advertised")
}
}
@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()
}
}
/**
* Successful NIP-42 AUTH must unlock REQ/EVENT/COUNT. We can't bind
* the server on a known port AND configure the policy with that URL
* unless we discover the port first — so reserve a free TCP port
* before constructing the relay, then bind to that exact port so
* the policy's `relay` field matches what `RelayAuthenticator`
* sends in the AUTH event's `relay` tag.
*/
@Test
fun nip42_successfulAuthUnlocksPublishing() =
runBlocking {
val freePort =
java.net.ServerSocket(0).use { it.localPort }
val authUrl = "ws://127.0.0.1:$freePort/".normalizeRelayUrl()
val authRelay = Relay(authUrl, policyBuilder = { FullAuthPolicy(authUrl) })
val authServer = LocalRelayServer(authRelay, host = "127.0.0.1", port = freePort).start()
try {
val signer =
com.vitorpamplona.quartz.nip01Core.signers
.NostrSignerSync(KeyPair())
// RelayAuthenticator hooks the client: when the relay
// sends the AUTH challenge, it auto-signs and replies.
val authenticator =
com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator(
client = client,
scope = scope,
) { template ->
listOf(signer.sign(template))
}
try {
// Trigger the AUTH dance: subscribe to anything,
// which the relay rejects with `auth-required:` →
// [RelayAuthenticator] catches the challenge, signs
// and sends the AUTH event, the relay's OK true
// makes the client re-sync filters, and the second
// REQ succeeds and EOSEs.
val gotEose =
kotlinx.coroutines.channels.Channel<Unit>(
kotlinx.coroutines.channels.Channel.UNLIMITED,
)
client.subscribe(
"auth-warmup",
mapOf(authUrl to listOf(Filter(kinds = listOf(1)))),
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
override fun onEose(
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gotEose.trySend(Unit)
}
},
)
kotlinx.coroutines.withTimeout(5000) { gotEose.receive() }
client.unsubscribe("auth-warmup")
val event = signer.sign(TextNoteEvent.build("after-auth"))
val ok =
client.publishAndConfirm(
event = event,
relayList = setOf(authUrl),
)
assertEquals(true, ok, "after AUTH succeeds, publishing must work")
} finally {
authenticator.destroy()
}
} finally {
authServer.stop()
authRelay.close()
}
}
/**
* Regression for the OkMessage wire format (the Jackson serializer
* was writing `success` as a JSON string). `publishAndConfirm`
* relies on parsing the OK response — if the relay's serialization
* regresses, this test catches it.
*/
@Test
fun nip01_okMessageRoundtripWithEmptyAndNonEmptyMessage() =
runBlocking {
val signer =
com.vitorpamplona.quartz.nip01Core.signers
.NostrSignerSync(KeyPair())
val event = signer.sign(TextNoteEvent.build("ok-roundtrip"))
val ok =
client.publishAndConfirm(
event = event,
relayList = setOf(server.url.normalizeRelayUrl()),
)
assertEquals(true, ok, "successful insert must round-trip OK true on the wire")
// Duplicate insert returns OK false; this also exercises the
// "non-empty message" branch of the serializer.
val ok2 =
client.publishAndConfirm(
event = event,
relayList = setOf(server.url.normalizeRelayUrl()),
)
assertEquals(false, ok2, "duplicate insert must round-trip OK false")
}
/**
* A custom config's `[info]` section flows through to the NIP-11
* doc returned on the HTTP endpoint.
*/
@Test
fun nip11_servesConfigDrivenInfoDoc() {
val freePort =
java.net.ServerSocket(0).use { it.localPort }
val customUrl = "ws://127.0.0.1:$freePort/".normalizeRelayUrl()
val customInfo =
RelayInfo(
Nip11RelayInformation(
name = "custom-relay-name",
contact = "ops@example.com",
description = "Custom from config",
supported_nips = listOf("1", "11", "42"),
),
)
val customRelay = Relay(customUrl, info = customInfo)
val customServer =
LocalRelayServer(customRelay, host = "127.0.0.1", port = freePort).start()
try {
val httpUrl = customServer.url.replace("ws://", "http://")
val response =
httpClient
.newCall(
Request
.Builder()
.url(httpUrl)
.header("Accept", "application/nostr+json")
.build(),
).execute()
response.use {
assertEquals(200, it.code)
val info = Nip11RelayInformation.fromJson(it.body.string())
assertEquals("custom-relay-name", info.name)
assertEquals("ops@example.com", info.contact)
assertEquals(listOf("1", "11", "42"), info.supported_nips)
}
} finally {
customServer.stop()
customRelay.close()
}
}
@Test
fun nip01_closeStopsLiveSubscription() =
runBlocking {
val ch =
kotlinx.coroutines.channels.Channel<com.vitorpamplona.quartz.nip01Core.core.Event>(
kotlinx.coroutines.channels.Channel.UNLIMITED,
)
val gotEose =
kotlinx.coroutines.channels.Channel<Unit>(
kotlinx.coroutines.channels.Channel.UNLIMITED,
)
client.subscribe(
"close-test",
mapOf(server.url.normalizeRelayUrl() to listOf(Filter(kinds = listOf(1)))),
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
override fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(event)
}
override fun onEose(
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gotEose.trySend(Unit)
}
},
)
kotlinx.coroutines.withTimeout(5000) { gotEose.receive() }
// CLOSE the subscription, then publish a matching event over
// the wire. The unsubscribed client must NOT receive it.
client.unsubscribe("close-test")
val signer =
com.vitorpamplona.quartz.nip01Core.signers
.NostrSignerSync(KeyPair())
val late = signer.sign(TextNoteEvent.build("post-close"))
relay.publish(late)
val seen = kotlinx.coroutines.withTimeoutOrNull(500) { ch.receive() }
assertEquals(null, seen, "events arriving after CLOSE must not reach the unsubscribed client")
}
}
@@ -0,0 +1,554 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.geode.testing.collectUntilEose
import com.vitorpamplona.geode.testing.collectUntilEoseMulti
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Compatibility suite that drives the in-process relay through the same
* `NostrClient` + WebSocket abstraction production code uses. These tests
* are how we validate that:
*
* 1. The relay implements NIP-01 correctly (REQ/EVENT/EOSE/CLOSE/COUNT,
* replaceable + parameterized-replaceable, filter matching, multi-relay
* pools).
* 2. The bridge between [com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket]
* and [com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer] preserves
* wire ordering and lifecycle.
*
* The same suite should be runnable, conceptually, against any
* spec-compliant relay (nostr-rs-relay, strfry, khatru, …) — only the
* `socketBuilder` and the relay URL would change.
*/
class Nip01ComplianceTest : RelayClientTest() {
private suspend fun preload(vararg events: Event) {
defaultRelay.preload(*events)
}
private fun fakeEvent(
idSeed: Int,
kind: Int = 1,
pubKey: String = SyntheticEvents.hexId(0),
createdAt: Long = idSeed.toLong(),
tags: Array<Array<String>> = emptyArray(),
content: String = "",
) = SyntheticEvents.fakeEvent(idSeed, kind, pubKey, createdAt, content, tags)
// -- Subscriptions -------------------------------------------------------
/** REQ returns events matching kinds, then EOSE, in order. */
@Test
fun reqByKindReturnsMatchesThenEose() =
runBlocking {
preload(
fakeEvent(1, kind = 1),
fakeEvent(2, kind = 4),
fakeEvent(3, kind = 1),
)
val (events, eose) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(1)))
assertEquals(2, events.size)
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
assertTrue(eose, "EOSE should fire after stored events")
}
/** REQ honours `limit` — newest first, capped to limit. */
@Test
fun reqRespectsLimitAndOrdersNewestFirst() =
runBlocking {
preload(
fakeEvent(1, kind = 1, createdAt = 100),
fakeEvent(2, kind = 1, createdAt = 200),
fakeEvent(3, kind = 1, createdAt = 300),
fakeEvent(4, kind = 1, createdAt = 400),
)
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(1), limit = 2))
assertEquals(2, events.size)
assertEquals(400L, events[0].createdAt)
assertEquals(300L, events[1].createdAt)
}
/** REQ with `authors` filters by pubkey. */
@Test
fun reqFiltersByAuthors() =
runBlocking {
val alice = SyntheticEvents.hexId(101)
val bob = SyntheticEvents.hexId(102)
preload(
fakeEvent(1, kind = 1, pubKey = alice),
fakeEvent(2, kind = 1, pubKey = bob),
fakeEvent(3, kind = 1, pubKey = alice),
)
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(authors = listOf(alice)))
assertEquals(2, events.size)
assertTrue(events.all { it.pubKey == alice })
}
/** REQ with `ids` returns only the requested events. */
@Test
fun reqFiltersByIds() =
runBlocking {
preload(fakeEvent(1), fakeEvent(2), fakeEvent(3))
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(ids = listOf(SyntheticEvents.hexId(2))))
assertEquals(1, events.size)
assertEquals(SyntheticEvents.hexId(2), events[0].id)
}
/** REQ with `since`/`until` filters on createdAt. */
@Test
fun reqFiltersBySinceAndUntil() =
runBlocking {
preload(
fakeEvent(1, createdAt = 100),
fakeEvent(2, createdAt = 200),
fakeEvent(3, createdAt = 300),
fakeEvent(4, createdAt = 400),
)
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(since = 150L, until = 350L))
assertEquals(setOf(200L, 300L), events.map { it.createdAt }.toSet())
}
/** REQ with single-letter `#e` tag filter matches events whose tag values intersect. */
@Test
fun reqFiltersByETag() =
runBlocking {
val target = SyntheticEvents.hexId(999)
preload(
fakeEvent(1, tags = arrayOf(arrayOf("e", target))),
fakeEvent(2, tags = arrayOf(arrayOf("e", SyntheticEvents.hexId(7)))),
fakeEvent(3, tags = arrayOf(arrayOf("e", target), arrayOf("p", SyntheticEvents.hexId(8)))),
)
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("e" to listOf(target))))
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
}
/** REQ with `#p` tag filter — same single-letter machinery as `#e`. */
@Test
fun reqFiltersByPTag() =
runBlocking {
val targetPubkey = SyntheticEvents.hexId(2222)
preload(
fakeEvent(1, tags = arrayOf(arrayOf("p", targetPubkey))),
fakeEvent(2, tags = arrayOf(arrayOf("p", SyntheticEvents.hexId(3333)))),
fakeEvent(3, tags = arrayOf(arrayOf("p", targetPubkey), arrayOf("e", SyntheticEvents.hexId(99)))),
)
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("p" to listOf(targetPubkey))))
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
}
/** REQ with a generic single-letter tag (e.g. `#t`) for hashtags. */
@Test
fun reqFiltersByGenericSingleLetterTag() =
runBlocking {
preload(
fakeEvent(1, tags = arrayOf(arrayOf("t", "nostr"))),
fakeEvent(2, tags = arrayOf(arrayOf("t", "bitcoin"))),
fakeEvent(3, tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "kotlin"))),
)
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("t" to listOf("nostr"))))
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
}
/**
* Multi-value tag filter — matches events with tag value in the OR
* set. NIP-01 says values inside a single filter list are OR'd.
*/
@Test
fun reqTagFilterValuesAreOred() =
runBlocking {
val a = SyntheticEvents.hexId(101)
val b = SyntheticEvents.hexId(102)
preload(
fakeEvent(1, tags = arrayOf(arrayOf("e", a))),
fakeEvent(2, tags = arrayOf(arrayOf("e", b))),
fakeEvent(3, tags = arrayOf(arrayOf("e", SyntheticEvents.hexId(999)))),
)
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("e" to listOf(a, b))))
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(2)), events.map { it.id }.toSet())
}
// -- Multi-filter REQ ----------------------------------------------------
/**
* NIP-01: filters within a single REQ are OR'd. The relay returns
* events matching ANY of the filters, deduplicated.
*/
@Test
fun reqMultipleFiltersAreOred() =
runBlocking {
preload(
fakeEvent(1, kind = 1),
fakeEvent(2, kind = 4),
fakeEvent(3, kind = 7),
)
val (events, _) =
client.collectUntilEoseMulti(
defaultRelayUrl,
listOf(
Filter(kinds = listOf(1)),
Filter(kinds = listOf(7)),
),
)
assertEquals(
setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)),
events.map { it.id }.toSet(),
)
}
/**
* Two subscriptions on the same connection are independent — each
* gets its own EOSE and its own event stream.
*/
@Test
fun multipleSubscriptionsOnOneConnectionAreIndependent() =
runBlocking {
preload(
fakeEvent(1, kind = 1),
fakeEvent(2, kind = 4),
)
val ch1 = Channel<Event>(UNLIMITED)
val ch2 = Channel<Event>(UNLIMITED)
val eose1 = Channel<Unit>(UNLIMITED)
val eose2 = Channel<Unit>(UNLIMITED)
client.subscribe(
"sub-A",
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch1.trySend(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eose1.trySend(Unit)
}
},
)
client.subscribe(
"sub-B",
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(4)))),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch2.trySend(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eose2.trySend(Unit)
}
},
)
withTimeout(5000) {
eose1.receive()
eose2.receive()
}
// sub-A only saw kind=1, sub-B only saw kind=4.
val a = withTimeoutOrNull(100) { ch1.receive() }
val b = withTimeoutOrNull(100) { ch2.receive() }
assertEquals(SyntheticEvents.hexId(1), a?.id)
assertEquals(SyntheticEvents.hexId(2), b?.id)
client.unsubscribe("sub-A")
client.unsubscribe("sub-B")
}
// -- Replaceable + addressable ------------------------------------------
/** Kind 0 is replaceable by `(pubkey, kind)` — newer wins. */
@Test
fun replaceableEventsKeepNewestPerPubkey() =
runBlocking {
val pubkey = SyntheticEvents.hexId(50)
preload(
fakeEvent(1, kind = 0, pubKey = pubkey, createdAt = 100, content = "old"),
fakeEvent(2, kind = 0, pubKey = pubkey, createdAt = 200, content = "new"),
)
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(0), authors = listOf(pubkey)))
assertEquals(1, events.size)
assertEquals("new", events[0].content)
}
/**
* Addressable events (kind 30000-39999, NIP-01 §"Kinds") are replaced
* by `(pubkey, kind, d)`. Uses a real signed [LongTextNoteEvent] because
* the SQLite store dispatches on the typed `AddressableEvent` subclass
* to extract the d-tag — synthetic plain `Event`s aren't recognised.
*/
@Test
fun parameterizedReplaceableEventsKeepNewestPerDTag() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
val v1 = signer.sign(LongTextNoteEvent.build("old", "title", dTag = "list-a", createdAt = 100))
val v2 = signer.sign(LongTextNoteEvent.build("new", "title", dTag = "list-a", createdAt = 200))
val v3 = signer.sign(LongTextNoteEvent.build("list-b", "title", dTag = "list-b", createdAt = 100))
preload(v1, v2, v3)
val (events, _) =
client.collectUntilEose(
defaultRelayUrl,
Filter(kinds = listOf(LongTextNoteEvent.KIND), authors = listOf(signer.pubKey)),
)
assertEquals(2, events.size)
assertEquals(setOf("new", "list-b"), events.map { it.content }.toSet())
}
// -- Live updates --------------------------------------------------------
/** A subscription receives new matching events that arrive after EOSE. */
@Test
fun liveSubscriptionReceivesPostEoseEvents() =
runBlocking {
val ch = Channel<Event>(UNLIMITED)
val gotEose = Channel<Unit>(UNLIMITED)
client.subscribe(
"live-1",
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gotEose.trySend(Unit)
}
},
)
withTimeout(5000) { gotEose.receive() }
// Inject an event through the wire path (not preload — that bypasses
// the live broadcast that subscriptions feed off of).
defaultRelay.publish(fakeEvent(99, kind = 1, content = "live"))
val received = withTimeout(5000) { ch.receive() }
assertEquals("live", received.content)
client.unsubscribe("live-1")
}
/** Non-matching live events are not pushed to a subscription. */
@Test
fun liveSubscriptionIgnoresNonMatchingEvents() =
runBlocking {
val ch = Channel<Event>(UNLIMITED)
val gotEose = Channel<Unit>(UNLIMITED)
client.subscribe(
"live-2",
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gotEose.trySend(Unit)
}
},
)
withTimeout(5000) { gotEose.receive() }
defaultRelay.publish(fakeEvent(98, kind = 4, content = "off-topic"))
val seen = withTimeoutOrNull(500) { ch.receive() }
assertNull(seen, "kind 4 should not match a kind-1 subscription")
client.unsubscribe("live-2")
}
// -- Ephemeral events (NIP-01: kinds 20000-29999) ----------------------
/**
* Ephemeral events MUST be forwarded to active subscriptions whose
* filters match, even though the relay does not persist them.
*/
@Test
fun ephemeralEventForwardedToActiveSubscription() =
runBlocking {
val ch = Channel<Event>(UNLIMITED)
val gotEose = Channel<Unit>(UNLIMITED)
client.subscribe(
"eph-1",
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(20_001)))),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gotEose.trySend(Unit)
}
},
)
withTimeout(5000) { gotEose.receive() }
defaultRelay.publish(fakeEvent(70, kind = 20_001, content = "ephemeral-payload"))
val received = withTimeout(5000) { ch.receive() }
assertEquals("ephemeral-payload", received.content)
assertEquals(20_001, received.kind)
client.unsubscribe("eph-1")
}
/**
* Ephemeral events MUST NOT be persisted. A REQ issued after the
* event was published returns nothing.
*/
@Test
fun ephemeralEventIsNotStoredAndDoesNotShowOnFollowupReq() =
runBlocking {
// Publish ephemeral first — no live subscriber listening.
defaultRelay.publish(fakeEvent(71, kind = 20_002, content = "vanish"))
// Late subscriber: should see EOSE with no events.
val (events, eose) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(20_002)))
assertTrue(eose, "EOSE must fire for an ephemeral kind even if zero events match")
assertEquals(0, events.size, "Ephemeral events must not be persisted")
}
// -- Multi-relay --------------------------------------------------------
/** A single client can hold subscriptions against multiple relays simultaneously. */
@Test
fun multiRelayPoolReturnsContentFromEachRelay() =
runBlocking {
val relayA = RelayUrlNormalizer.normalize("ws://127.0.0.1:7771/")
val relayB = RelayUrlNormalizer.normalize("ws://127.0.0.1:7772/")
hub.getOrCreate(relayA).preload(fakeEvent(1, kind = 1, content = "from-a"))
hub.getOrCreate(relayB).preload(fakeEvent(2, kind = 1, content = "from-b"))
val received = mutableMapOf<NormalizedRelayUrl, String>()
val eosed = mutableSetOf<NormalizedRelayUrl>()
val ch = Channel<Unit>(UNLIMITED)
client.subscribe(
"multi-1",
mapOf(
relayA to listOf(Filter(kinds = listOf(1))),
relayB to listOf(Filter(kinds = listOf(1))),
),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
received[relay] = event.content
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eosed += relay
if (eosed.size == 2) ch.trySend(Unit)
}
},
)
withTimeout(5000) { ch.receive() }
client.unsubscribe("multi-1")
assertEquals("from-a", received[relayA])
assertEquals("from-b", received[relayB])
}
}
@@ -0,0 +1,190 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Verifies NIP-09 deletion request behavior end-to-end through
* `NostrClient` → `RelayHub`. The relay's
* [com.vitorpamplona.quartz.nip01Core.store.sqlite.DeletionRequestModule]
* is responsible for honouring kind-5 events:
*
* 1. Existing events targeted by id are removed from the store.
* 2. A SQL trigger blocks re-insertion of any event that matches a
* stored kind-5 deletion (so a malicious relay can't sneak the
* deleted event back in via another connection).
* 3. Cross-author deletion is silently ignored: a kind-5 event from
* pubkey X cannot delete pubkey Y's events.
*/
class Nip09DeletionTest {
private lateinit var hub: RelayHub
private lateinit var scope: CoroutineScope
private lateinit var client: NostrClient
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
@BeforeTest
fun setup() {
hub = RelayHub()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
client = NostrClient(hub, scope)
}
@AfterTest
fun teardown() {
client.disconnect()
scope.cancel()
hub.close()
}
private suspend fun query(filter: Filter): List<Event> {
val ch = kotlinx.coroutines.channels.Channel<Either>(kotlinx.coroutines.channels.Channel.UNLIMITED)
val subId = "sub-${System.nanoTime()}"
client.subscribe(
subId,
mapOf(relayUrl to listOf(filter)),
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Either.Ev(event))
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Either.Eose)
}
},
)
val events = mutableListOf<Event>()
kotlinx.coroutines.withTimeout(5000) {
while (true) {
when (val msg = ch.receive()) {
is Either.Ev -> events += msg.event
Either.Eose -> return@withTimeout
}
}
}
client.unsubscribe(subId)
return events
}
private sealed interface Either {
data class Ev(
val event: Event,
) : Either
object Eose : Either
}
@Test
fun deletionRemovesTargetedEventFromStore() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
val note = signer.sign(TextNoteEvent.build("delete me"))
val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1))
// Publish original, confirm it's stored.
assertEquals(true, client.publishAndConfirm(note, setOf(relayUrl)))
assertEquals(1, query(Filter(ids = listOf(note.id))).size)
// Publish deletion; the store removes the targeted event.
assertEquals(true, client.publishAndConfirm(deletion, setOf(relayUrl)))
assertEquals(0, query(Filter(ids = listOf(note.id))).size, "event must be gone")
}
@Test
fun deletionEventItselfIsStoredAndQueryable() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
val note = signer.sign(TextNoteEvent.build("a"))
val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1))
client.publishAndConfirm(note, setOf(relayUrl))
client.publishAndConfirm(deletion, setOf(relayUrl))
val results = query(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(signer.pubKey)))
assertEquals(1, results.size)
assertEquals(deletion.id, results[0].id)
}
@Test
fun reinsertingADeletedEventIsRejected() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
val note = signer.sign(TextNoteEvent.build("once-upon-a-time"))
val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1))
client.publishAndConfirm(note, setOf(relayUrl))
client.publishAndConfirm(deletion, setOf(relayUrl))
// Trying to reinsert the same event must fail — relay returns OK false.
val ok = client.publishAndConfirm(note, setOf(relayUrl))
assertEquals(false, ok, "reinserting a deleted event must be blocked")
}
@Test
fun crossAuthorDeletionDoesNotRemoveOtherUsersEvents() =
runBlocking {
val alice = NostrSignerSync(KeyPair())
val mallory = NostrSignerSync(KeyPair())
val aliceNote = alice.sign(TextNoteEvent.build("alice-private"))
client.publishAndConfirm(aliceNote, setOf(relayUrl))
assertEquals(1, query(Filter(ids = listOf(aliceNote.id))).size)
// Mallory tries to delete Alice's event: relay accepts the
// kind-5 event itself (it's just an event), but the SQL DELETE
// is owner-scoped, so Alice's event survives.
val malloryDelete =
mallory.sign(DeletionEvent.build(listOf(aliceNote), createdAt = aliceNote.createdAt + 1))
client.publishAndConfirm(malloryDelete, setOf(relayUrl))
assertEquals(
1,
query(Filter(ids = listOf(aliceNote.id))).size,
"Mallory's deletion must NOT remove Alice's event",
)
}
}
@@ -0,0 +1,165 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
/**
* NIP-40 expiration:
* - The relay rejects EVENTs whose `expiration` tag is in the past
* (the SQLite store's `insertEvent` raises on `event.isExpired()`).
* - Events with a future expiration are stored and queryable until
* the operator calls `deleteExpiredEvents()` (or sufficient time
* passes that `isExpired()` returns true on read).
*/
class Nip40ExpirationTest {
private lateinit var hub: RelayHub
private lateinit var scope: CoroutineScope
private lateinit var client: NostrClient
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
@BeforeTest
fun setup() {
hub = RelayHub()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
client = NostrClient(hub, scope)
}
@AfterTest
fun teardown() {
client.disconnect()
scope.cancel()
hub.close()
}
@Test
fun expiredEventOnArrivalIsRejectedWithOkFalse() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
// expiration set to a past timestamp.
val past = TimeUtils.now() - 60
val event =
signer.sign(
TextNoteEvent.build("expired") {
expiration(past)
},
)
val ok = client.publishAndConfirm(event, setOf(relayUrl))
assertEquals(false, ok, "expired-on-arrival event must be rejected")
}
@Test
fun nonExpiredEventIsStoredAndRetrievable() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
val future = TimeUtils.now() + 3600
val event =
signer.sign(
TextNoteEvent.build("not-yet-expired") {
expiration(future)
},
)
assertEquals(true, client.publishAndConfirm(event, setOf(relayUrl)))
val fetched =
client.fetchFirst(
relay = relayUrl,
filter = Filter(ids = listOf(event.id)),
)
assertNotNull(fetched)
assertEquals(event.id, fetched.id)
}
@Test
fun deleteExpiredEventsRemovesThemFromStore() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
val now = TimeUtils.now()
// Two events: one expires in the past, one in the future.
// We set the past one to be in the past relative to NOW, but
// not so far that the original `insertEvent` rejects on
// arrival. The trick: use a createdAt slightly in the past
// and an expiration also slightly in the past, both still
// recent enough that the relay accepts the event but
// `deleteExpiredEvents()` will sweep it.
//
// Actually `insertEvent` rejects on `isExpired()` —
// [past] timestamps fail at insert. So instead we publish a
// non-expired event (long-lived) and a barely-non-expired
// one (1s out), then sleep 2s and call sweep.
val longLived =
signer.sign(TextNoteEvent.build("keep-me") { expiration(now + 3600) })
val shortLived =
signer.sign(TextNoteEvent.build("sweep-me") { expiration(now + 1) })
client.publishAndConfirm(longLived, setOf(relayUrl))
client.publishAndConfirm(shortLived, setOf(relayUrl))
// Both currently in the store.
assertNotNull(
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(longLived.id))),
)
assertNotNull(
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(shortLived.id))),
)
// Wait until shortLived is past its expiration, then sweep.
// SQLite's unixepoch() is integer seconds, so we need a full
// second's gap from the (now + 1) expiration; bump to 2.5s
// to absorb thread-scheduling jitter on busy CI runners.
kotlinx.coroutines.delay(2500)
hub.getOrCreate(relayUrl).store.deleteExpiredEvents()
// Long-lived survives.
assertNotNull(
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(longLived.id))),
)
// Short-lived is gone.
assertEquals(
null,
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(shortLived.id))),
"deleteExpiredEvents() must purge the short-lived event",
)
}
}
@@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
/**
* NIP-62 right-to-vanish:
* - A kind-62 event scoped to a relay URL cascades-deletes ALL of the
* author's earlier events on that relay.
* - After the vanish, attempts to insert OLDER events from that author
* are rejected (the SQL `reject_events_on_event_vanish` trigger).
* - Newer events from the same author (createdAt > vanish.createdAt)
* can still be published — the user is asking the relay to forget
* their past, not to ban them.
* - A vanish from author A does not affect author B's events.
*/
class Nip62VanishTest {
private lateinit var hub: RelayHub
private lateinit var scope: CoroutineScope
private lateinit var client: NostrClient
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
@BeforeTest
fun setup() {
hub = RelayHub()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
client = NostrClient(hub, scope)
}
@AfterTest
fun teardown() {
client.disconnect()
scope.cancel()
hub.close()
}
@Test
fun vanishCascadesPriorEventsFromSameAuthor() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
val now = TimeUtils.now()
val a = signer.sign(TextNoteEvent.build("first", createdAt = now - 100))
val b = signer.sign(TextNoteEvent.build("second", createdAt = now - 50))
client.publishAndConfirm(a, setOf(relayUrl))
client.publishAndConfirm(b, setOf(relayUrl))
assertNotNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(a.id))))
assertNotNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(b.id))))
val vanish =
signer.sign(
RequestToVanishEvent.build(
relay = relayUrl,
reason = "GDPR cleanup",
createdAt = now,
),
)
assertEquals(true, client.publishAndConfirm(vanish, setOf(relayUrl)))
assertNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(a.id))))
assertNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(b.id))))
}
@Test
fun vanishBlocksReinsertionOfOlderEvents() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
val now = TimeUtils.now()
val vanish =
signer.sign(
RequestToVanishEvent.build(relay = relayUrl, createdAt = now),
)
client.publishAndConfirm(vanish, setOf(relayUrl))
// Older event from the same author must be rejected.
val older = signer.sign(TextNoteEvent.build("comeback", createdAt = now - 100))
val ok = client.publishAndConfirm(older, setOf(relayUrl))
assertEquals(false, ok, "events older than the vanish must be rejected")
}
@Test
fun vanishDoesNotAffectOtherAuthors() =
runBlocking {
val alice = NostrSignerSync(KeyPair())
val bob = NostrSignerSync(KeyPair())
val now = TimeUtils.now()
val aliceNote = alice.sign(TextNoteEvent.build("alice", createdAt = now - 100))
val bobNote = bob.sign(TextNoteEvent.build("bob", createdAt = now - 100))
client.publishAndConfirm(aliceNote, setOf(relayUrl))
client.publishAndConfirm(bobNote, setOf(relayUrl))
val aliceVanish =
alice.sign(RequestToVanishEvent.build(relay = relayUrl, createdAt = now))
client.publishAndConfirm(aliceVanish, setOf(relayUrl))
assertNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(aliceNote.id))))
val bobStillThere =
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(bobNote.id)))
assertEquals(bobNote.id, bobStillThere?.id, "bob's events must survive alice's vanish")
}
}
@@ -0,0 +1,273 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySession
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* End-to-end NIP-77 reconciliation through `RelayHub` + the
* in-process WebSocket bridge.
*
* - The relay is preloaded with a known set of events.
* - A [NegentropySession] is initialised with a partially-overlapping
* set on the client side.
* - We drive the NEG-OPEN / NEG-MSG round trips manually until
* `processMessage` reports completion.
* - We assert that `haveIds` (events the client has that the relay
* doesn't) and `needIds` (events the relay has that the client
* doesn't) cover exactly the symmetric difference.
*
* NEG-CLOSE is exercised separately — sending NEG-MSG after CLOSE
* must surface a NEG-ERR from the relay.
*/
class Nip77NegentropyTest {
private lateinit var hub: RelayHub
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
@BeforeTest
fun setup() {
hub = RelayHub()
}
@AfterTest
fun teardown() {
hub.close()
}
/**
* Bare-bones WS client that captures every server message into a
* channel and exposes a `send` that goes straight at the in-process
* bridge. We don't need NostrClient's filter-management for these
* tests — we drive the wire.
*/
private class WireClient(
hub: RelayHub,
url: NormalizedRelayUrl,
) {
val incoming: Channel<String> = Channel(UNLIMITED)
private val ws =
hub.build(
url,
object : WebSocketListener {
override fun onOpen(
pingMillis: Int,
compression: Boolean,
) {}
override fun onMessage(text: String) {
incoming.trySend(text)
}
override fun onClosed(
code: Int,
reason: String,
) {
incoming.close()
}
override fun onFailure(
t: Throwable,
code: Int?,
response: String?,
) {
incoming.close(t)
}
},
)
init {
ws.connect()
}
fun send(json: String) {
check(ws.send(json)) { "send returned false" }
}
fun close() {
ws.disconnect()
}
}
private suspend fun WireClient.nextMessage(timeoutMs: Long = 5_000): Message {
val raw = withTimeout(timeoutMs) { incoming.receive() }
return OptimizedJsonMapper.fromJsonToMessage(raw)
}
/** Generates [count] signed text notes with monotonic createdAt. */
private fun makeEvents(count: Int): List<Event> {
val signer = NostrSignerSync(KeyPair())
val now = 1_700_000_000L
return List(count) { i ->
signer.sign(TextNoteEvent.build("event-$i", createdAt = now + i))
}
}
@Test
fun negentropyComputesSymmetricDifference() =
runBlocking {
// Universe of 10 events. Relay has events [0..7], client has [3..9] —
// overlap [3..7], relay-only [0..2], client-only [8..9].
val all = makeEvents(10)
val relayEvents = all.subList(0, 8)
val clientEvents = all.subList(3, 10)
hub.getOrCreate(relayUrl).preload(relayEvents)
val client = WireClient(hub, relayUrl)
try {
val session =
NegentropySession(
subId = "neg-1",
filter = Filter(kinds = listOf(1)),
localEvents = clientEvents,
)
// Step 1: send NEG-OPEN.
val openCmd = session.open()
client.send(OptimizedJsonMapper.toJson(openCmd))
// Step 2: drive NEG-MSG round trips until reconciliation completes.
val haveIds = mutableSetOf<String>()
val needIds = mutableSetOf<String>()
var safety = 32
while (safety-- > 0) {
val response = client.nextMessage()
if (response is NegErrMessage) {
kotlin.test.fail("relay sent NEG-ERR: ${response.reason}")
}
response as NegMsgMessage
val result = session.processMessage(response.message)
haveIds += result.haveIds
needIds += result.needIds
if (result.isComplete()) break
client.send(OptimizedJsonMapper.toJson(result.nextCmd!!))
}
assertTrue(safety > 0, "reconciliation did not converge in 32 rounds")
// Step 3: verify the symmetric difference.
val expectedNeed = relayEvents.subList(0, 3).map { it.id }.toSet() // [0..2]
val expectedHave = clientEvents.subList(5, 7).map { it.id }.toSet() // [8..9]
assertEquals(expectedNeed, needIds, "client should NEED events 0..2 from relay")
assertEquals(expectedHave, haveIds, "client should HAVE events 8..9 to send to relay")
} finally {
client.close()
}
}
@Test
fun negCloseFreesServerStateAndReopenWorks() =
runBlocking {
val all = makeEvents(5)
hub.getOrCreate(relayUrl).preload(all)
val client = WireClient(hub, relayUrl)
try {
// First session.
val s1 = NegentropySession("neg", Filter(kinds = listOf(1)), localEvents = emptyList())
client.send(OptimizedJsonMapper.toJson(s1.open()))
val response = client.nextMessage() as NegMsgMessage
val r1 = s1.processMessage(response.message)
// Client had nothing, so it needs all 5 from the relay.
assertEquals(5, r1.needIds.size)
// Close.
client.send(OptimizedJsonMapper.toJson(s1.close()))
// Re-OPEN with the same subId and an empty client store —
// server must build a new session and respond. If the
// close didn't free state, this would either error or
// continue the previous reconciliation.
val s2 = NegentropySession("neg", Filter(kinds = listOf(1)), localEvents = emptyList())
client.send(OptimizedJsonMapper.toJson(s2.open()))
val resp2 = client.nextMessage() as NegMsgMessage
val r2 = s2.processMessage(resp2.message)
assertEquals(5, r2.needIds.size)
} finally {
client.close()
}
}
@Test
fun negMsgWithoutOpenReturnsNegErr() =
runBlocking {
val client = WireClient(hub, relayUrl)
try {
// Synthesise a stray NEG-MSG for a sub-id that was never opened.
val raw = """["NEG-MSG","ghost-sub","00"]"""
client.send(raw)
val response = client.nextMessage()
assertTrue(response is NegErrMessage, "expected NEG-ERR, got ${response::class.simpleName}")
assertEquals("ghost-sub", response.subId)
assertTrue(response.reason.contains("no negentropy session"))
} finally {
client.close()
}
}
@Test
fun negOpenWithSameSubIdReplacesPriorSession() =
runBlocking {
val a = makeEvents(3)
val b = makeEvents(2)
hub.getOrCreate(relayUrl).preload(a + b)
val client = WireClient(hub, relayUrl)
try {
// First open with localEvents = a; next we'll re-open
// and confirm the new session sees a fresh state.
val first = NegentropySession("dup", Filter(kinds = listOf(1)), localEvents = a)
client.send(OptimizedJsonMapper.toJson(first.open()))
client.nextMessage() as NegMsgMessage // discard
// Re-OPEN with same subId, different localEvents.
val second = NegentropySession("dup", Filter(kinds = listOf(1)), localEvents = a + b)
client.send(OptimizedJsonMapper.toJson(second.open()))
val resp = client.nextMessage() as NegMsgMessage
val r = second.processMessage(resp.message)
// Client now has every event the relay has → nothing to need.
assertEquals(0, r.needIds.size)
assertEquals(0, r.haveIds.size)
} finally {
client.close()
}
}
}
@@ -0,0 +1,232 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.admin
import com.vitorpamplona.geode.LocalRelayServer
import com.vitorpamplona.geode.Relay
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Drives a real `LocalRelayServer` over HTTP and proves the NIP-86
* admin RPC flow works end-to-end: NIP-98 auth, admin allow-list
* gate, ban mutation, and the resulting policy effect on a follow-up
* EVENT publish.
*/
class Nip86EndToEndTest {
private lateinit var relay: Relay
private lateinit var server: LocalRelayServer
private lateinit var scope: CoroutineScope
private lateinit var nostrClient: NostrClient
private val httpClient = OkHttpClient.Builder().build()
private val admin = NostrSignerSync(KeyPair())
private val outsider = NostrSignerSync(KeyPair())
private val targetUser = NostrSignerSync(KeyPair())
@BeforeTest
fun setup() {
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
relay = Relay(url = placeholder)
server =
LocalRelayServer(
relay = relay,
host = "127.0.0.1",
port = 0,
adminPubkeys = setOf(admin.pubKey),
).start()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient }
nostrClient = NostrClient(builder, scope)
}
@AfterTest
fun teardown() {
nostrClient.disconnect()
scope.cancel()
server.stop(gracePeriodMillis = 200, timeoutMillis = 500)
relay.close()
}
private val httpUrl get() = server.url.replace("ws://", "http://")
/** Sends a NIP-86 RPC request signed by [signer] and returns the raw HTTP response. */
private fun rpc(
request: Nip86Request,
signer: NostrSignerSync,
): okhttp3.Response {
val body = JsonMapper.toJson(request).encodeToByteArray()
val authTemplate =
HTTPAuthorizationEvent.build(url = httpUrl, method = "POST", file = body)
val authToken = signer.sign(authTemplate).toAuthToken()
return httpClient
.newCall(
Request
.Builder()
.url(httpUrl)
.post(body.toRequestBody("application/nostr+json+rpc".toMediaType()))
.header("Authorization", authToken)
.build(),
).execute()
}
@Test
fun supportedMethodsListsTheServersMethods() {
rpc(Nip86Request.supportedMethods(), admin).use {
assertEquals(200, it.code)
val json = JsonMapper.fromJson<com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response>(it.body.string())
val arr = json.result as JsonArray
val names = arr.map { e -> e.jsonPrimitive.content }
assertTrue(names.contains("supportedmethods"))
assertTrue(names.contains("banpubkey"))
}
}
@Test
fun foreignSignerReturns403() {
rpc(Nip86Request.supportedMethods(), outsider).use {
assertEquals(403, it.code)
}
}
@Test
fun missingAuthHeaderReturns401() {
val body = JsonMapper.toJson(Nip86Request.supportedMethods()).encodeToByteArray()
httpClient
.newCall(
Request
.Builder()
.url(httpUrl)
.post(body.toRequestBody("application/nostr+json+rpc".toMediaType()))
.build(),
).execute()
.use {
assertEquals(401, it.code)
assertTrue(it.headers["WWW-Authenticate"]?.startsWith("Nostr") == true)
}
}
@Test
fun banPubkeyBlocksSubsequentEventsFromThatAuthor() =
runBlocking {
val relayUrl = server.url.normalizeRelayUrl()
// Baseline: targetUser can publish.
val before = nostrClient.publishAndConfirm(targetUser.sign(TextNoteEvent.build("first")), setOf(relayUrl))
assertEquals(true, before)
// Admin bans them.
rpc(Nip86Request.banPubkey(targetUser.pubKey, "spam"), admin).use {
assertEquals(200, it.code)
val resp = JsonMapper.fromJson<com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response>(it.body.string())
assertEquals(true, (resp.result as JsonPrimitive).boolean)
}
// Subsequent EVENT from the banned author is rejected.
val after = nostrClient.publishAndConfirm(targetUser.sign(TextNoteEvent.build("second")), setOf(relayUrl))
assertEquals(false, after, "BanListPolicy must reject events from banned pubkeys")
}
@Test
fun changeRelayNameFlowsToNip11Endpoint() {
rpc(Nip86Request.changeRelayName("renamed-by-admin"), admin).use {
assertEquals(200, it.code)
}
// Read the NIP-11 endpoint and confirm the new name is live.
val response =
httpClient
.newCall(
Request
.Builder()
.url(httpUrl)
.header("Accept", "application/nostr+json")
.build(),
).execute()
response.use {
val info = Nip11RelayInformation.fromJson(it.body.string())
assertEquals("renamed-by-admin", info.name)
}
}
@Test
fun adminEndpointDisabledWhenNoPubkeysConfigured() =
runBlocking {
// Spin up a *separate* server with no admin pubkeys.
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
val openRelay = Relay(url = placeholder)
val openServer =
LocalRelayServer(openRelay, host = "127.0.0.1", port = 0).start()
try {
val openHttpUrl = openServer.url.replace("ws://", "http://")
val body =
JsonMapper.toJson(Nip86Request.supportedMethods()).encodeToByteArray()
val authToken =
admin
.sign(
HTTPAuthorizationEvent.build(url = openHttpUrl, method = "POST", file = body),
).toAuthToken()
httpClient
.newCall(
Request
.Builder()
.url(openHttpUrl)
.post(body.toRequestBody("application/nostr+json+rpc".toMediaType()))
.header("Authorization", authToken)
.build(),
).execute()
.use {
assertEquals(403, it.code)
}
} finally {
openServer.stop(gracePeriodMillis = 100, timeoutMillis = 500)
openRelay.close()
}
}
}
@@ -0,0 +1,156 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.config
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class RelayConfigTest {
@Test
fun emptyTomlYieldsAllDefaults() {
val c = RelayConfig.fromToml("")
assertEquals("0.0.0.0", c.network.host)
assertEquals(7447, c.network.port)
assertEquals("/", c.network.path)
assertEquals(true, c.database.in_memory)
assertEquals(false, c.options.require_auth)
// Verify is on by default — operators have to opt out explicitly.
assertEquals(true, c.options.verify_signatures)
assertTrue(c.authorization.pubkey_whitelist.isEmpty())
}
@Test
fun verifySignaturesCanBeExplicitlyDisabled() {
val c = RelayConfig.fromToml("[options]\nverify_signatures = false")
assertEquals(false, c.options.verify_signatures)
}
@Test
fun parsesAllSectionsTogether() {
val toml =
"""
[info]
relay_url = "wss://relay.example.com/"
name = "Example"
contact = "ops@example.com"
supported_nips = [1, 9, 11, 42]
[network]
host = "127.0.0.1"
port = 9988
path = "/relay"
[database]
in_memory = false
file = "/var/lib/quartz-relay/events.db"
[options]
verify_signatures = true
require_auth = true
reject_future_seconds = 1800
[limits]
max_ws_frame_bytes = 1048576
[authorization]
pubkey_blacklist = ["aaaa", "bbbb"]
kind_blacklist = [4, 1059]
""".trimIndent()
val c = RelayConfig.fromToml(toml)
assertEquals("wss://relay.example.com/", c.info.relay_url)
assertEquals("Example", c.info.name)
assertEquals(listOf(1, 9, 11, 42), c.info.supported_nips)
assertEquals("127.0.0.1", c.network.host)
assertEquals(9988, c.network.port)
assertEquals("/relay", c.network.path)
assertEquals(false, c.database.in_memory)
assertEquals("/var/lib/quartz-relay/events.db", c.database.file)
assertEquals(true, c.options.verify_signatures)
assertEquals(true, c.options.require_auth)
assertEquals(1800, c.options.reject_future_seconds)
assertEquals(1_048_576, c.limits.max_ws_frame_bytes)
assertEquals(listOf("aaaa", "bbbb"), c.authorization.pubkey_blacklist)
assertEquals(listOf(4, 1059), c.authorization.kind_blacklist)
}
@Test
fun supportedNipsRenderedAsStringsInNip11Doc() {
val c =
RelayConfig.fromToml(
"""
[info]
supported_nips = [1, 11, 42]
""".trimIndent(),
)
val info =
c.resolveInfo(
"ws://127.0.0.1:7447/".let {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
.normalize(it)
},
)
assertEquals(listOf("1", "11", "42"), info.document.supported_nips)
}
@Test
fun loadsTheBundledExampleConfigCleanly() {
// The example file lives at the module root so operators have a
// canonical reference. Read it via a relative path resolved
// against the working directory (gradle runs tests from the
// module dir).
val candidates =
listOf(
File("config.example.toml"),
File("geode/config.example.toml"),
)
val example =
candidates.firstOrNull { it.exists() }
?: error(
"config.example.toml not found in any of: ${candidates.joinToString { it.absolutePath }}",
)
val c = RelayConfig.fromFile(example)
assertEquals("wss://relay.example.com/", c.info.relay_url)
assertEquals(true, c.options.verify_signatures)
assertEquals(false, c.database.in_memory)
assertNotNull(c.database.file)
}
@Test
fun missingSectionsAreOptional() {
val c = RelayConfig.fromToml("[info]\nname = \"only-info\"")
assertEquals("only-info", c.info.name)
// Defaults preserved for unspecified sections.
assertEquals(7447, c.network.port)
assertEquals(true, c.database.in_memory)
}
}
@@ -0,0 +1,333 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.perf
import com.vitorpamplona.geode.LocalRelayServer
import com.vitorpamplona.geode.Relay
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import java.util.concurrent.atomic.AtomicLong
import kotlin.test.Test
import kotlin.time.measureTime
/**
* Single-process load tests that report real numbers for:
* - WebSocket connection establishment rate
* - Concurrent subscription steady-state count
* - Live-event fanout latency to N subscribers
* - End-to-end EVENT publish throughput (EVENT store OK)
*
* Disabled by default runs only when `runLoadBenchmark` system
* property is set. Add `-DrunLoadBenchmark=true` to a Gradle test
* invocation. Skipped under the normal test run because (a) numbers
* vary on busy CI runners, and (b) some scenarios spin up thousands
* of sockets which is rude on shared infra.
*/
class LoadBenchmark {
private val enabled = System.getProperty("runLoadBenchmark") == "true"
private inline fun benchmark(
name: String,
block: () -> Unit,
) {
if (!enabled) {
println("[skip] $name — set -DrunLoadBenchmark=true to enable")
return
}
println("--- $name ---")
block()
}
/**
* How many concurrent WebSocket *connections* can we hold open?
* Each test client opens a raw WS, sends one REQ, expects EOSE.
* Stays connected after that.
*/
@Test
fun connectionsHeldOpen() =
benchmark("connections held open") {
for (target in listOf(100, 500, 1_000, 2_000, 5_000, 10_000)) {
runBenchmarkServer { server, http ->
val httpUrl =
okhttp3.Request
.Builder()
.url(server.url.replace("ws://", "http://"))
.build()
val sockets = java.util.concurrent.CopyOnWriteArrayList<okhttp3.WebSocket>()
val opened = AtomicLong()
val gotEose = AtomicLong()
val opens =
measureTime {
repeat(target) {
val ws =
http.newWebSocket(
httpUrl,
object : okhttp3.WebSocketListener() {
override fun onOpen(
webSocket: okhttp3.WebSocket,
response: okhttp3.Response,
) {
opened.incrementAndGet()
webSocket.send(
"""["REQ","s",{"kinds":[1],"limit":1}]""",
)
}
override fun onMessage(
webSocket: okhttp3.WebSocket,
text: String,
) {
if (text.startsWith("[\"EOSE\"")) {
gotEose.incrementAndGet()
}
}
},
)
sockets += ws
}
// Wait for either EOSE on every connection or 60s deadline.
val deadline = System.currentTimeMillis() + 60_000
while (gotEose.get() < target && System.currentTimeMillis() < deadline) {
Thread.sleep(50)
}
}
// Let activeSessionCount settle.
Thread.sleep(200)
println(
"target=$target opened=${opened.get()} eosed=${gotEose.get()} " +
"active=${server.activeSessionCount} elapsedMs=${opens.inWholeMilliseconds}",
)
sockets.forEach { runCatching { it.cancel() } }
if (gotEose.get() < target) {
println(" --> degradation at $target; stopping ramp-up")
return@runBenchmarkServer
}
}
}
}
/**
* One publisher sends 10k events serially. Measures the round-trip
* `EVENT` `OK true` time, which is dominated by SQLite write
* throughput + the write side of the policy stack.
*/
@Test
fun publishThroughputSingleClient() =
benchmark("publish throughput single client") {
runBenchmarkServer { server, http ->
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
try {
val signer = NostrSignerSync(KeyPair())
val relayUrl = server.url.normalizeRelayUrl()
val n = 10_000
var ok = 0
val elapsed =
measureTime {
runBlocking {
repeat(n) { i ->
val event = signer.sign(TextNoteEvent.build("hello $i"))
if (client.publishAndConfirm(event, setOf(relayUrl))) ok++
}
}
}
val eps = (n * 1000.0) / elapsed.inWholeMilliseconds
println("events=$n ok=$ok elapsedMs=${elapsed.inWholeMilliseconds} eps=${"%.0f".format(eps)}")
} finally {
client.disconnect()
scope.cancel()
}
}
}
/**
* One publisher, N subscribers. Publishes one EVENT and measures
* fan-out latency: time from publish to last subscriber receiving.
*/
@Test
fun fanoutLatency() =
benchmark("fanout latency") {
for (subs in listOf(100, 500, 1_000, 2_000)) {
runBenchmarkServer { server, http ->
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val subClient = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
val pubClient = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
try {
val relayUrl = server.url.normalizeRelayUrl()
val received = AtomicLong()
val firstReceiveNs = AtomicLong(-1)
val lastReceiveNs = AtomicLong(-1)
// Set up `subs` subscribers.
val eosed = AtomicLong()
repeat(subs) { i ->
subClient.subscribe(
"fanout-$i",
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))),
object : SubscriptionListener {
override fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
val now = System.nanoTime()
firstReceiveNs.compareAndSet(-1, now)
lastReceiveNs.set(now)
received.incrementAndGet()
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eosed.incrementAndGet()
}
},
)
}
runBlocking {
withTimeout(60_000) {
while (eosed.get() < subs) kotlinx.coroutines.delay(50)
}
}
println("$subs subs ready; publishing one event...")
val signer = NostrSignerSync(KeyPair())
val event = signer.sign(TextNoteEvent.build("fanout"))
val publishStart = System.nanoTime()
runBlocking {
pubClient.publishAndConfirm(event, setOf(relayUrl))
}
val publishEnd = System.nanoTime()
// Wait for fanout to complete.
runBlocking {
withTimeout(60_000) {
while (received.get() < subs) kotlinx.coroutines.delay(10)
}
}
val firstFanoutMs = (firstReceiveNs.get() - publishStart) / 1_000_000.0
val lastFanoutMs = (lastReceiveNs.get() - publishStart) / 1_000_000.0
val publishMs = (publishEnd - publishStart) / 1_000_000.0
println(
"subs=$subs publishMs=${"%.1f".format(publishMs)} " +
"fanoutFirstMs=${"%.1f".format(firstFanoutMs)} " +
"fanoutLastMs=${"%.1f".format(lastFanoutMs)} " +
"received=${received.get()}/$subs",
)
} finally {
subClient.disconnect()
pubClient.disconnect()
scope.cancel()
}
}
}
}
/**
* Many concurrent publishers, each on their own WebSocket. Tells
* us whether the SQLite single-writer bottleneck is the floor or
* if there's contention upstream.
*/
@Test
fun publishThroughputConcurrent() =
benchmark("publish throughput concurrent") {
for (parallel in listOf(2, 4, 8, 16, 32)) {
runBenchmarkServer { server, http ->
val total = 5_000
val perThread = total / parallel
val ok = AtomicLong()
val elapsed =
measureTime {
val threads =
(0 until parallel).map { tid ->
Thread {
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
try {
val signer = NostrSignerSync(KeyPair())
val relayUrl = server.url.normalizeRelayUrl()
runBlocking {
repeat(perThread) { i ->
val ev = signer.sign(TextNoteEvent.build("hi-$tid-$i"))
if (client.publishAndConfirm(ev, setOf(relayUrl))) ok.incrementAndGet()
}
}
} finally {
client.disconnect()
scope.cancel()
}
}.also { it.start() }
}
threads.forEach { it.join() }
}
val eps = (ok.get() * 1000.0) / elapsed.inWholeMilliseconds
println(
"parallel=$parallel total=${ok.get()}/$total elapsedMs=${elapsed.inWholeMilliseconds} eps=${"%.0f".format(eps)}",
)
}
}
}
/** Spin up an isolated relay + http client per scenario. */
private inline fun runBenchmarkServer(block: (LocalRelayServer, OkHttpClient) -> Unit) {
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
val relay = Relay(url = placeholder)
val server = LocalRelayServer(relay, host = "127.0.0.1", port = 0).start()
val http =
OkHttpClient
.Builder()
// Don't bottleneck on the OkHttp dispatcher when we
// open thousands of WS connections from one client.
.dispatcher(
okhttp3.Dispatcher().apply {
maxRequests = 100_000
maxRequestsPerHost = 100_000
},
).build()
try {
block(server, http)
} finally {
http.dispatcher.executorService.shutdownNow()
server.stop(gracePeriodMillis = 200, timeoutMillis = 1_000)
relay.close()
}
}
}
@@ -0,0 +1,208 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.persistence
import com.vitorpamplona.geode.Relay
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import java.io.File
import java.nio.file.Files
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PersistenceTest {
private lateinit var dir: File
private lateinit var stateFile: File
private val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
@BeforeTest
fun setup() {
dir = Files.createTempDirectory("quartz-relay-persist-").toFile()
stateFile = File(dir, "admin.json")
}
@AfterTest
fun teardown() {
dir.deleteRecursively()
}
@Test
fun firstBootWritesNothingUntilFirstMutation() {
val relay = Relay(url = url, stateFile = stateFile)
try {
// No mutation yet → file does not exist.
assertTrue(!stateFile.exists(), "fresh relay must not eagerly write a snapshot")
} finally {
relay.close()
}
}
@Test
fun banPubkeyTriggersSnapshotAndSurvivesRestart() {
val pk = "a".repeat(64)
val r1 = Relay(url = url, stateFile = stateFile)
try {
r1.banStore.banPubkey(pk, "spam")
} finally {
r1.close()
}
assertTrue(stateFile.exists(), "snapshot must be written after a mutation")
// Fresh relay reads the snapshot and sees the ban.
val r2 = Relay(url = url, stateFile = stateFile)
try {
assertTrue(r2.banStore.isBanned(pk))
assertEquals("spam", r2.banStore.listBannedPubkeys()[0].second)
} finally {
r2.close()
}
}
@Test
fun updateInfoSurvivesRestart() {
val r1 = Relay(url = url, stateFile = stateFile)
try {
r1.updateInfo { it.copy(name = "renamed") }
} finally {
r1.close()
}
val r2 = Relay(url = url, stateFile = stateFile)
try {
assertEquals("renamed", r2.info.document.name)
} finally {
r2.close()
}
}
@Test
fun allowKindRoundTripsAcrossRestart() {
val r1 = Relay(url = url, stateFile = stateFile)
try {
r1.banStore.allowKind(1)
r1.banStore.allowKind(7)
r1.banStore.disallowKind(4)
} finally {
r1.close()
}
val r2 = Relay(url = url, stateFile = stateFile)
try {
assertEquals(listOf(1, 7), r2.banStore.listAllowedKinds())
assertEquals(listOf(4), r2.banStore.listDisallowedKinds())
} finally {
r2.close()
}
}
@Test
fun corruptStateFileIsTolerated() {
stateFile.writeText("not valid json {")
// Should not throw — just log and start fresh.
val r = Relay(url = url, stateFile = stateFile)
try {
assertTrue(r.banStore.listBannedPubkeys().isEmpty())
assertTrue(r.banStore.listAllowedKinds().isEmpty())
} finally {
r.close()
}
}
@Test
fun snapshotWriteIsAtomicViaTempFile() {
// After a mutation completes, no `.tmp` file should remain.
val r = Relay(url = url, stateFile = stateFile)
try {
r.banStore.banPubkey("b".repeat(64))
val tmp = File(dir, "admin.json.tmp")
assertTrue(!tmp.exists(), "tmp file must be moved into place, not left behind")
} finally {
r.close()
}
}
@Test
fun missingStateFileMeansInMemoryOnly() {
val r = Relay(url = url) // no stateFile
try {
r.banStore.banPubkey("c".repeat(64))
// No snapshot path → nothing on disk in our temp dir.
assertNull(dir.list()?.firstOrNull { it.startsWith("admin") })
} finally {
r.close()
}
}
@Test
fun stateStoreRoundTripsAllSections() {
val ss = RelayStateStore(stateFile)
val state =
RelayPersistedState(
info = Nip11RelayInformation(name = "x", description = "y"),
bannedPubkeys = listOf(BannedEntry("aa", "spam"), BannedEntry("bb", null)),
allowedPubkeys = listOf(BannedEntry("cc", "trusted")),
bannedEvents = listOf(BannedEntry("dd", "off-topic")),
allowedKinds = listOf(1, 7),
disallowedKinds = listOf(4, 1059),
)
ss.save(state)
val loaded = ss.load()!!
assertEquals("x", loaded.info!!.name)
assertEquals(2, loaded.bannedPubkeys.size)
assertEquals(listOf(1, 7), loaded.allowedKinds)
assertEquals(listOf(4, 1059), loaded.disallowedKinds)
}
/**
* Manual `Nip11RelayInformation.copy` the class isn't a data
* class so Kotlin doesn't generate one. We only need `name` here.
*/
private fun Nip11RelayInformation.copy(name: String? = this.name) =
Nip11RelayInformation(
id = id,
name = name,
description = description,
icon = icon,
pubkey = pubkey,
self = self,
contact = contact,
supported_nips = supported_nips,
supported_nip_extensions = supported_nip_extensions,
software = software,
version = version,
limitation = limitation,
relay_countries = relay_countries,
language_tags = language_tags,
tags = tags,
posting_policy = posting_policy,
privacy_policy = privacy_policy,
terms_of_service = terms_of_service,
payments_url = payments_url,
retention = retention,
fees = fees,
nip50 = nip50,
supported_grasps = supported_grasps,
)
}
@@ -0,0 +1,127 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.policies
import com.vitorpamplona.geode.RelayHub
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* End-to-end through `NostrClient RelayHub Relay` with the policies
* actually wired into the relay. Proves an EVENT command sent on the
* wire surfaces an OK false response when the policy rejects.
*/
class PoliciesIntegrationTest {
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
private lateinit var scope: CoroutineScope
@BeforeTest
fun setup() {
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
}
@AfterTest
fun teardown() {
scope.cancel()
}
/** Spin up a hub whose only relay uses the supplied policy factory. */
private fun hubWith(policyFactory: () -> IRelayPolicy): Pair<NostrClient, RelayHub> {
val hub = RelayHub(defaultPolicy = policyFactory)
// Materialise the relay so the URL resolves in the hub.
hub.getOrCreate(relayUrl)
return NostrClient(hub, scope) to hub
}
@Test
fun kindBlacklistRejectsKind4OverWire() =
runBlocking {
val (client, hub) = hubWith { KindAllowDenyPolicy(deny = setOf(4)) }
try {
val signer = NostrSignerSync(KeyPair())
val ok = client.publishAndConfirm(signer.sign(TextNoteEvent.build("ok")), setOf(relayUrl))
assertEquals(true, ok, "kind 1 must pass")
// Synthetic kind-4 event — the relay's deny list rejects it.
val kind4 = SyntheticEvents.fakeEvent(idSeed = 999, kind = 4, pubKey = signer.pubKey)
val rejected = client.publishAndConfirm(kind4, setOf(relayUrl))
assertEquals(false, rejected, "kind 4 must be rejected")
} finally {
client.disconnect()
hub.close()
}
}
@Test
fun pubkeyAllowListRejectsForeignAuthorOverWire() =
runBlocking {
val alice = NostrSignerSync(KeyPair())
val mallory = NostrSignerSync(KeyPair())
val (client, hub) = hubWith { PubkeyAllowDenyPolicy(allow = setOf(alice.pubKey)) }
try {
val accepted = client.publishAndConfirm(alice.sign(TextNoteEvent.build("hi")), setOf(relayUrl))
assertEquals(true, accepted)
val denied = client.publishAndConfirm(mallory.sign(TextNoteEvent.build("nope")), setOf(relayUrl))
assertEquals(false, denied)
} finally {
client.disconnect()
hub.close()
}
}
@Test
fun rejectFutureEventsBlocksFarFutureCreatedAtOverWire() =
runBlocking {
// Use a fixed clock so the policy decision is deterministic.
val frozen = 1_000_000L
val (client, hub) =
hubWith { RejectFutureEventsPolicy(maxFutureSeconds = 60, now = { frozen }) }
try {
val signer = NostrSignerSync(KeyPair())
val nearby = signer.sign(TextNoteEvent.build("ok", createdAt = frozen + 30))
assertEquals(true, client.publishAndConfirm(nearby, setOf(relayUrl)))
val tooFar = signer.sign(TextNoteEvent.build("nope", createdAt = frozen + 3600))
assertEquals(false, client.publishAndConfirm(tooFar, setOf(relayUrl)))
} finally {
client.disconnect()
hub.close()
}
}
}
@@ -0,0 +1,175 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.policies
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
import kotlin.test.Test
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* Per-policy unit tests. Each policy gets a small, focused suite that
* proves accept/reject behaviour at the boundaries (empty config,
* single hit, collision between allow + deny, etc.).
*
* The end-to-end "policy is applied through the Ktor server" coverage
* lives in `LocalRelayServerTest` / `Nip01ComplianceTest` these tests
* just exercise the policy in isolation.
*/
class PoliciesTest {
private fun event(
kind: Int = 1,
pubKey: String = SyntheticEvents.hexId(1),
createdAt: Long = 1000L,
content: String = "",
) = SyntheticEvents.fakeEvent(idSeed = 1, kind = kind, pubKey = pubKey, createdAt = createdAt, content = content)
private fun assertAccepted(result: PolicyResult<*>) {
if (result is PolicyResult.Rejected) fail("expected Accepted, got Rejected: ${result.reason}")
}
private fun assertRejected(
result: PolicyResult<*>,
reasonContains: String? = null,
) {
when (result) {
is PolicyResult.Accepted -> {
fail("expected Rejected, got Accepted")
}
is PolicyResult.Rejected -> {
reasonContains?.let {
assertTrue(
result.reason.contains(it),
"expected reason to contain '$it', got '${result.reason}'",
)
}
}
}
}
// -- KindAllowDenyPolicy -------------------------------------------------
@Test
fun kindPolicyEmptyListsAreNoOp() {
val p = KindAllowDenyPolicy()
assertAccepted(p.accept(EventCmd(event(kind = 1))))
assertAccepted(p.accept(EventCmd(event(kind = 99))))
}
@Test
fun kindAllowListExcludesEverythingElse() {
val p = KindAllowDenyPolicy(allow = setOf(1, 7))
assertAccepted(p.accept(EventCmd(event(kind = 1))))
assertAccepted(p.accept(EventCmd(event(kind = 7))))
assertRejected(p.accept(EventCmd(event(kind = 4))), reasonContains = "kind 4 not allowed")
}
@Test
fun kindDenyListBlocksLastWordOverAllowList() {
// When both lists are set, allow is a permissive ceiling and
// deny still removes specific kinds inside it.
val p = KindAllowDenyPolicy(allow = setOf(1, 4, 7), deny = setOf(4))
assertAccepted(p.accept(EventCmd(event(kind = 1))))
assertRejected(p.accept(EventCmd(event(kind = 4))), reasonContains = "kind 4 denied")
assertRejected(p.accept(EventCmd(event(kind = 999))), reasonContains = "not allowed")
}
// -- PubkeyAllowDenyPolicy ----------------------------------------------
@Test
fun pubkeyAllowList() {
val alice = SyntheticEvents.hexId(101)
val mallory = SyntheticEvents.hexId(102)
val p = PubkeyAllowDenyPolicy(allow = setOf(alice))
assertAccepted(p.accept(EventCmd(event(pubKey = alice))))
assertRejected(p.accept(EventCmd(event(pubKey = mallory))), reasonContains = "not on allow")
}
@Test
fun pubkeyDenyList() {
val alice = SyntheticEvents.hexId(101)
val mallory = SyntheticEvents.hexId(102)
val p = PubkeyAllowDenyPolicy(deny = setOf(mallory))
assertAccepted(p.accept(EventCmd(event(pubKey = alice))))
assertRejected(p.accept(EventCmd(event(pubKey = mallory))), reasonContains = "denied")
}
@Test
fun pubkeyMatchIsCaseInsensitive() {
val pk = "ABCDEF".padEnd(64, '0')
val p = PubkeyAllowDenyPolicy(deny = setOf(pk.lowercase()))
// Event arrives with the upper-case form; policy must match.
assertRejected(p.accept(EventCmd(event(pubKey = pk))))
}
// -- RejectFutureEventsPolicy -------------------------------------------
@Test
fun futureEventsBeyondSkewAreRejected() {
val now = 1_000_000L
val p = RejectFutureEventsPolicy(maxFutureSeconds = 60, now = { now })
assertAccepted(p.accept(EventCmd(event(createdAt = now + 60))))
assertAccepted(p.accept(EventCmd(event(createdAt = now))))
assertAccepted(p.accept(EventCmd(event(createdAt = now - 9999)))) // past is fine
assertRejected(p.accept(EventCmd(event(createdAt = now + 61))), reasonContains = "future")
}
@Test
fun futureEventsZeroSkewMeansOnlyPastOrPresent() {
val now = 1_000L
val p = RejectFutureEventsPolicy(maxFutureSeconds = 0, now = { now })
assertAccepted(p.accept(EventCmd(event(createdAt = now))))
assertRejected(p.accept(EventCmd(event(createdAt = now + 1))))
}
// -- Stack composition --------------------------------------------------
/**
* Verifies that policies compose via `IRelayPolicy.plus` so an
* EVENT must clear every policy in the stack to be accepted.
*/
@Test
fun stackedPoliciesAllMustAccept() {
val now = 1_000L
val stack =
(KindAllowDenyPolicy(allow = setOf(1)) as com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy) +
RejectFutureEventsPolicy(maxFutureSeconds = 10, now = { now })
// Allowed kind, in window — accepted.
assertAccepted(stack.accept(EventCmd(event(kind = 1, createdAt = now))))
// Allowed kind, future timestamp — rejected by RejectFuture.
assertRejected(
stack.accept(EventCmd(event(kind = 1, createdAt = now + 1000))),
reasonContains = "future",
)
// Disallowed kind — rejected by KindPolicy regardless of timestamp.
assertRejected(
stack.accept(EventCmd(event(kind = 99, createdAt = now))),
reasonContains = "not allowed",
)
}
}
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.fixtures
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import java.util.zip.GZIPInputStream
/**
* Loaders for test event corpora bundled in `quartz/src/commonTest/resources/`.
* Lookup order:
* 1. The `TEST_RESOURCES_ROOT` environment variable (set by quartz's Gradle
* config to the absolute path of `commonTest/resources`).
* 2. The classpath, for callers that copy fixtures into their own
* `src/test/resources`.
*/
object RelayFixtures {
/** Reads a fixture file as a UTF-8 string. */
fun loadString(name: String): String {
val envRoot = System.getenv("TEST_RESOURCES_ROOT")
if (envRoot != null) {
val file = java.io.File(envRoot, name)
if (file.exists()) return file.readText()
}
val cp = RelayFixtures::class.java.classLoader?.getResourceAsStream(name)
if (cp != null) return cp.bufferedReader().use { it.readText() }
throw IllegalArgumentException(
"Fixture not found: $name. Set TEST_RESOURCES_ROOT or place on classpath.",
)
}
/** Reads a gzipped fixture file as a UTF-8 string. */
fun loadGzipString(name: String): String {
val envRoot = System.getenv("TEST_RESOURCES_ROOT")
if (envRoot != null) {
val file = java.io.File(envRoot, name)
if (file.exists()) {
return GZIPInputStream(file.inputStream()).bufferedReader().use { it.readText() }
}
}
val cp = RelayFixtures::class.java.classLoader?.getResourceAsStream(name)
if (cp != null) return GZIPInputStream(cp).bufferedReader().use { it.readText() }
throw IllegalArgumentException(
"Fixture not found: $name. Set TEST_RESOURCES_ROOT or place on classpath.",
)
}
/** Loads `nostr_vitor_short.json` — the small handcrafted Vitor corpus. */
fun vitorShort(): List<Event> = OptimizedJsonMapper.fromJsonToEventList(loadString("nostr_vitor_short.json"))
/** Loads `nostr_vitor_startup_data.json.gz` — the larger Vitor startup corpus. */
fun vitorStartup(): List<Event> = OptimizedJsonMapper.fromJsonToEventList(loadGzipString("nostr_vitor_startup_data.json"))
}
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.fixtures
import com.vitorpamplona.quartz.nip01Core.core.Event
/**
* Generators for cheap, deterministic, structurally valid Nostr events.
*
* The signatures and ids produced here are *not* cryptographically valid
* the in-process relay's default [com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy]
* doesn't verify them, and neither does the underlying SQLite event store.
* Use these when a test only needs to exercise relay logic (filter matching,
* limits, EOSE, live updates) and not the cryptographic layer.
*/
object SyntheticEvents {
/** A pubkey/sig pair that's syntactically valid (64/128 hex chars) but signs nothing. */
private val DEFAULT_PUBKEY = "0".repeat(64)
private val FAKE_SIG = "0".repeat(128)
/** Hex padding to 64 chars so deterministic ids look like real event ids. */
fun hexId(seed: Int): String = seed.toString().padStart(64, '0')
fun fakeEvent(
idSeed: Int,
kind: Int = 1,
pubKey: String = DEFAULT_PUBKEY,
createdAt: Long = idSeed.toLong(),
content: String = "",
tags: Array<Array<String>> = emptyArray(),
): Event = Event(hexId(idSeed), pubKey, createdAt, kind, tags, content, FAKE_SIG)
/**
* Returns [count] events of [kind] with monotonic [createdAt] starting at 1
* and a *distinct* pubkey per event. Distinct pubkeys are essential for
* replaceable kinds (0, 3, 10000-19999): without them, the relay collapses
* the whole batch to one row per (kind, pubkey).
*/
fun batch(
count: Int,
kind: Int = 1,
pubKeyOf: (Int) -> String = { hexId(1_000_000 + it) },
): List<Event> =
List(count) { i ->
val seed = i + 1
fakeEvent(idSeed = seed, kind = kind, pubKey = pubKeyOf(seed), createdAt = seed.toLong())
}
}
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.testing
import com.vitorpamplona.geode.Relay
import com.vitorpamplona.geode.RelayHub
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import org.junit.After
/**
* Base class for tests that drive a real [NostrClient] against an
* in-process [RelayHub]. Owns the lifecycle of the four pieces every
* such test needs:
*
* - [hub] the registry of in-process relays (also serves as
* `WebsocketBuilder` for [NostrClient]).
* - [scope] application coroutine scope for the client.
* - [client] a [NostrClient] wired to [hub] and [scope].
* - [defaultRelay] / [defaultRelayUrl] convenience handles for the
* single-relay case (the most common in tests).
*
* Cleanup happens in [tearDownRelayClientTest], registered with
* JUnit's [@After][After], so an assertion failure does NOT leak the
* scope, the SQLite event store, or the WebSocket bridge a recurring
* problem with the previous "clean up at the end of the test body"
* pattern.
*
* Subclasses that need their own setup/teardown should add their own
* `@Before` / `@After` methods; JUnit runs all of them.
*
* Multi-relay tests use [hub] directly:
* ```
* val relayA = RelayUrlNormalizer.normalize("ws://relay-a/")
* hub.getOrCreate(relayA).preload(eventA)
* hub.getOrCreate(relayB).preload(eventB)
* ```
*/
open class RelayClientTest {
val hub: RelayHub = RelayHub()
val scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client: NostrClient = NostrClient(hub, scope)
/** Stable URL for the single-relay case — see [RelayHub.DEFAULT_URL]. */
val defaultRelayUrl: NormalizedRelayUrl get() = RelayHub.DEFAULT_URL
/** Lazy handle to the relay at [defaultRelayUrl]. Auto-created on first read. */
val defaultRelay: Relay get() = hub.getOrCreate(defaultRelayUrl)
@After
fun tearDownRelayClientTest() {
client.disconnect()
scope.cancel()
hub.close()
}
}
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.testing
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.withTimeout
/** Tagged result of [collectUntilEose]: stored events plus whether EOSE actually arrived. */
data class CollectResult(
val events: List<Event>,
val eoseReceived: Boolean,
)
/**
* Subscribe with [filter] on a single [relay], drain the
* historical-replay phase, and return when EOSE arrives. The
* subscription is closed before this returns. The pattern that 80% of
* REQ-style tests need.
*
* ```
* val (events, eose) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(1)))
* assertEquals(20, events.size)
* assertTrue(eose)
* ```
*
* @param timeoutMillis time to wait for EOSE before failing the test.
* Default 5 s generous for in-process; tighten if needed.
*/
suspend fun NostrClient.collectUntilEose(
relay: NormalizedRelayUrl,
filter: Filter,
timeoutMillis: Long = 5_000,
): CollectResult = collectUntilEoseMulti(relay, listOf(filter), timeoutMillis)
/**
* Multi-filter variant. NIP-01 allows a REQ to carry several filters
* that the relay OR's together. EOSE fires once after the union of all
* filters has been replayed.
*/
suspend fun NostrClient.collectUntilEoseMulti(
relay: NormalizedRelayUrl,
filters: List<Filter>,
timeoutMillis: Long = 5_000,
): CollectResult {
val ch = Channel<Signal>(UNLIMITED)
val subId = "test-sub-${nextSubId()}"
subscribe(
subId,
mapOf(relay to filters),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Signal.Ev(event))
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Signal.Eose)
}
},
)
val events = mutableListOf<Event>()
var eose = false
try {
withTimeout(timeoutMillis) {
while (!eose) {
when (val msg = ch.receive()) {
is Signal.Ev -> events += msg.event
Signal.Eose -> eose = true
}
}
}
} finally {
unsubscribe(subId)
}
return CollectResult(events, eose)
}
private sealed interface Signal {
data class Ev(
val event: Event,
) : Signal
object Eose : Signal
}
/** Monotonic counter for unique sub-ids inside a JVM. */
private var subIdSeq: Int = 0
private fun nextSubId(): Int = ++subIdSeq
+6
View File
@@ -81,6 +81,8 @@ kotlinTest = "2.3.21"
core = "1.7.0"
mavenPublish = "0.36.0"
sqlite = "2.6.2"
ktor = "3.4.1"
fourkoma = "1.2.0"
[libraries]
abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" }
@@ -175,6 +177,10 @@ negentropy-kmp = { module = "com.vitorpamplona.negentropy:kmp-negentropy", versi
net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
fourkoma = { module = "cc.ekblad:4koma", version.ref = "fourkoma" }
secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
+17
View File
@@ -180,6 +180,14 @@ kotlin {
dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
// In-process Nostr relay (geode) so JVM/Android host
// tests don't need network access or a Rust toolchain.
// testFixtures (RelayClientTest base, fixtures,
// collectUntilEose) are wired below at the top-level
// `dependencies` block — the KMP source-set DSL
// doesn't expose the `testFixtures(...)` consumer.
implementation(project(":geode"))
}
}
@@ -341,6 +349,15 @@ kotlin {
}
}
// testFixtures(...) consumer lives outside the KMP source-set DSL —
// the KMP source-set `dependencies { }` block uses
// `KotlinDependencyHandler`, which does not expose the
// `testFixtures(...)` projection. The standard Gradle dependency
// configuration name (`jvmAndroidTestImplementation`) does work here.
dependencies {
"jvmAndroidTestImplementation"(testFixtures(project(":geode")))
}
mavenPublishing {
// sources publishing is always enabled by the Kotlin Multiplatform plugin
configure(
@@ -42,7 +42,7 @@ object CountResultKSerializer : KSerializer<CountResult> {
override val descriptor: SerialDescriptor =
buildClassSerialDescriptor("CountResult") {
element<Int>("count")
element<Boolean>("pubkey")
element<Boolean>("approximate")
}
override fun serialize(
@@ -56,8 +56,8 @@ object CountResultKSerializer : KSerializer<CountResult> {
fun serializeToElement(value: CountResult): JsonObject =
buildJsonObject {
put("count", value.count)
// Matches Jackson's CountResultSerializer which writes "pubkey" for approximate
put("pubkey", value.approximate)
// NIP-45: include "approximate" only when true.
if (value.approximate) put("approximate", true)
value.hll?.let { put("hll", HyperLogLog.encode(it)) }
}
@@ -68,12 +68,10 @@ object MessageKSerializer : KSerializer<Message> {
}
is OkMessage -> {
// NIP-01 wire format: ["OK", <event_id>, <true|false>, <message>]
add(JsonPrimitive(value.eventId))
// Jackson writes success as a string, not boolean
add(JsonPrimitive(value.success.toString()))
if (value.message.isNotBlank()) {
add(JsonPrimitive(value.message))
}
add(JsonPrimitive(value.success))
add(JsonPrimitive(value.message))
}
is AuthMessage -> {
@@ -90,6 +88,8 @@ object MessageKSerializer : KSerializer<Message> {
}
is CountMessage -> {
// NIP-45 wire format: ["COUNT", <query_id>, <count_result>]
add(JsonPrimitive(value.queryId))
add(CountResultKSerializer.serializeToElement(value.result))
}
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription
/**
* A reactive event store that combines historical data retrieval with live event streaming.
@@ -56,19 +57,68 @@ class LiveEventStore(
onEach: (Event) -> Unit,
onEose: () -> Unit,
) {
// 1. Replay stored events matching filters.
store.query(filters, onEach)
// 2. Signal end of stored events.
onEose()
// 3. Stream live events until cancelled.
newEventStream.collect { newEvent ->
if (filters.any { it.match(newEvent) }) {
onEach(newEvent)
}
// Order matters: register the live collector BEFORE replaying
// stored events and signalling EOSE. Otherwise an event emitted
// between EOSE and `collect` is lost because [newEventStream] has
// replay=0. The race is only occasionally visible for kinds the
// store persists (insert latency masks it) but fires reliably for
// ephemeral kinds (20000-29999) where insert is a no-op — and
// ephemeral events MUST still reach matching live subscribers per
// NIP-01.
//
// Side effect of registering the collector first: an event
// inserted *during* `store.query` will be both replayed by the
// store AND emitted to the live stream. We dedupe by tracking
// ids seen during the historical replay and skipping them on
// the live path. The set is dropped after EOSE so live-only
// events don't accumulate memory.
var inHistoricalPhase = true
var seenIds: HashSet<String>? = HashSet()
val historicalOnEach: (Event) -> Unit = { event ->
seenIds?.add(event.id)
onEach(event)
}
newEventStream
.onSubscription {
store.query(filters, historicalOnEach)
onEose()
// Free the dedupe set once we've crossed EOSE: from
// here on the live stream is the only source of
// events, so duplicates aren't possible.
inHistoricalPhase = false
seenIds = null
}.collect { newEvent ->
if (inHistoricalPhase && seenIds?.contains(newEvent.id) == true) return@collect
if (filters.any { it.match(newEvent) }) {
onEach(newEvent)
}
}
}
suspend fun count(filters: List<Filter>) = store.count(filters)
/**
* One-shot snapshot query. Used by NIP-77 negentropy: the server
* needs the full set of event ids matching the filter at the
* moment the NEG-OPEN arrives, not a streamed/live result.
*/
suspend fun snapshotQuery(filter: Filter): List<Event> = store.query(filter)
/**
* Multi-filter snapshot. Unions the per-filter results and
* deduplicates by event id so an event matching N filters is
* yielded once. Used by NIP-77 NEG-OPEN when the policy stack
* rewrote the single incoming filter into several.
*/
suspend fun snapshotQuery(filters: List<Filter>): List<Event> {
if (filters.size == 1) return snapshotQuery(filters[0])
val seen = HashSet<String>()
val merged = ArrayList<Event>()
for (f in filters) {
for (e in store.query<Event>(f)) {
if (seen.add(e.id)) merged += e
}
}
return merged
}
}
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.server
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegentropyServerSession
/**
* Per-connection NIP-77 negentropy state and dispatch.
*
* Owns the map of active reconciliation sessions keyed by NEG-OPEN
* subId, and the open/msg/close handlers. Pulled out of [RelaySession]
* so the connection class only routes commands while this class owns
* the negentropy lifecycle and error mapping.
*
* Plain [HashMap] is sufficient because the registry is mutated only
* from [RelaySession.receive] that path is single-threaded per the
* WebSocket handler contract.
*/
class NegSessionRegistry(
private val store: LiveEventStore,
private val send: (Message) -> Unit,
) {
private val sessions = HashMap<String, NegentropyServerSession>()
/**
* Open a reconciliation session. The relay snapshots its matching
* events at this instant concurrent inserts during the sync are
* not surfaced; clients re-open if they want fresh state.
*
* Access control reuses the REQ policy hook: a relay that requires
* AUTH or has kind/pubkey allow-deny lists applies the same rules
* to NEG-OPEN as it does to subscription REQs.
*/
suspend fun open(
cmd: NegOpenCmd,
policy: IRelayPolicy,
) {
val gate = policy.accept(ReqCmd(cmd.subId, listOf(cmd.filter)))
if (gate is PolicyResult.Rejected) {
send(NegErrMessage(cmd.subId, gate.reason))
return
}
val filters = (gate as PolicyResult.Accepted).cmd.filters
// NIP-77: same-subId OPEN replaces any prior session.
sessions.remove(cmd.subId)
val events = store.snapshotQuery(filters)
val session = NegentropyServerSession(cmd.subId, events)
sessions[cmd.subId] = session
runMessage(cmd.subId, session) { it.processMessage(cmd.initialMessage) }
}
fun msg(cmd: NegMsgCmd) {
val session = sessions[cmd.subId]
if (session == null) {
send(NegErrMessage(cmd.subId, "error: no negentropy session for ${cmd.subId}"))
return
}
runMessage(cmd.subId, session) { it.processMessage(cmd.message) }
}
/**
* Spec: clients send NEG-CLOSE to free server-side state.
* Silent no-op if the session is unknown there's no authoritative
* error response in NIP-77 for an unknown close.
*/
fun close(cmd: NegCloseCmd) {
sessions.remove(cmd.subId)
}
/** Dropped on `RelaySession.cancelAllSubscriptions`. */
fun clear() {
sessions.clear()
}
private inline fun runMessage(
subId: String,
session: NegentropyServerSession,
block: (NegentropyServerSession) -> Message?,
) {
try {
val response = block(session)
if (response != null) send(response)
} catch (e: Exception) {
sessions.remove(subId)
send(NegErrMessage(subId, "error: ${e.message ?: e::class.simpleName}"))
}
}
}
@@ -34,8 +34,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@@ -53,6 +57,9 @@ class RelaySession(
) : AutoCloseable {
private val subscriptions = LargeCache<String, Job>()
/** NIP-77 negentropy state for this connection. */
private val negentropy = NegSessionRegistry(store, ::send)
private fun addSubscription(
subId: String,
job: Job,
@@ -67,6 +74,7 @@ class RelaySession(
fun cancelAllSubscriptions() {
subscriptions.forEach { _, job -> job.cancel() }
subscriptions.clear()
negentropy.clear()
}
fun send(message: Message) {
@@ -107,6 +115,9 @@ class RelaySession(
is ReqCmd -> handleReq(cmd)
is CloseCmd -> handleClose(cmd)
is CountCmd -> handleCount(cmd)
is NegOpenCmd -> negentropy.open(cmd, policy)
is NegMsgCmd -> negentropy.msg(cmd)
is NegCloseCmd -> negentropy.close(cmd)
else -> send(NoticeMessage("error: unsupported command ${cmd.label()}"))
}
}
@@ -178,7 +189,7 @@ class RelaySession(
},
onEose = { send(EoseMessage(cmd.subId)) },
)
} catch (_: kotlinx.coroutines.CancellationException) {
} catch (_: CancellationException) {
// Subscription was closed this is expected.
}
}
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.server.inprocess
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.launch
/**
* In-memory implementation of [WebSocket] that talks directly to a
* [NostrServer] without touching the network. Each instance opens one
* [RelaySession] on [connect] and routes:
*
* - Outbound (`send`) server `RelaySession.receive()` via an inbound
* channel drained by a single coroutine, preserving message order
* per the [WebSocketListener] contract.
* - Server-side `send` callbacks [WebSocketListener.onMessage].
*
* Use this to wire a `NostrClient` to an embedded server in unit tests
* or single-JVM scenarios without paying for a real TCP socket. Because
* it implements [WebSocket], it slots into anywhere a `WebsocketBuilder`
* expects.
*
* Reconnect-after-disconnect is supported: each [connect] creates a
* fresh scope + drain channel so a previous [disconnect] (which
* cancels both) doesn't leave a dead drainer behind.
*/
class InProcessWebSocket(
private val server: NostrServer,
private val out: WebSocketListener,
) : WebSocket {
private var scope: CoroutineScope? = null
private var incoming: Channel<String>? = null
private var drainJob: Job? = null
private var session: RelaySession? = null
override fun needsReconnect(): Boolean = session == null
override fun connect() {
if (session != null) return
val newScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val newIncoming = Channel<String>(UNLIMITED)
val s = server.connect { json -> out.onMessage(json) }
scope = newScope
incoming = newIncoming
session = s
drainJob =
newScope.launch {
for (msg in newIncoming) {
s.receive(msg)
}
}
out.onOpen(0, false)
}
override fun disconnect() {
val s = session ?: return
session = null
incoming?.close()
incoming = null
drainJob = null
scope?.cancel()
scope = null
s.close()
out.onClosed(1000, "client disconnect")
}
override fun send(msg: String): Boolean {
// Capture the current channel reference: we want to fail
// (return false) if the socket was disconnected, even if a
// racing thread is mid-`connect()`.
val ch = incoming ?: return false
return ch.trySend(msg).isSuccess
}
}
@@ -20,28 +20,11 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.server.policies
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
/**
* Allows all commands without authentication. This is the default policy.
* Allows all commands without authentication. The default policy.
*
* Singleton form of [PassThroughPolicy] for callers that want a
* shared no-op (saves an allocation and lets the relay shortcut
* `policy === EmptyPolicy` checks when composing stacks).
*/
object EmptyPolicy : IRelayPolicy {
override fun onConnect(send: (Message) -> Unit) { }
override fun accept(cmd: EventCmd) = PolicyResult.Accepted(cmd)
override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd)
override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd)
override fun accept(cmd: AuthCmd) = PolicyResult.Accepted(cmd)
override fun canSendToSession(event: Event) = true
}
object EmptyPolicy : PassThroughPolicy()
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.server.policies
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
/**
* Operator-controlled kind allow/deny list. Mirrors nostr-rs-relay's
* `[authorization].kind_whitelist` / `kind_blacklist`.
*
* - When [allow] is non-empty, only events whose kind is in [allow]
* are accepted; everything else is rejected.
* - When [deny] is non-empty, events whose kind is in [deny] are
* rejected.
* - Both lists may be empty (no-op pass-through).
* - When both are set, allow is checked first (deny inside allow is
* still denied, matching nostr-rs-relay's precedence).
*/
class KindAllowDenyPolicy(
val allow: Set<Int> = emptySet(),
val deny: Set<Int> = emptySet(),
) : PassThroughPolicy() {
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
val k = cmd.event.kind
if (allow.isNotEmpty() && k !in allow) {
return PolicyResult.Rejected("blocked: kind $k not allowed")
}
if (k in deny) {
return PolicyResult.Rejected("blocked: kind $k denied")
}
return PolicyResult.Accepted(cmd)
}
}
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.server.policies
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
/**
* Convenience base that accepts everything by default. Subclasses
* override only the hook(s) they actually enforce so the call sites
* stay readable.
*
* Concrete (not abstract) so [EmptyPolicy] can subclass it as a
* singleton and external code that just wants a no-op policy can
* instantiate this directly.
*/
open class PassThroughPolicy : IRelayPolicy {
override fun onConnect(send: (Message) -> Unit) {}
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> = PolicyResult.Accepted(cmd)
override fun accept(cmd: ReqCmd): PolicyResult<ReqCmd> = PolicyResult.Accepted(cmd)
override fun accept(cmd: CountCmd): PolicyResult<CountCmd> = PolicyResult.Accepted(cmd)
override fun accept(cmd: AuthCmd): PolicyResult<AuthCmd> = PolicyResult.Accepted(cmd)
override fun canSendToSession(event: Event): Boolean = true
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.server.policies
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
/**
* Operator-controlled author allow/deny list. Mirrors nostr-rs-relay's
* `[authorization].pubkey_whitelist` / `pubkey_blacklist`.
*
* - [allow] non-empty: only events from listed pubkeys are accepted.
* This is the "private relay" mode.
* - [deny] non-empty: events from listed pubkeys are rejected.
* - Empty lists are no-op pass-through.
* - When both are set, allow is checked first.
*
* Pubkeys are matched case-insensitively (lowercased on entry).
*/
class PubkeyAllowDenyPolicy(
allow: Set<HexKey> = emptySet(),
deny: Set<HexKey> = emptySet(),
) : PassThroughPolicy() {
private val allow = allow.mapTo(HashSet()) { it.lowercase() }
private val deny = deny.mapTo(HashSet()) { it.lowercase() }
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
val pk = cmd.event.pubKey.lowercase()
if (allow.isNotEmpty() && pk !in allow) {
return PolicyResult.Rejected("blocked: pubkey not on allow list")
}
if (pk in deny) {
return PolicyResult.Rejected("blocked: pubkey is denied")
}
return PolicyResult.Accepted(cmd)
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.server.policies
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Rejects events whose `created_at` is more than [maxFutureSeconds]
* seconds in the future relative to the relay's clock. Mirrors
* nostr-rs-relay's `[options].reject_future_seconds`.
*
* Catches both clock-skew accidents and intentional far-future
* timestamps used to push events to the top of newest-first feeds.
*
* The current time is read from [TimeUtils.now] (epoch seconds), the
* same source the [com.vitorpamplona.quartz.nip40Expiration.isExpired]
* check uses, so the relay's "future" and "expired" decisions agree.
*/
class RejectFutureEventsPolicy(
val maxFutureSeconds: Int,
private val now: () -> Long = { TimeUtils.now() },
) : PassThroughPolicy() {
init {
require(maxFutureSeconds >= 0) { "maxFutureSeconds must be >= 0, got $maxFutureSeconds" }
}
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
val skew = cmd.event.createdAt - now()
return if (skew > maxFutureSeconds) {
PolicyResult.Rejected("invalid: created_at is $skew seconds in the future (max $maxFutureSeconds)")
} else {
PolicyResult.Accepted(cmd)
}
}
}
@@ -27,7 +27,7 @@ import kotlinx.serialization.Serializable
@Stable
@Serializable
class Nip11RelayInformation(
data class Nip11RelayInformation(
val id: String? = null,
val name: String? = null,
val description: String? = null,
@@ -59,7 +59,7 @@ class Nip11RelayInformation(
@Stable
@Serializable
class RelayInformationFee(
data class RelayInformationFee(
val amount: Int? = null,
val unit: String? = null,
val period: Int? = null,
@@ -68,7 +68,7 @@ class Nip11RelayInformation(
@Stable
@Serializable
class RelayInformationFees(
data class RelayInformationFees(
val admission: List<RelayInformationFee>? = null,
val subscription: List<RelayInformationFee>? = null,
val publication: List<RelayInformationFee>? = null,
@@ -76,7 +76,7 @@ class Nip11RelayInformation(
@Stable
@Serializable
class RelayInformationLimitation(
data class RelayInformationLimitation(
val max_message_length: Int? = null,
val max_subscriptions: Int? = null,
val max_filters: Int? = null,
@@ -96,7 +96,7 @@ class Nip11RelayInformation(
@Stable
@Serializable
class RelayInformationRetentionData(
data class RelayInformationRetentionData(
val kinds: ArrayList<Int>? = null,
val time: Int? = null,
val count: Int? = null,
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy
/**
* Reads the live [BanStore] on every EVENT and rejects events that
* violate any of: banned-event-id, banned-pubkey, missing from a
* non-empty pubkey allow list, or kind disallowed / not in the kind
* allow list.
*
* This is the runtime-mutable counterpart of the static
* [com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy] +
* [com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy]
* both layers compose: the event must clear all stacked policies.
* NIP-86 admin RPC mutations land in the [BanStore]; the static
* policies stay frozen at boot-time config values.
*/
class BanListPolicy(
val banStore: BanStore,
) : PassThroughPolicy() {
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
val ev = cmd.event
if (banStore.isBannedEvent(ev.id)) {
return PolicyResult.Rejected("blocked: event id is banned")
}
if (banStore.isBanned(ev.pubKey)) {
return PolicyResult.Rejected("blocked: pubkey is banned")
}
if (banStore.hasAllowList() && !banStore.isAllowedPubkey(ev.pubKey)) {
return PolicyResult.Rejected("blocked: pubkey is not on the allow list")
}
if (!banStore.isKindAllowed(ev.kind)) {
return PolicyResult.Rejected("blocked: kind ${ev.kind} not allowed")
}
return PolicyResult.Accepted(cmd)
}
}
@@ -0,0 +1,187 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Lock-free runtime state for the NIP-86 management API. Holds the
* ban/allow lists that [BanListPolicy] consults on every accept call,
* plus an [onMutation] hook so the relay can persist the latest
* snapshot whenever an admin RPC mutates state.
*
* Each entry carries an optional reason string so list-* RPCs can echo
* back why an admin took the action useful for audit trails.
*
* Persistence is intentionally NOT inside this class; supply
* [onMutation] to flush to disk (or wherever) and use [seedFromSnapshot]
* at boot to load. `null` keeps the store in-memory only.
*
* Concurrency: state is held in a single [AtomicReference] and mutated
* via copy-on-write CAS loops. Reads are wait-free single-load atomic.
* The data structures are tiny (operator-controlled) so the per-write
* map copy is negligible.
*/
@OptIn(ExperimentalAtomicApi::class)
class BanStore(
private val onMutation: (() -> Unit)? = null,
) {
/**
* Single immutable snapshot of all ban/allow state. Combined into
* one object so kind allow/disallow lock-step (allow adds to
* allowedKinds AND removes from disallowedKinds) is naturally
* atomic no possibility of an interleaved reader observing a
* kind in both sets.
*/
private data class State(
val bannedPubkeys: Map<HexKey, String?> = emptyMap(),
val allowedPubkeys: Map<HexKey, String?> = emptyMap(),
val bannedEventIds: Map<HexKey, String?> = emptyMap(),
val allowedKinds: Set<Int> = emptySet(),
val disallowedKinds: Set<Int> = emptySet(),
)
private val state = AtomicReference(State())
private inline fun mutate(transform: (State) -> State) {
while (true) {
val current = state.load()
if (state.compareAndSet(current, transform(current))) break
}
onMutation?.invoke()
}
// -- Pubkey ban list -----------------------------------------------------
fun banPubkey(
pubkey: HexKey,
reason: String? = null,
) = mutate { it.copy(bannedPubkeys = it.bannedPubkeys + (pubkey.lowercase() to reason)) }
fun unbanPubkey(pubkey: HexKey) = mutate { it.copy(bannedPubkeys = it.bannedPubkeys - pubkey.lowercase()) }
fun isBanned(pubkey: HexKey): Boolean = pubkey.lowercase() in state.load().bannedPubkeys
fun listBannedPubkeys(): List<Pair<HexKey, String?>> =
state
.load()
.bannedPubkeys.entries
.map { it.key to it.value }
// -- Pubkey allow list ---------------------------------------------------
fun allowPubkey(
pubkey: HexKey,
reason: String? = null,
) = mutate { it.copy(allowedPubkeys = it.allowedPubkeys + (pubkey.lowercase() to reason)) }
fun unallowPubkey(pubkey: HexKey) = mutate { it.copy(allowedPubkeys = it.allowedPubkeys - pubkey.lowercase()) }
fun isAllowedPubkey(pubkey: HexKey): Boolean = pubkey.lowercase() in state.load().allowedPubkeys
fun listAllowedPubkeys(): List<Pair<HexKey, String?>> =
state
.load()
.allowedPubkeys.entries
.map { it.key to it.value }
fun hasAllowList(): Boolean = state.load().allowedPubkeys.isNotEmpty()
// -- Event id ban list ---------------------------------------------------
fun banEvent(
eventId: HexKey,
reason: String? = null,
) = mutate { it.copy(bannedEventIds = it.bannedEventIds + (eventId.lowercase() to reason)) }
/** Removes an event id from the ban list. Mirrors NIP-86 `allowevent`. */
fun allowEvent(eventId: HexKey) = mutate { it.copy(bannedEventIds = it.bannedEventIds - eventId.lowercase()) }
fun isBannedEvent(eventId: HexKey): Boolean = eventId.lowercase() in state.load().bannedEventIds
fun listBannedEvents(): List<Pair<HexKey, String?>> =
state
.load()
.bannedEventIds.entries
.map { it.key to it.value }
// -- Kind allow / deny --------------------------------------------------
/**
* `allowKind` and `disallowKind` are symmetric: each adds to its
* own set AND removes the kind from the opposite set. Otherwise
* an `allowKind(K)` after a `disallowKind(K)` would leave K in
* both sets and stay blocked, surprising operators.
*/
fun allowKind(kind: Int) =
mutate {
it.copy(
allowedKinds = it.allowedKinds + kind,
disallowedKinds = it.disallowedKinds - kind,
)
}
fun disallowKind(kind: Int) =
mutate {
it.copy(
allowedKinds = it.allowedKinds - kind,
disallowedKinds = it.disallowedKinds + kind,
)
}
fun listAllowedKinds(): List<Int> = state.load().allowedKinds.sorted()
fun listDisallowedKinds(): List<Int> = state.load().disallowedKinds.sorted()
fun isKindAllowed(kind: Int): Boolean {
val s = state.load()
if (kind in s.disallowedKinds) return false
if (s.allowedKinds.isEmpty()) return true
return kind in s.allowedKinds
}
/**
* Bulk-load state without firing [onMutation]. Used at startup to
* seed the in-memory state from a persisted snapshot we don't
* want every individual `put` to trigger another disk write. After
* this call the store behaves exactly as if every entry had been
* mutated through the public API.
*/
fun seedFromSnapshot(
bannedPubkeys: List<Pair<HexKey, String?>> = emptyList(),
allowedPubkeys: List<Pair<HexKey, String?>> = emptyList(),
bannedEvents: List<Pair<HexKey, String?>> = emptyList(),
allowedKinds: List<Int> = emptyList(),
disallowedKinds: List<Int> = emptyList(),
) {
state.store(
State(
bannedPubkeys = bannedPubkeys.associate { (k, r) -> k.lowercase() to r },
allowedPubkeys = allowedPubkeys.associate { (k, r) -> k.lowercase() to r },
bannedEventIds = bannedEvents.associate { (k, r) -> k.lowercase() to r },
allowedKinds = allowedKinds.toSet(),
disallowedKinds = disallowedKinds.toSet(),
),
)
}
}
@@ -0,0 +1,256 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
import com.vitorpamplona.quartz.utils.Hex
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.int
/**
* Server-side dispatcher for the NIP-86 relay management API.
*
* Holds the [BanStore] (mutated by ban/allow methods), an [InfoHolder]
* for the live NIP-11 doc (mutated by `changerelay*` methods, which
* atomically swap it), and an optional [IEventStore] so `banevent`
* can also delete the offending event from the store.
*
* Transport-agnostic relay implementations call [dispatch] from
* whatever HTTP route they expose (e.g. POST `application/nostr+json+rpc`),
* and in-process tests can build a [Nip86Request] directly.
*
* [supportedMethods] is the canonical list this server actually
* implements; methods returned outside of it are no-ops and a NIP-86
* client must not advertise them.
*/
class Nip86Server(
val banStore: BanStore,
/**
* Read-write access to the relay's NIP-11 info doc. The dispatcher
* mutates this when an admin calls `changerelayname` /
* `changerelaydescription` / `changerelayicon`. Relay code reading
* the doc (e.g. the NIP-11 endpoint) must consult this object on
* every request, not cache it.
*/
private val infoHolder: InfoHolder,
private val store: IEventStore? = null,
) {
/** Pluggable container so the relay's NIP-11 doc can be swapped at runtime. */
interface InfoHolder {
fun get(): Nip11RelayInformation
fun set(info: Nip11RelayInformation)
}
val supportedMethods: List<String> =
listOf(
Nip86Method.SUPPORTED_METHODS,
Nip86Method.BAN_PUBKEY,
Nip86Method.UNBAN_PUBKEY,
Nip86Method.LIST_BANNED_PUBKEYS,
Nip86Method.ALLOW_PUBKEY,
Nip86Method.UNALLOW_PUBKEY,
Nip86Method.LIST_ALLOWED_PUBKEYS,
Nip86Method.BAN_EVENT,
Nip86Method.ALLOW_EVENT,
Nip86Method.LIST_BANNED_EVENTS,
Nip86Method.ALLOW_KIND,
Nip86Method.DISALLOW_KIND,
Nip86Method.LIST_ALLOWED_KINDS,
Nip86Method.CHANGE_RELAY_NAME,
Nip86Method.CHANGE_RELAY_DESCRIPTION,
Nip86Method.CHANGE_RELAY_ICON,
)
/**
* Dispatches a single RPC request. Synchronous-looking but does
* suspend internally for the `banevent` event-store delete path.
*/
suspend fun dispatch(req: Nip86Request): Nip86Response =
runCatching {
when (req.method) {
Nip86Method.SUPPORTED_METHODS -> {
ok(buildJsonArray { supportedMethods.forEach { add(JsonPrimitive(it)) } })
}
Nip86Method.BAN_PUBKEY -> {
withHexAndReason(req, "pubkey") { pk, reason -> banStore.banPubkey(pk, reason) }
}
Nip86Method.UNBAN_PUBKEY -> {
withHex(req, "pubkey") { pk -> banStore.unbanPubkey(pk) }
}
Nip86Method.LIST_BANNED_PUBKEYS -> {
ok(banStore.listBannedPubkeys().map { (pk, r) -> BannedPubkey(pk, r) }.toJsonArray(BannedPubkey.serializer()))
}
Nip86Method.ALLOW_PUBKEY -> {
withHexAndReason(req, "pubkey") { pk, reason -> banStore.allowPubkey(pk, reason) }
}
Nip86Method.UNALLOW_PUBKEY -> {
withHex(req, "pubkey") { pk -> banStore.unallowPubkey(pk) }
}
Nip86Method.LIST_ALLOWED_PUBKEYS -> {
ok(banStore.listAllowedPubkeys().map { (pk, r) -> AllowedPubkey(pk, r) }.toJsonArray(AllowedPubkey.serializer()))
}
Nip86Method.BAN_EVENT -> {
withHexAndReason(req, "event_id") { id, reason ->
banStore.banEvent(id, reason)
// Also remove the event from the store if present.
store?.delete(Filter(ids = listOf(id)))
}
}
Nip86Method.ALLOW_EVENT -> {
withHex(req, "event_id") { id -> banStore.allowEvent(id) }
}
Nip86Method.LIST_BANNED_EVENTS -> {
ok(banStore.listBannedEvents().map { (id, r) -> BannedEvent(id, r) }.toJsonArray(BannedEvent.serializer()))
}
Nip86Method.ALLOW_KIND -> {
withInt(req, "kind") { k -> banStore.allowKind(k) }
}
Nip86Method.DISALLOW_KIND -> {
withInt(req, "kind") { k -> banStore.disallowKind(k) }
}
Nip86Method.LIST_ALLOWED_KINDS -> {
ok(buildJsonArray { banStore.listAllowedKinds().forEach { add(JsonPrimitive(it)) } })
}
Nip86Method.CHANGE_RELAY_NAME -> {
withString(req, "name") { name -> rewriteInfo { it.copy(name = name) } }
}
Nip86Method.CHANGE_RELAY_DESCRIPTION -> {
withString(req, "description") { desc -> rewriteInfo { it.copy(description = desc) } }
}
Nip86Method.CHANGE_RELAY_ICON -> {
withString(req, "icon_url") { icon -> rewriteInfo { it.copy(icon = icon) } }
}
else -> {
Nip86Response(error = "method not supported: ${req.method}")
}
}
}.getOrElse { e ->
// CancellationException must propagate so structured
// concurrency works — swallowing it would let a parent
// cancellation be reported as a benign RPC error.
if (e is CancellationException) throw e
Nip86Response(error = "internal: ${e.message ?: e::class.simpleName}")
}
private inline fun withHex(
req: Nip86Request,
label: String,
action: (String) -> Unit,
): Nip86Response {
val (value, _) = req.params.stringPair() ?: return malformed("expected [$label]")
if (!Hex.isHex64(value)) return malformed("$label must be 64-char hex")
action(value)
return okTrue
}
private suspend inline fun withHexAndReason(
req: Nip86Request,
label: String,
action: suspend (String, String?) -> Unit,
): Nip86Response {
val (value, reason) = req.params.stringPair() ?: return malformed("expected [$label, reason?]")
if (!Hex.isHex64(value)) return malformed("$label must be 64-char hex")
action(value, reason)
return okTrue
}
private inline fun withInt(
req: Nip86Request,
label: String,
action: (Int) -> Unit,
): Nip86Response {
val v = req.params.firstInt() ?: return malformed("expected [$label]")
action(v)
return okTrue
}
private inline fun withString(
req: Nip86Request,
label: String,
action: (String) -> Unit,
): Nip86Response {
val v = req.params.firstString() ?: return malformed("expected [$label]")
action(v)
return okTrue
}
private fun rewriteInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
infoHolder.set(transform(infoHolder.get()))
}
}
private fun malformed(reason: String) = Nip86Response(error = "invalid params: $reason")
private fun ok(j: JsonElement) = Nip86Response(result = j, error = null)
private val okTrue = ok(JsonPrimitive(true))
private val rpcJson = Json { encodeDefaults = false }
private fun <T> List<T>.toJsonArray(serializer: KSerializer<T>): JsonElement = rpcJson.encodeToJsonElement(ListSerializer(serializer), this)
private fun JsonArray.stringPair(): Pair<String, String?>? {
val first = (getOrNull(0) as? JsonPrimitive)?.contentOrNull() ?: return null
val second = (getOrNull(1) as? JsonPrimitive)?.contentOrNull()
return first to second
}
private fun JsonArray.firstString(): String? = (getOrNull(0) as? JsonPrimitive)?.contentOrNull()
private fun JsonArray.firstInt(): Int? =
runCatching {
(this[0] as? JsonPrimitive)?.int
}.getOrNull()
private fun JsonPrimitive.contentOrNull(): String? = if (this == JsonNull) null else content
@@ -0,0 +1,177 @@
/*
* 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.nip98HttpAuth
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
/**
* Server-side counterpart to [HTTPAuthorizationEvent]. Verifies a
* NIP-98 `Authorization: Nostr <base64-event>` header.
*
* NIP-98 reuses kind 27235 events with `u`, `method`, and (for bodies)
* `payload` tags. Verification checks:
* 1. Header is `Nostr <base64>`.
* 2. Decoded body is a kind-27235 event with a valid Schnorr signature.
* 3. The event's `created_at` is within ±[toleranceSeconds] of now.
* 4. The `method` tag matches the HTTP method.
* 5. The `u` tag matches the requested URL.
* 6. If a body is present, the `payload` tag matches `sha256(body)` hex.
*
* Returns the verified pubkey on success; a [Result.Malformed] /
* [Result.Missing] otherwise (the caller turns these into 401/403).
*/
class Nip98AuthVerifier(
private val now: () -> Long = { TimeUtils.now() },
/** Allowed clock skew in seconds. NIP-98 says 60. */
private val toleranceSeconds: Long = 60,
) {
/**
* Recently-accepted event ids expiry epoch second. Bounded to
* [MAX_REPLAY_ENTRIES] (LRU eviction); each entry expires after
* `2 × toleranceSeconds` (twice the accepted window so a token
* can't be reused by an attacker who buffers across the boundary).
*
* Guarded by [seenLock] so the eviction sweep + insertion are
* atomic. We use a coroutine [Mutex] so the type works in KMP
* commonMain (no `synchronized` block).
*/
private val seenEventIds: LinkedHashMap<String, Long> =
object : LinkedHashMap<String, Long>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, Long>?): Boolean = size > MAX_REPLAY_ENTRIES
}
private val seenLock = Mutex()
@OptIn(ExperimentalEncodingApi::class)
suspend fun verify(
authorizationHeader: String?,
method: String,
url: String,
body: ByteArray?,
): Result {
if (authorizationHeader.isNullOrBlank()) return Result.Missing
if (!authorizationHeader.startsWith(SCHEME)) return Result.Malformed("expected '$SCHEME <base64>' header")
val token = authorizationHeader.substring(SCHEME.length).trim()
val json =
try {
Base64.decode(token).decodeToString()
} catch (_: IllegalArgumentException) {
return Result.Malformed("token is not valid base64")
}
val event =
try {
OptimizedJsonMapper.fromJson(json)
} catch (_: Exception) {
return Result.Malformed("token does not decode to a Nostr event")
}
if (event.kind != HTTPAuthorizationEvent.KIND) {
return Result.Malformed("event kind ${event.kind} != ${HTTPAuthorizationEvent.KIND}")
}
if (!event.verify()) return Result.Malformed("bad event signature or id")
val nowSec = now()
val skew = abs(event.createdAt - nowSec)
if (skew > toleranceSeconds) {
return Result.Malformed("created_at is ${skew}s away from now (max ${toleranceSeconds}s)")
}
// Re-wrap as the typed event so the tag accessors work.
val auth =
HTTPAuthorizationEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
if (!auth.method().equals(method, ignoreCase = true)) {
return Result.Malformed("method mismatch: expected $method, got ${auth.method()}")
}
if (auth.url() != url) {
return Result.Malformed("url mismatch: expected $url, got ${auth.url()}")
}
if (body != null && body.isNotEmpty()) {
val expected = sha256(body).toHexKey()
if (auth.payloadHash() != expected) {
return Result.Malformed("payload hash mismatch")
}
}
// Replay check — done LAST so we don't burn a one-shot id on a
// request that would otherwise have failed signature/url/etc.
val expiry = nowSec + 2 * toleranceSeconds
seenLock.withLock {
// Evict expired entries while we hold the lock. Insertion
// order (LinkedHashMap default) tracks expiry order
// because every entry's expiry = now + 2·tolerance, so
// the first non-expired entry guarantees no later entry
// is expired either.
val it = seenEventIds.entries.iterator()
while (it.hasNext()) {
if (it.next().value <= nowSec) it.remove() else break
}
if (seenEventIds.put(event.id, expiry) != null) {
return Result.Malformed("replay: this NIP-98 token has already been used")
}
}
return Result.Verified(event.pubKey)
}
sealed interface Result {
data class Verified(
val pubkey: HexKey,
) : Result
object Missing : Result
data class Malformed(
val reason: String,
) : Result
}
companion object {
const val SCHEME = "Nostr "
/**
* Cap on the in-memory replay-cache size. With a 60s tolerance
* an attacker would need to push >MAX/120 verified requests per
* second (one new id per ~120 ms) to evict legitimate entries.
* 1024 is generous for an admin endpoint.
*/
const val MAX_REPLAY_ENTRIES = 1024
}
}
@@ -163,7 +163,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"true\""))
assertTrue(okMessages[0].contains(",true,"))
assertTrue((session.policy as FullAuthPolicy).isAuthenticated())
assertTrue(session.policy.authenticatedUsers.contains(pubkey))
@@ -184,7 +184,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"false\""))
assertTrue(okMessages[0].contains(",false,"))
assertTrue(okMessages[0].contains("challenge"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
@@ -209,7 +209,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"false\""))
assertTrue(okMessages[0].contains(",false,"))
assertTrue(okMessages[0].contains("relay url"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
@@ -238,7 +238,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"false\""))
assertTrue(okMessages[0].contains(",false,"))
assertTrue(okMessages[0].contains("created_at"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
@@ -307,8 +307,8 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(2, okMessages.size)
assertTrue(okMessages[0].contains("\"true\""))
assertTrue(okMessages[1].contains("\"true\""))
assertTrue(okMessages[0].contains(",true,"))
assertTrue(okMessages[1].contains(",true,"))
val authedPubkeys = (session.policy as FullAuthPolicy).authenticatedUsers
assertEquals(2, authedPubkeys.size)
@@ -334,7 +334,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"false\""))
assertTrue(okMessages[0].contains(",false,"))
assertTrue(okMessages[0].contains("auth-required:"))
server.close()
@@ -394,7 +394,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"true\""))
assertTrue(okMessages[0].contains(",true,"))
// Now EVENT should work
val event = testEvent()
@@ -402,7 +402,7 @@ class NostrServerAuthTest {
val allOk = collector.rawMessagesContaining("OK")
assertEquals(2, allOk.size)
assertTrue(allOk[1].contains("\"true\""))
assertTrue(allOk[1].contains(",true,"))
// REQ should work
session.receive("""["REQ","sub1",{"kinds":[1]}]""")
@@ -428,7 +428,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"true\""))
assertTrue(okMessages[0].contains(",true,"))
server.close()
}
@@ -461,14 +461,14 @@ class NostrServerAuthTest {
// Kind 1 should be accepted without auth
val note = testEvent(hexId(1), kind = 1)
session.receive("""["EVENT",${note.toJson()}]""")
assertTrue(collector.rawMessagesContaining("OK")[0].contains("\"true\""))
assertTrue(collector.rawMessagesContaining("OK")[0].contains(",true,"))
// Kind 4 should be rejected without auth
val dm = testEvent(hexId(2), kind = 4)
session.receive("""["EVENT",${dm.toJson()}]""")
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(2, okMessages.size)
assertTrue(okMessages[1].contains("\"false\""))
assertTrue(okMessages[1].contains(",false,"))
assertTrue(okMessages[1].contains("auth-required:"))
server.close()
@@ -109,7 +109,7 @@ class NostrServerTest {
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"true\""))
assertTrue(okMessages[0].contains(",true,"))
// Event should be in store
val stored = store.query<Event>(Filter(ids = listOf(event.id)))
@@ -135,8 +135,8 @@ class NostrServerTest {
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(2, okMessages.size)
assertTrue(okMessages[0].contains("\"true\""))
assertTrue(okMessages[1].contains("\"false\""))
assertTrue(okMessages[0].contains(",true,"))
assertTrue(okMessages[1].contains(",false,"))
server.close()
}
@@ -30,9 +30,12 @@ class CountResultSerializer : StdSerializer<CountResult>(CountResult::class.java
gen: JsonGenerator,
provider: SerializerProvider,
) {
// NIP-45 result object: { "count": <int>, "approximate": <bool>? }.
gen.writeStartObject()
gen.writeNumberField("count", result.count)
gen.writeBooleanField("pubkey", result.approximate)
if (result.approximate) {
gen.writeBooleanField("approximate", true)
}
gen.writeEndObject()
}
}
@@ -50,11 +50,11 @@ class MessageSerializer : StdSerializer<Message>(Message::class.java) {
}
is OkMessage -> {
// NIP-01 wire format: ["OK", <event_id>, <true|false>, <message>]
// The third element is a JSON boolean, not a string.
gen.writeString(msg.eventId)
gen.writeString(msg.success.toString())
if (msg.message.isNotBlank()) {
gen.writeString(msg.message)
}
gen.writeBoolean(msg.success)
gen.writeString(msg.message)
}
is AuthMessage -> {
@@ -71,6 +71,8 @@ class MessageSerializer : StdSerializer<Message>(Message::class.java) {
}
is CountMessage -> {
// NIP-45 wire format: ["COUNT", <query_id>, <count_result>]
gen.writeString(msg.queryId)
countSerializer.serialize(msg.result, gen, provider)
}
@@ -1,54 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class DefaultContentTypeInterceptor(
private val userAgentHeader: String,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest: Request = chain.request()
val requestWithUserAgent: Request =
originalRequest
.newBuilder()
.header("User-Agent", userAgentHeader)
.build()
return chain.proceed(requestWithUserAgent)
}
}
open class BaseNostrClientTest {
companion object {
val rootClient =
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
.build()
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
}
}
@@ -19,39 +19,41 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
class NostrClientFirstEventTest : BaseNostrClientTest() {
class NostrClientFirstEventTest : RelayClientTest() {
@Test
fun testDownloadFirstEvent() =
runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
val seed =
Event(
id = "a".repeat(64),
pubKey = pubKey,
createdAt = 1000L,
kind = MetadataEvent.KIND,
tags = emptyArray(),
content = """{"name":"vitor"}""",
sig = "b".repeat(128),
)
defaultRelay.preload(seed)
val event =
client.fetchFirst(
relay = "wss://nos.lol",
filter =
Filter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"),
),
relay = defaultRelayUrl,
filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubKey)),
)
client.disconnect()
appScope.cancel()
assertEquals(MetadataEvent.KIND, event?.kind)
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", event?.pubKey)
assertEquals(pubKey, event?.pubKey)
}
}
@@ -19,17 +19,14 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.runBlocking
@@ -37,12 +34,11 @@ import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.Test
import kotlin.test.assertEquals
class NostrClientManualSubTest : BaseNostrClientTest() {
class NostrClientManualSubTest : RelayClientTest() {
@Test
fun testEoseAfter100Events() =
runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
defaultRelay.preload(SyntheticEvents.batch(150, kind = MetadataEvent.KIND))
val resultChannel = Channel<String>(UNLIMITED)
val events = mutableListOf<String>()
@@ -67,18 +63,11 @@ class NostrClientManualSubTest : BaseNostrClientTest() {
}
}
val filters =
mapOf(
RelayUrlNormalizer.normalize("wss://nos.lol") to
listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 100,
),
),
)
client.subscribe(mySubId, filters, listener)
client.subscribe(
mySubId,
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100))),
listener,
)
withTimeoutOrNull(10000) {
while (events.size < 101) {
@@ -88,11 +77,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() {
}
resultChannel.close()
client.unsubscribe(mySubId)
client.disconnect()
appScope.cancel()
assertEquals(101, events.size)
assertEquals(true, events.take(100).all { it.length == 64 })
@@ -19,73 +19,70 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
class NostrClientQueryCountTest : BaseNostrClientTest() {
val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl()
val utxo = "wss://news.utxo.one".normalizeRelayUrl()
class NostrClientQueryCountTest : RelayClientTest() {
private val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl()
private val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl()
val metadata = Filter(kinds = listOf(0))
val outboxRelays = Filter(kinds = listOf(10002))
private val metadata = Filter(kinds = listOf(0))
private val outboxRelays = Filter(kinds = listOf(10002))
private suspend fun seed() {
// 5 metadata + 3 outbox relay events on A, 2 metadata + 7 outbox on B.
// Each event needs a distinct (kind, pubkey, dTag) to avoid replaceable-event collisions.
fun pk(seed: Int) = SyntheticEvents.hexId(seed)
hub.getOrCreate(relayA).preload(
(1..5).map { SyntheticEvents.fakeEvent(idSeed = it, kind = 0, pubKey = pk(it)) },
)
hub.getOrCreate(relayA).preload(
(1..3).map { SyntheticEvents.fakeEvent(idSeed = 1000 + it, kind = 10002, pubKey = pk(1000 + it)) },
)
hub.getOrCreate(relayB).preload(
(1..2).map { SyntheticEvents.fakeEvent(idSeed = 2000 + it, kind = 0, pubKey = pk(2000 + it)) },
)
hub.getOrCreate(relayB).preload(
(1..7).map { SyntheticEvents.fakeEvent(idSeed = 3000 + it, kind = 10002, pubKey = pk(3000 + it)) },
)
}
@Test
fun testQueryCountSuspend() =
runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val result = client.count(fiatjaf, metadata)
assertTrue((result?.count ?: 0) > 1)
client.disconnect()
appScope.cancel()
seed()
val result = client.count(relayA, metadata)
assertEquals(5, result?.count)
}
@Test
fun testQueryCountSuspendAllEvents() =
runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val result = client.count(fiatjaf, Filter())
assertTrue((result?.count ?: 0) > 1)
client.disconnect()
appScope.cancel()
seed()
val result = client.count(relayA, Filter())
assertEquals(8, result?.count)
}
@Test
fun testQueryCountSuspendMultipleRelays() =
runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
seed()
val results =
client.count(
mapOf(
fiatjaf to listOf(metadata, outboxRelays),
utxo to listOf(metadata, outboxRelays),
relayA to listOf(metadata, outboxRelays),
relayB to listOf(metadata, outboxRelays),
),
)
results.forEach { (url, countResult) ->
println("${url.url}: ${countResult.count}")
assertTrue(countResult.count > 1)
}
client.disconnect()
appScope.cancel()
assertEquals(8, results[relayA]?.count)
assertEquals(9, results[relayB]?.count)
}
}
@@ -19,21 +19,18 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.coroutineScope
@@ -43,12 +40,29 @@ import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.Test
import kotlin.test.assertEquals
class NostrClientRepeatSubTest : BaseNostrClientTest() {
class NostrClientRepeatSubTest : RelayClientTest() {
@Test
fun testRepeatSubEvents() =
runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
// Each replaceable kind needs unique pubkeys.
defaultRelay.preload(
(1..150).map {
SyntheticEvents.fakeEvent(
idSeed = it,
kind = MetadataEvent.KIND,
pubKey = SyntheticEvents.hexId(it),
)
},
)
defaultRelay.preload(
(1..50).map {
SyntheticEvents.fakeEvent(
idSeed = 100_000 + it,
kind = AdvertisedRelayListEvent.KIND,
pubKey = SyntheticEvents.hexId(100_000 + it),
)
},
)
val resultChannel = Channel<String>(UNLIMITED)
val events = mutableListOf<String>()
@@ -80,38 +94,11 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
client.addConnectionListener(listener)
val filters =
mapOf(
RelayUrlNormalizer.normalize("wss://nos.lol") to
listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 100,
),
),
)
val filters = mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100)))
val filtersShouldIgnore =
mapOf(
RelayUrlNormalizer.normalize("wss://nos.lol") to
listOf(
Filter(
kinds = listOf(AdvertisedRelayListEvent.KIND),
limit = 500,
),
),
)
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 500)))
val filtersShouldSendAfterEOSE =
mapOf(
RelayUrlNormalizer.normalize("wss://nos.lol") to
listOf(
Filter(
kinds = listOf(AdvertisedRelayListEvent.KIND),
limit = 10,
),
),
)
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 10)))
coroutineScope {
launch {
@@ -140,29 +127,18 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
client.unsubscribe(mySubId)
client.removeConnectionListener(listener)
client.disconnect()
appScope.cancel()
// The relay may return up to limit events before EOSE; some relays return
// one extra past the requested limit, so don't assert on the exact count.
// First sub: <= 100 metadata events, then EOSE.
// Second sub: <= 10 advertised relay list events, then EOSE.
val firstEose = events.indexOf("EOSE")
val lastEose = events.lastIndexOf("EOSE")
// both EOSEs must be present and distinct
assertEquals(true, firstEose >= 0)
assertEquals(true, lastEose > firstEose)
// last entry is the second EOSE (loop stops on it)
assertEquals(events.size - 1, lastEose)
// first sub stays within its limit (allow +1 for relay quirks)
assertEquals(true, firstEose in 1..101)
// second sub stays within its limit (allow +1 for relay quirks)
assertEquals(true, (lastEose - firstEose - 1) in 1..11)
// everything before the first EOSE is an event id
assertEquals(true, events.take(firstEose).all { it.length == 64 })
// everything between the two EOSEs is an event id
assertEquals(true, events.subList(firstEose + 1, lastEose).all { it.length == 64 })
}
}
@@ -19,79 +19,82 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() {
class NostrClientReqBypassingRelayLimitsTest : RelayClientTest() {
@Test
fun testDownloadFromRelayReturnsMetadataEvents() =
runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
// Each event needs a unique pubkey so replaceable kind 0 doesn't
// collapse them all to one row.
val corpus =
(1..1000).map {
SyntheticEvents.fakeEvent(
idSeed = it,
kind = MetadataEvent.KIND,
pubKey = SyntheticEvents.hexId(it),
)
}
defaultRelay.preload(corpus)
val events = mutableListOf<Event>()
// nos.lol returns only 500 events per req
val totalFound =
client.fetchAllPages(
relay = "wss://nos.lol",
filters =
listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 1000,
),
),
relay = defaultRelayUrl,
filters = listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 1000)),
) { event ->
events.add(event)
}
client.disconnect()
delay(500)
appScope.cancel()
assertEquals(1000, totalFound, "Expected 1000 events from wss://nos.lol")
assertEquals(1000, events.size, "Events list should be 1000 events")
assertEquals(1000, totalFound)
assertEquals(1000, events.size)
events.forEach { event ->
assertEquals(MetadataEvent.KIND, event.kind, "All events should be kind ${MetadataEvent.KIND}")
assertEquals(MetadataEvent.KIND, event.kind)
}
}
@Test
fun testDownloadFromRelayReturnsMetadataAndContactListEvents() =
runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val metadata =
(1..1000).map {
SyntheticEvents.fakeEvent(
idSeed = it,
kind = MetadataEvent.KIND,
pubKey = SyntheticEvents.hexId(it),
)
}
val contacts =
(1..1500).map {
SyntheticEvents.fakeEvent(
idSeed = 100_000 + it,
kind = ContactListEvent.KIND,
pubKey = SyntheticEvents.hexId(100_000 + it),
)
}
defaultRelay.preload(metadata + contacts)
val metadataEvents = mutableListOf<Event>()
val contactListEvents = mutableListOf<Event>()
// nos.lol returns only 500 events per req
val totalFound =
client.fetchAllPages(
relay = "wss://nos.lol",
relay = defaultRelayUrl,
filters =
listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 1000,
),
Filter(
kinds = listOf(ContactListEvent.KIND),
limit = 1500,
),
Filter(kinds = listOf(MetadataEvent.KIND), limit = 1000),
Filter(kinds = listOf(ContactListEvent.KIND), limit = 1500),
),
) { event ->
if (event.kind == MetadataEvent.KIND) {
@@ -102,18 +105,8 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() {
}
}
client.disconnect()
delay(500)
appScope.cancel()
assertEquals(2500, totalFound, "Expected 1000 events from wss://nos.lol")
assertEquals(1000, metadataEvents.size, "Events list should be 1000 events")
assertEquals(1500, contactListEvents.size, "Events list should be 1000 events")
metadataEvents.forEach { event ->
assertEquals(MetadataEvent.KIND, event.kind, "All events should be kind ${MetadataEvent.KIND}")
}
contactListEvents.forEach { event ->
assertEquals(ContactListEvent.KIND, event.kind, "All events should be kind ${ContactListEvent.KIND}")
}
assertEquals(2500, totalFound)
assertEquals(1000, metadataEvents.size)
assertEquals(1500, contactListEvents.size)
}
}
@@ -19,47 +19,31 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
class NostrClientSendAndWaitTest : BaseNostrClientTest() {
class NostrClientSendAndWaitTest : RelayClientTest() {
@Test
fun testSendAndWaitForResponse() =
runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val randomSigner = NostrSignerInternal(KeyPair())
val event = randomSigner.sign(TextNoteEvent.build("Hello World"))
val resultDamus =
client.publishAndConfirm(
event = event,
relayList = setOf("wss://nostr.bitcoiner.social".normalizeRelayUrl()),
)
val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl()
val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl()
val resultNos =
client.publishAndConfirm(
event = event,
relayList = setOf("wss://nos.lol".normalizeRelayUrl()),
)
val resultA = client.publishAndConfirm(event = event, relayList = setOf(relayA))
val resultB = client.publishAndConfirm(event = event, relayList = setOf(relayB))
client.disconnect()
appScope.cancel()
assertEquals(true, resultDamus)
assertEquals(true, resultNos)
assertEquals(true, resultA)
assertEquals(true, resultB)
}
}
@@ -19,18 +19,16 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
@@ -38,7 +36,7 @@ import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
class NostrClientSubscriptionAsFlowTest : RelayClientTest() {
fun List<Event>.printDates(): String {
val starting = this[0].createdAt
return joinToString { (it.createdAt - starting).toString() }
@@ -48,17 +46,12 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
@Test
fun testNostrClientSubscriptionAsFlow() =
runTest {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND))
val flow =
client.subscribeAsFlow(
relay = "wss://nos.lol",
filter =
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 10,
),
relay = defaultRelayUrl,
filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10),
)
var feedStates = listOf<Event>()
@@ -75,10 +68,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
advanceUntilIdle()
}
job.cancel() // Cancel the collection job
client.disconnect()
appScope.cancel()
job.cancel()
assertEquals(10, feedStates.size)
}
@@ -87,17 +77,12 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
@Test
fun testNostrClientSubscriptionAsFlowDebouncing() =
runTest {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND))
val flow =
client.subscribeAsFlow(
relay = "wss://nos.lol",
filter =
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 10,
),
relay = defaultRelayUrl,
filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10),
)
var feedStates = listOf<Event>()
@@ -109,15 +94,11 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
}
}
// Advance the test dispatcher to ensure emissions are processed
while (feedStates.size < 10) {
advanceUntilIdle()
}
job.cancel() // Cancel the collection job
client.disconnect()
appScope.cancel()
job.cancel()
assertEquals(10, feedStates.size)
}
@@ -19,16 +19,13 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.StaticSubscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.runBlocking
@@ -36,12 +33,11 @@ import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.Test
import kotlin.test.assertEquals
class NostrClientSubscriptionTest : BaseNostrClientTest() {
class NostrClientSubscriptionTest : RelayClientTest() {
@Test
fun testNostrClientSubscription() =
runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
defaultRelay.preload(SyntheticEvents.batch(150, kind = MetadataEvent.KIND))
val resultChannel = Channel<Event>(UNLIMITED)
val events = mutableSetOf<Event>()
@@ -49,15 +45,7 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() {
val sub =
StaticSubscription(
client,
mapOf(
RelayUrlNormalizer.normalize("wss://nos.lol") to
listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 100,
),
),
),
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100))),
) { event ->
assertEquals(MetadataEvent.KIND, event.kind)
resultChannel.trySend(event)
@@ -71,12 +59,8 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() {
}
resultChannel.close()
sub.close()
client.disconnect()
appScope.cancel()
assertEquals(100, events.size)
}
}
@@ -19,18 +19,16 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
@@ -38,7 +36,7 @@ import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
class NostrClientSubscriptionUntilEoseAsFlowTest : RelayClientTest() {
fun List<Event>.printDates(): String {
val starting = this[0].createdAt
return joinToString { (it.createdAt - starting).toString() }
@@ -48,17 +46,12 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
@Test
fun testNostrClientSubscriptionUntilEoseAsFlow() =
runTest {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND))
val flow =
client.fetchAsFlow(
relay = "wss://nos.lol",
filter =
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 10,
),
relay = defaultRelayUrl,
filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10),
)
var feedStates = listOf<Event>()
@@ -70,15 +63,11 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
}
}
// Advance the test dispatcher to ensure emissions are processed
while (feedStates.size < 10) {
advanceUntilIdle()
}
job.cancel() // Cancel the collection job
client.disconnect()
appScope.cancel()
job.cancel()
assertEquals(10, feedStates.size)
}
@@ -87,17 +76,12 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
@Test
fun testNostrClientSubscriptionUntilEoseAsFlowDebouncing() =
runTest {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND))
val flow =
client.fetchAsFlow(
relay = "wss://nos.lol",
filter =
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 10,
),
relay = defaultRelayUrl,
filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10),
)
var feedStates = listOf<Event>()
@@ -109,15 +93,11 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
}
}
// Advance the test dispatcher to ensure emissions are processed
while (feedStates.size < 10) {
advanceUntilIdle()
}
job.cancel() // Cancel the collection job
client.disconnect()
appScope.cancel()
job.cancel()
assertEquals(10, feedStates.size)
}
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip86RelayManagement.server
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class BanStoreTest {
@Test
fun pubkeyBanIsCaseInsensitive() {
val s = BanStore()
s.banPubkey("ABCDEF1234".padEnd(64, '0'), "spam")
assertTrue(s.isBanned("abcdef1234".padEnd(64, '0')))
s.unbanPubkey("abcdef1234".padEnd(64, '0'))
assertFalse(s.isBanned("ABCDEF1234".padEnd(64, '0')))
}
@Test
fun allowListEmptyMeansEveryoneAllowed() {
val s = BanStore()
assertFalse(s.hasAllowList())
// No allow list → policy decision is purely deny-based; the
// store doesn't say a pubkey IS allowed unless it's listed.
assertFalse(s.isAllowedPubkey("aaaa".padEnd(64, '0')))
}
@Test
fun allowListNonEmptyTracksMembers() {
val s = BanStore()
s.allowPubkey("aa".padEnd(64, '0'), "trusted")
assertTrue(s.hasAllowList())
assertTrue(s.isAllowedPubkey("aa".padEnd(64, '0')))
assertFalse(s.isAllowedPubkey("bb".padEnd(64, '0')))
s.unallowPubkey("aa".padEnd(64, '0'))
assertFalse(s.hasAllowList())
}
@Test
fun eventBanRoundTrip() {
val s = BanStore()
s.banEvent("ee".padEnd(64, '0'), "policy")
assertTrue(s.isBannedEvent("EE".padEnd(64, '0')))
s.allowEvent("ee".padEnd(64, '0'))
assertFalse(s.isBannedEvent("ee".padEnd(64, '0')))
}
@Test
fun kindAllowDenyRules() {
val s = BanStore()
// Empty allow + empty deny → every kind is allowed.
assertTrue(s.isKindAllowed(1))
s.allowKind(1)
s.allowKind(7)
// Allow non-empty → only listed kinds are allowed.
assertTrue(s.isKindAllowed(1))
assertFalse(s.isKindAllowed(4))
s.disallowKind(7)
// Disallowing a kind removes it from the allow list and blocks.
assertFalse(s.isKindAllowed(7))
assertTrue(s.isKindAllowed(1))
assertEquals(listOf(1), s.listAllowedKinds())
assertEquals(listOf(7), s.listDisallowedKinds())
}
@Test
fun listsReflectStateForAuditTrail() {
val s = BanStore()
s.banPubkey("aa".padEnd(64, '0'), "spam")
s.banPubkey("bb".padEnd(64, '0'), null)
val banned = s.listBannedPubkeys().toMap()
assertEquals("spam", banned["aa".padEnd(64, '0')])
assertEquals(null, banned["bb".padEnd(64, '0')])
}
}
@@ -0,0 +1,202 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class Nip86ServerTest {
private fun fixture(): Triple<Nip86Server, BanStore, Holder> {
val store = BanStore()
val holder = Holder(Nip11RelayInformation(name = "before", description = "before-desc"))
val server = Nip86Server(banStore = store, infoHolder = holder, store = null)
return Triple(server, store, holder)
}
private class Holder(
var current: Nip11RelayInformation,
) : Nip86Server.InfoHolder {
override fun get() = current
override fun set(info: Nip11RelayInformation) {
current = info
}
}
private val pk = "a".repeat(64)
private val pk2 = "b".repeat(64)
private val eventId = "c".repeat(64)
@Test
fun supportedMethodsRoundTrip() {
runBlocking {
val (server, _, _) = fixture()
val resp = server.dispatch(Nip86Request.supportedMethods())
assertNull(resp.error)
val arr = resp.result as JsonArray
val names = arr.map { it.jsonPrimitive.content }
assertTrue(Nip86Method.SUPPORTED_METHODS in names)
assertTrue(Nip86Method.BAN_PUBKEY in names)
assertTrue(Nip86Method.CHANGE_RELAY_NAME in names)
}
}
@Test
fun banPubkeyMutatesStoreAndListsRoundTripWithReason() {
runBlocking {
val (server, banStore, _) = fixture()
val ok = server.dispatch(Nip86Request.banPubkey(pk, "spam"))
assertEquals(true, (ok.result as JsonPrimitive).boolean)
assertTrue(banStore.isBanned(pk))
val list = server.dispatch(Nip86Request.listBannedPubkeys())
val parsed =
kotlinx.serialization.json.Json
.decodeFromJsonElement(
kotlinx.serialization.builtins.ListSerializer(BannedPubkey.serializer()),
list.result as JsonArray,
)
assertEquals(1, parsed.size)
assertEquals(pk, parsed[0].pubkey)
assertEquals("spam", parsed[0].reason)
server.dispatch(Nip86Request.unbanPubkey(pk))
assertTrue(banStore.listBannedPubkeys().isEmpty())
}
}
@Test
fun allowPubkeyAndListRoundTrip() {
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(Nip86Request.allowPubkey(pk, "trusted"))
server.dispatch(Nip86Request.allowPubkey(pk2))
assertTrue(banStore.hasAllowList())
val resp = server.dispatch(Nip86Request.listAllowedPubkeys())
val list =
kotlinx.serialization.json.Json
.decodeFromJsonElement(
kotlinx.serialization.builtins.ListSerializer(AllowedPubkey.serializer()),
resp.result as JsonArray,
)
assertEquals(2, list.size)
assertEquals(setOf(pk, pk2), list.map { it.pubkey }.toSet())
}
}
@Test
fun banEventMarksIdAndDeletesFromStoreWhenStorePresent() {
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(Nip86Request.banEvent(eventId, "off-topic"))
assertTrue(banStore.isBannedEvent(eventId))
val resp = server.dispatch(Nip86Request.listBannedEvents())
val list =
kotlinx.serialization.json.Json
.decodeFromJsonElement(
kotlinx.serialization.builtins.ListSerializer(BannedEvent.serializer()),
resp.result as JsonArray,
)
assertEquals(1, list.size)
assertEquals(eventId, list[0].id)
assertEquals("off-topic", list[0].reason)
// allowevent (which is "unban") removes the entry.
server.dispatch(Nip86Request.allowEvent(eventId))
assertTrue(banStore.listBannedEvents().isEmpty())
}
}
@Test
fun allowKindAndDisallowKind() {
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(Nip86Request.allowKind(1))
server.dispatch(Nip86Request.allowKind(7))
server.dispatch(Nip86Request.disallowKind(4))
val list = server.dispatch(Nip86Request.listAllowedKinds())
val ints = (list.result as JsonArray).map { it.jsonPrimitive.int }
assertEquals(listOf(1, 7), ints)
assertTrue(banStore.isKindAllowed(1))
assertTrue(banStore.isKindAllowed(7))
assertEquals(false, banStore.isKindAllowed(4))
assertEquals(false, banStore.isKindAllowed(99))
}
}
@Test
fun changeRelayNameDescriptionIconRewriteInfoDoc() {
runBlocking {
val (server, _, holder) = fixture()
assertEquals("before", holder.current.name)
server.dispatch(Nip86Request.changeRelayName("after"))
assertEquals("after", holder.current.name)
server.dispatch(Nip86Request.changeRelayDescription("nice relay"))
assertEquals("nice relay", holder.current.description)
server.dispatch(Nip86Request.changeRelayIcon("https://x/icon.png"))
assertEquals("https://x/icon.png", holder.current.icon)
}
}
@Test
fun unsupportedMethodReturnsError() {
runBlocking {
val (server, _, _) = fixture()
val resp = server.dispatch(Nip86Request(method = "frobnicate"))
assertNotNull(resp.error)
assertTrue(resp.error!!.contains("frobnicate"))
}
}
@Test
fun missingParamsAreReportedAsErrors() {
runBlocking {
val (server, _, _) = fixture()
// banpubkey requires at least one positional param.
val resp = server.dispatch(Nip86Request(method = Nip86Method.BAN_PUBKEY))
assertNotNull(resp.error)
assertTrue(resp.error!!.startsWith("invalid params"))
}
}
}
@@ -0,0 +1,134 @@
/*
* 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.nip98HttpAuth
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class Nip98AuthVerifierTest {
private val verifier = Nip98AuthVerifier(now = { 1_000L })
private fun signedToken(
url: String,
method: String,
body: ByteArray? = null,
signer: NostrSignerSync = NostrSignerSync(KeyPair()),
createdAt: Long = 1_000L,
): Pair<String, String> {
val template = HTTPAuthorizationEvent.build(url = url, method = method, file = body, createdAt = createdAt)
val signed = signer.sign(template)
return signer.pubKey to signed.toAuthToken()
}
@Test
fun verifiesAValidPostWithBody() =
runBlocking {
val body = "hello".encodeToByteArray()
val (pubkey, header) = signedToken("http://x/", "POST", body)
val r = verifier.verify(header, "POST", "http://x/", body)
assertIs<Nip98AuthVerifier.Result.Verified>(r)
assertEquals(pubkey, r.pubkey)
}
@Test
fun missingHeaderReturnsMissing() {
runBlocking {
val r = verifier.verify(null, "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Missing>(r)
}
}
@Test
fun wrongSchemeIsMalformed() {
runBlocking {
val r = verifier.verify("Bearer abc", "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("Nostr"))
}
}
@Test
fun urlMismatchIsMalformed() {
runBlocking {
val (_, header) = signedToken("http://x/", "POST")
val r = verifier.verify(header, "POST", "http://y/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("url mismatch"))
}
}
@Test
fun methodMismatchIsMalformed() {
runBlocking {
val (_, header) = signedToken("http://x/", "POST")
val r = verifier.verify(header, "GET", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("method mismatch"))
}
}
@Test
fun payloadHashMismatchIsMalformed() {
runBlocking {
val (_, header) = signedToken("http://x/", "POST", "alpha".encodeToByteArray())
val r = verifier.verify(header, "POST", "http://x/", "beta".encodeToByteArray())
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("payload hash"))
}
}
@Test
fun staleCreatedAtIsMalformed() {
runBlocking {
// Verifier's clock is fixed at 1_000; sign a token created 5
// minutes earlier — outside the 60s tolerance.
val (_, header) = signedToken("http://x/", "POST", createdAt = 1_000L - 600)
val r = verifier.verify(header, "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("created_at"))
}
}
@Test
fun nonAuthEventKindIsMalformed() {
runBlocking {
// Build a kind-1 event by hand and shove it into the header — it
// must be rejected because NIP-98 specifically uses kind 27235.
val signer = NostrSignerSync(KeyPair())
val template =
com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
.build("not an auth event")
val signed = signer.sign(template)
val token =
"Nostr " +
kotlin.io.encoding.Base64
.encode(signed.toJson().encodeToByteArray())
val r = verifier.verify(token, "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("kind"))
}
}
}
+1
View File
@@ -34,6 +34,7 @@ rootProject.name = "Amethyst"
include ':amethyst'
include ':benchmark'
include ':quartz'
include ':geode'
include ':commons'
include ':ammolite'
include ':quic'