feat(relay): NIP-86 relay management API

Implements the operator-facing JSON-RPC admin protocol on the relay
side, layered on top of the existing NIP-98 HTTP-Auth + NIP-86 wire
types in :quartz. Same path as the NIP-01 WebSocket and NIP-11 GET —
HTTP POST with Content-Type application/nostr+json+rpc, gated by a
NIP-98 Authorization header signed by an operator-listed pubkey.

Architecture:
  - BanStore           — concurrent in-memory state for runtime
                         pubkey/event/kind ban + allow lists.
  - DynamicBanPolicy   — IRelayPolicy that consults BanStore on every
                         EVENT; auto-prepended to every Relay's policy
                         stack so admin actions take effect without a
                         restart.
  - Nip98AuthVerifier  — parses `Authorization: Nostr <base64-event>`,
                         verifies kind 27235 + Schnorr sig + ±60 s
                         clock skew + method/url/payload-hash match.
  - Nip86Server        — JSON-RPC dispatcher. Mutates BanStore for
                         ban/allow/list methods, mutates the live
                         RelayInfo doc for changerelayname/desc/icon
                         (Relay.info is now @Volatile var, mutation
                         through Relay.updateInfo).
  - LocalRelayServer   — adds POST handler at the relay path:
                         403 if no admin pubkeys configured,
                         401 if NIP-98 missing/invalid,
                         403 if signer's pubkey isn't on the admin list,
                         200 with Nip86Response otherwise.
  - RelayConfig.AdminSection — `[admin].pubkeys = [...]` config key.
  - RelayInfo.default() now advertises NIP-86 alongside 1/9/11/40/42/45/50/62.

Methods implemented:
  supportedmethods, banpubkey, unbanpubkey, listbannedpubkeys,
  allowpubkey, unallowpubkey, listallowedpubkeys,
  banevent (also deletes from store), allowevent, listbannedevents,
  allowkind, disallowkind, listallowedkinds,
  changerelayname, changerelaydescription, changerelayicon.

Tests (28 new):
  - BanStoreTest (6)            — ban/allow round-trips, case-insensitive
                                  pubkey match, kind allow/deny precedence,
                                  audit-trail listing.
  - Nip86ServerTest (8)         — every method's accept/reject path.
  - Nip98AuthVerifierTest (8)   — happy path, missing/wrong scheme,
                                  url/method/payload-hash mismatch,
                                  stale created_at, wrong event kind.
  - Nip86EndToEndTest (6)       — real HTTP POST through LocalRelayServer:
                                  supportedmethods returns 200,
                                  outsider returns 403, no auth header
                                  returns 401 + WWW-Authenticate, banpubkey
                                  blocks subsequent EVENT publish over WS,
                                  changerelayname flows to NIP-11 GET,
                                  admin endpoint is disabled when no
                                  pubkeys configured.

Total :quartz-relay tests: 87, 0 failures.
This commit is contained in:
Claude
2026-05-07 02:55:47 +00:00
parent 9ebdfc0b81
commit 7c7908d373
15 changed files with 1444 additions and 4 deletions
+1
View File
@@ -31,6 +31,7 @@ dependencies {
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.jackson.module.kotlin) implementation(libs.jackson.module.kotlin)
implementation(libs.kotlinx.serialization.json)
// Bundled SQLite driver — Relay's default in-memory EventStore creates // Bundled SQLite driver — Relay's default in-memory EventStore creates
// an in-memory DB at runtime. // an in-memory DB at runtime.
+8
View File
@@ -60,3 +60,11 @@ require_auth = false
# pubkey_blacklist = [] # pubkey_blacklist = []
# kind_whitelist = [0, 1, 3, 7, 1059, 30023] # kind_whitelist = [0, 1, 3, 7, 1059, 30023]
# kind_blacklist = [4] # 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..."]
@@ -20,8 +20,14 @@
*/ */
package com.vitorpamplona.quartz.relay package com.vitorpamplona.quartz.relay
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
import com.vitorpamplona.quartz.relay.admin.Nip86Server
import com.vitorpamplona.quartz.relay.admin.Nip98AuthVerifier
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
@@ -30,11 +36,14 @@ import io.ktor.server.cio.CIO
import io.ktor.server.cio.CIOApplicationEngine import io.ktor.server.cio.CIOApplicationEngine
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.request.header import io.ktor.server.request.header
import io.ktor.server.request.receiveChannel
import io.ktor.server.response.respondText import io.ktor.server.response.respondText
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket import io.ktor.server.websocket.webSocket
import io.ktor.utils.io.toByteArray
import io.ktor.websocket.Frame import io.ktor.websocket.Frame
import io.ktor.websocket.readText import io.ktor.websocket.readText
import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.channels.consumeEach
@@ -72,7 +81,32 @@ class LocalRelayServer(
* uses Ktor's default (~1 MiB). * uses Ktor's default (~1 MiB).
*/ */
val maxFrameBytes: Long? = null, 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(),
) { ) {
/**
* Bridges the relay's mutable [RelayInfo] to [Nip86Server.InfoHolder]
* so admin RPCs can rewrite the NIP-11 doc atomically.
*/
private val infoHolder =
object : Nip86Server.InfoHolder {
override fun get() = relay.info
override fun set(info: RelayInfo) {
relay.updateInfo { info.document }
}
}
private val nip86 = Nip86Server(banStore = relay.banStore, infoHolder = infoHolder, store = relay.store)
private val nip98 = Nip98AuthVerifier()
private val adminAllowList: Set<HexKey> = adminPubkeys.mapTo(HashSet()) { it.lowercase() }
private var engine: CIOApplicationEngine? = null private var engine: CIOApplicationEngine? = null
private var resolvedPort: Int = -1 private var resolvedPort: Int = -1
@@ -126,6 +160,11 @@ class LocalRelayServer(
) )
} }
} }
// NIP-86: POST application/nostr+json+rpc with a NIP-98
// signed Authorization header → JSON-RPC dispatch.
post(path) {
handleNip86(call)
}
webSocket(path) { webSocket(path) {
val session = val session =
relay.server.connect { json -> relay.server.connect { json ->
@@ -190,6 +229,94 @@ class LocalRelayServer(
resolvedPort = -1 resolvedPort = -1
} }
/**
* Handles a NIP-86 admin RPC request:
* 1. 403 if no admin pubkey list is configured (endpoint disabled).
* 2. 401 if the NIP-98 Authorization header is missing/invalid.
* 3. 403 if the verified pubkey isn't in [adminAllowList].
* 4. 400 if the body isn't a valid Nip86Request.
* 5. 200 with a Nip86Response JSON body otherwise.
*
* `application/nostr+json+rpc` is the wire content type prescribed
* by NIP-86; we send it on responses and accept any body on the
* request (the auth event's payload-hash already binds the body).
*/
private suspend fun handleNip86(call: io.ktor.server.application.ApplicationCall) {
if (adminAllowList.isEmpty()) {
call.respondText(
"NIP-86 management API is not enabled on this relay.",
ContentType.Text.Plain,
HttpStatusCode.Forbidden,
)
return
}
val body = call.receiveChannel().toByteArray()
val authHeader = call.request.header(HttpHeaders.Authorization)
// NIP-86 spec: the URL the client signed must be the relay's
// canonical http(s) URL, not the WS one. We reconstruct it from
// the request so the comparison is symmetric whether the
// operator runs the relay behind a reverse proxy or directly.
val signedUrl =
"http://" +
(call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path
val verification = nip98.verify(authHeader, method = "POST", url = signedUrl, body = body)
val pubkey =
when (verification) {
is Nip98AuthVerifier.Result.Verified -> {
verification.pubkey
}
Nip98AuthVerifier.Result.Missing -> {
call.response.headers.append(HttpHeaders.WWWAuthenticate, Nip98AuthVerifier.SCHEME.trim())
call.respondText(
"missing Authorization header (NIP-98)",
ContentType.Text.Plain,
HttpStatusCode.Unauthorized,
)
return
}
is Nip98AuthVerifier.Result.Malformed -> {
call.respondText(
"invalid NIP-98 Authorization: ${verification.reason}",
ContentType.Text.Plain,
HttpStatusCode.Unauthorized,
)
return
}
}
if (pubkey.lowercase() !in adminAllowList) {
call.respondText(
"pubkey is not on the admin list",
ContentType.Text.Plain,
HttpStatusCode.Forbidden,
)
return
}
val req =
try {
JsonMapper.fromJson<Nip86Request>(body.decodeToString())
} catch (e: Exception) {
call.respondText(
"invalid Nip86Request: ${e.message ?: e::class.simpleName}",
ContentType.Text.Plain,
HttpStatusCode.BadRequest,
)
return
}
val response: Nip86Response = nip86.dispatch(req)
call.respondText(
JsonMapper.toJson(response),
ContentType.parse("application/nostr+json+rpc"),
HttpStatusCode.OK,
)
}
/** /**
* Best-effort NOTICE to every active client. Failures are * Best-effort NOTICE to every active client. Failures are
* swallowed — a flaky socket on its way out is exactly the case * swallowed — a flaky socket on its way out is exactly the case
@@ -114,6 +114,7 @@ fun main(args: Array<String>) {
port = port, port = port,
path = path, path = path,
maxFrameBytes = frameLimit, maxFrameBytes = frameLimit,
adminPubkeys = config.admin.pubkeys.toSet(),
).start() ).start()
Runtime.getRuntime().addShutdownHook( Runtime.getRuntime().addShutdownHook(
@@ -29,6 +29,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.relay.admin.BanStore
import com.vitorpamplona.quartz.relay.admin.DynamicBanPolicy
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@@ -51,11 +54,45 @@ import kotlin.coroutines.CoroutineContext
class Relay( class Relay(
val url: NormalizedRelayUrl, val url: NormalizedRelayUrl,
val store: IEventStore = EventStore(dbName = null, relay = url), val store: IEventStore = EventStore(dbName = null, relay = url),
val info: RelayInfo = RelayInfo.default(url), info: RelayInfo = RelayInfo.default(url),
policyBuilder: () -> IRelayPolicy = { EmptyPolicy }, policyBuilder: () -> IRelayPolicy = { EmptyPolicy },
parentContext: CoroutineContext = SupervisorJob(), parentContext: CoroutineContext = SupervisorJob(),
) : AutoCloseable { ) : AutoCloseable {
val server = NostrServer(store, policyBuilder, parentContext) /**
* NIP-11 doc. Mutable so NIP-86 admin RPCs (`changerelayname`,
* `changerelaydescription`, `changerelayicon`) can swap the doc
* atomically. Readers (the NIP-11 GET endpoint) re-read on every
* request so changes are visible immediately, no restart needed.
*/
@Volatile
var info: RelayInfo = info
private set
/** Mutates the live NIP-11 doc. Called by [admin.Nip86Server]. */
fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
info = RelayInfo(transform(info.document))
}
/**
* Runtime-mutable ban / allow lists. NIP-86 RPC handlers in
* [admin.Nip86Server] mutate this; the policy stack consults it on
* every accept call via [DynamicBanPolicy].
*/
val banStore: BanStore = BanStore()
val server =
NostrServer(
store,
// Always prepend a DynamicBanPolicy so NIP-86 admin actions
// bite. When the operator-supplied builder returns
// [EmptyPolicy] we use the dynamic policy alone; otherwise
// we stack them so both layers must accept.
policyBuilder = {
val user = policyBuilder()
if (user === EmptyPolicy) DynamicBanPolicy(banStore) else user + DynamicBanPolicy(banStore)
},
parentContext,
)
/** /**
* Inserts events directly into the underlying store, bypassing the wire protocol. * Inserts events directly into the underlying store, bypassing the wire protocol.
@@ -49,8 +49,9 @@ data class RelayInfo(
// Currently implemented: NIP-01 (basic), NIP-09 (deletion via // Currently implemented: NIP-01 (basic), NIP-09 (deletion via
// DeletionRequestModule), NIP-11 (this doc), NIP-40 (expiration // DeletionRequestModule), NIP-11 (this doc), NIP-40 (expiration
// via ExpirationModule), NIP-42 (AUTH — when policy enables), // via ExpirationModule), NIP-42 (AUTH — when policy enables),
// NIP-45 (COUNT), NIP-50 (search via FTS), NIP-62 (right to vanish). // NIP-45 (COUNT), NIP-50 (search via FTS), NIP-62 (right to vanish),
supported_nips = listOf("1", "9", "11", "40", "42", "45", "50", "62"), // NIP-86 (relay management API — when admin pubkeys are configured).
supported_nips = listOf("1", "9", "11", "40", "42", "45", "50", "62", "86"),
), ),
) )
@@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.admin
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import java.util.concurrent.ConcurrentHashMap
/**
* Mutable, thread-safe runtime state for the NIP-86 management API.
*
* Each entry carries an optional reason string so list-* RPCs can echo
* back why an admin took the action — useful for audit trails.
*
* Today the state is in-memory only; a process restart wipes the bans.
* Wiring a persistent backend is a separate concern (a JSON file
* snapshot on each mutation, or a small SQLite table) and can be
* layered on by replacing this class behind the [DynamicBanPolicy]
* interface.
*/
class BanStore {
/**
* Pubkeys whose events the relay rejects. Compared case-insensitive
* (lowercased on insert / lookup) so an admin pasting a hex pubkey
* with mixed case still works. Empty-string value means "no
* reason given" — `ConcurrentHashMap` rejects nulls.
*/
private val bannedPubkeys = ConcurrentHashMap<HexKey, String>()
/**
* Pubkeys explicitly allowed. When non-empty, this acts as a
* whitelist: events from any pubkey not on the list are rejected.
*/
private val allowedPubkeys = ConcurrentHashMap<HexKey, String>()
/** Event ids the relay refuses to store/replay. */
private val bannedEventIds = ConcurrentHashMap<HexKey, String>()
private fun reasonOrEmpty(s: String?): String = s ?: ""
private fun nullIfEmpty(s: String): String? = s.ifEmpty { null }
/**
* Allowed kinds. When non-empty, events whose kind is not in the
* list are rejected.
*/
private val allowedKinds = ConcurrentHashMap.newKeySet<Int>()
/** Disallowed kinds. Always blocks regardless of [allowedKinds]. */
private val disallowedKinds = ConcurrentHashMap.newKeySet<Int>()
// -- Pubkey ban list -----------------------------------------------------
fun banPubkey(
pubkey: HexKey,
reason: String? = null,
) {
bannedPubkeys[pubkey.lowercase()] = reasonOrEmpty(reason)
}
fun unbanPubkey(pubkey: HexKey) {
bannedPubkeys.remove(pubkey.lowercase())
}
fun isBanned(pubkey: HexKey): Boolean = bannedPubkeys.containsKey(pubkey.lowercase())
fun listBannedPubkeys(): List<Pair<HexKey, String?>> = bannedPubkeys.entries.map { it.key to nullIfEmpty(it.value) }
// -- Pubkey allow list ---------------------------------------------------
fun allowPubkey(
pubkey: HexKey,
reason: String? = null,
) {
allowedPubkeys[pubkey.lowercase()] = reasonOrEmpty(reason)
}
fun unallowPubkey(pubkey: HexKey) {
allowedPubkeys.remove(pubkey.lowercase())
}
fun isAllowedPubkey(pubkey: HexKey): Boolean = allowedPubkeys.containsKey(pubkey.lowercase())
fun listAllowedPubkeys(): List<Pair<HexKey, String?>> = allowedPubkeys.entries.map { it.key to nullIfEmpty(it.value) }
fun hasAllowList(): Boolean = allowedPubkeys.isNotEmpty()
// -- Event id ban list ---------------------------------------------------
fun banEvent(
eventId: HexKey,
reason: String? = null,
) {
bannedEventIds[eventId.lowercase()] = reasonOrEmpty(reason)
}
/** Removes an event id from the ban list. Mirrors NIP-86 `allowevent`. */
fun allowEvent(eventId: HexKey) {
bannedEventIds.remove(eventId.lowercase())
}
fun isBannedEvent(eventId: HexKey): Boolean = bannedEventIds.containsKey(eventId.lowercase())
fun listBannedEvents(): List<Pair<HexKey, String?>> = bannedEventIds.entries.map { it.key to nullIfEmpty(it.value) }
// -- Kind allow / deny --------------------------------------------------
fun allowKind(kind: Int) {
allowedKinds.add(kind)
}
fun disallowKind(kind: Int) {
disallowedKinds.add(kind)
// Disallowing a kind implicitly removes it from the allow list.
allowedKinds.remove(kind)
}
fun listAllowedKinds(): List<Int> = allowedKinds.sorted()
fun listDisallowedKinds(): List<Int> = disallowedKinds.sorted()
fun isKindAllowed(kind: Int): Boolean {
if (kind in disallowedKinds) return false
if (allowedKinds.isEmpty()) return true
return kind in allowedKinds
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.admin
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
import com.vitorpamplona.quartz.relay.policies.PassThroughPolicy
/**
* Reads the live [BanStore] on every EVENT and rejects events that
* violate any of: banned-event-id, banned-pubkey, missing from a
* non-empty allow list, or kind disallowed / not in the kind allow
* list.
*
* This is the runtime-mutable counterpart of the static
* [com.vitorpamplona.quartz.relay.policies.KindAllowDenyPolicy] +
* [com.vitorpamplona.quartz.relay.policies.PubkeyAllowDenyPolicy] —
* both sets compose: the event must clear both layers. NIP-86 admin
* RPC mutations land here; the static policies stay frozen at
* boot-time config values.
*/
class DynamicBanPolicy(
val banStore: BanStore,
) : PassThroughPolicy() {
override fun accept(cmd: EventCmd): PolicyResult<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,272 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.admin
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
import com.vitorpamplona.quartz.relay.RelayInfo
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.int
/**
* NIP-86 RPC dispatcher. Holds the [BanStore] (mutated by ban/allow
* methods), the live [RelayInfo] handle (mutated by `changerelay*`
* methods, which atomically swap the doc), and the underlying
* [IEventStore] so `banevent` can also delete the offending event.
*
* The dispatcher is transport-agnostic — `LocalRelayServer` calls
* [dispatch] from its HTTP route, but the same handler also works for
* in-process tests that build a [Nip86Request] directly.
*
* [supportedMethods] is the canonical list this server actually
* implements; methods returned outside of it are no-ops and a NIP-86
* client must not advertise them.
*/
class Nip86Server(
val banStore: BanStore,
/**
* Read-write access to the relay's NIP-11 info doc. The dispatcher
* mutates this when an admin calls `changerelayname` /
* `changerelaydescription` / `changerelayicon`. Relay code reading
* the doc (e.g. the NIP-11 endpoint) must consult this object on
* every request, not cache it.
*/
private val infoHolder: InfoHolder,
private val store: IEventStore? = null,
) {
/** Pluggable container so the relay's NIP-11 doc can be swapped at runtime. */
interface InfoHolder {
fun get(): RelayInfo
fun set(info: RelayInfo)
}
val supportedMethods: List<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 -> {
result(buildJsonArray { supportedMethods.forEach { add(JsonPrimitive(it)) } })
}
Nip86Method.BAN_PUBKEY -> {
val (pk, reason) = req.params.stringPair() ?: return malformed("expected [pubkey, reason?]")
banStore.banPubkey(pk, reason)
result(JsonPrimitive(true))
}
Nip86Method.UNBAN_PUBKEY -> {
val (pk, _) = req.params.stringPair() ?: return malformed("expected [pubkey]")
banStore.unbanPubkey(pk)
result(JsonPrimitive(true))
}
Nip86Method.LIST_BANNED_PUBKEYS -> {
result(
banStore
.listBannedPubkeys()
.map { (pk, r) -> BannedPubkey(pk, r) }
.toJsonArray(BannedPubkey.serializer()),
)
}
Nip86Method.ALLOW_PUBKEY -> {
val (pk, reason) = req.params.stringPair() ?: return malformed("expected [pubkey, reason?]")
banStore.allowPubkey(pk, reason)
result(JsonPrimitive(true))
}
Nip86Method.UNALLOW_PUBKEY -> {
val (pk, _) = req.params.stringPair() ?: return malformed("expected [pubkey]")
banStore.unallowPubkey(pk)
result(JsonPrimitive(true))
}
Nip86Method.LIST_ALLOWED_PUBKEYS -> {
result(
banStore
.listAllowedPubkeys()
.map { (pk, r) -> AllowedPubkey(pk, r) }
.toJsonArray(AllowedPubkey.serializer()),
)
}
Nip86Method.BAN_EVENT -> {
val (id, reason) = req.params.stringPair() ?: return malformed("expected [event_id, reason?]")
banStore.banEvent(id, reason)
// Also remove the event from the store if it's there.
store?.delete(Filter(ids = listOf(id)))
result(JsonPrimitive(true))
}
Nip86Method.ALLOW_EVENT -> {
val (id, _) = req.params.stringPair() ?: return malformed("expected [event_id]")
banStore.allowEvent(id)
result(JsonPrimitive(true))
}
Nip86Method.LIST_BANNED_EVENTS -> {
result(
banStore
.listBannedEvents()
.map { (id, r) -> BannedEvent(id, r) }
.toJsonArray(BannedEvent.serializer()),
)
}
Nip86Method.ALLOW_KIND -> {
val k = req.params.firstInt() ?: return malformed("expected [kind]")
banStore.allowKind(k)
result(JsonPrimitive(true))
}
Nip86Method.DISALLOW_KIND -> {
val k = req.params.firstInt() ?: return malformed("expected [kind]")
banStore.disallowKind(k)
result(JsonPrimitive(true))
}
Nip86Method.LIST_ALLOWED_KINDS -> {
result(buildJsonArray { banStore.listAllowedKinds().forEach { add(JsonPrimitive(it)) } })
}
Nip86Method.CHANGE_RELAY_NAME -> {
val name = req.params.firstString() ?: return malformed("expected [name]")
rewriteInfo { it.copy(name = name) }
result(JsonPrimitive(true))
}
Nip86Method.CHANGE_RELAY_DESCRIPTION -> {
val desc = req.params.firstString() ?: return malformed("expected [description]")
rewriteInfo { it.copy(description = desc) }
result(JsonPrimitive(true))
}
Nip86Method.CHANGE_RELAY_ICON -> {
val icon = req.params.firstString() ?: return malformed("expected [icon_url]")
rewriteInfo { it.copy(icon = icon) }
result(JsonPrimitive(true))
}
else -> {
Nip86Response(error = "method not supported: ${req.method}")
}
}
}.getOrElse { e ->
Nip86Response(error = "internal: ${e.message ?: e::class.simpleName}")
}
private fun rewriteInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
val current = infoHolder.get().document
infoHolder.set(RelayInfo(transform(current)))
}
/** [Nip11RelayInformation] is not a `data class`; do a manual field-by-field copy. */
private fun Nip11RelayInformation.copy(
name: String? = this.name,
description: String? = this.description,
icon: String? = this.icon,
) = Nip11RelayInformation(
id = this.id,
name = name,
description = description,
icon = icon,
pubkey = this.pubkey,
self = this.self,
contact = this.contact,
supported_nips = this.supported_nips,
supported_nip_extensions = this.supported_nip_extensions,
software = this.software,
version = this.version,
limitation = this.limitation,
relay_countries = this.relay_countries,
language_tags = this.language_tags,
tags = this.tags,
posting_policy = this.posting_policy,
privacy_policy = this.privacy_policy,
terms_of_service = this.terms_of_service,
payments_url = this.payments_url,
retention = this.retention,
fees = this.fees,
nip50 = this.nip50,
supported_grasps = this.supported_grasps,
)
}
private fun malformed(reason: String) = Nip86Response(error = "invalid params: $reason")
private fun result(j: JsonElement) = Nip86Response(result = j, error = null)
private val rpcJson = Json { encodeDefaults = false }
private fun <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,131 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.admin
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
/**
* Verifies a NIP-98 `Authorization: Nostr <base64-event>` header.
*
* NIP-98 reuses kind 27235 events with `u`, `method`, and (for bodies)
* `payload` tags. The relay must check:
* 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 ±60 s of now (NIP-98 spec).
* 4. The `method` tag matches the HTTP method.
* 5. The `u` tag matches the requested URL.
* 6. If a body is present, the `payload` tag matches `sha256(body)` hex.
*
* Returns the verified pubkey on success; `null` on any failure (the
* caller turns this into a `401 Unauthorized`).
*/
class Nip98AuthVerifier(
private val now: () -> Long = { TimeUtils.now() },
/** Allowed clock skew in seconds. NIP-98 says 60. */
private val toleranceSeconds: Long = 60,
) {
@OptIn(ExperimentalEncodingApi::class)
fun verify(
authorizationHeader: String?,
method: String,
url: String,
body: ByteArray?,
): Result {
if (authorizationHeader.isNullOrBlank()) return Result.Missing
if (!authorizationHeader.startsWith(SCHEME)) return Result.Malformed("expected '$SCHEME <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 skew = abs(event.createdAt - now())
if (skew > toleranceSeconds) {
return Result.Malformed("created_at is ${skew}s away from now (max ${toleranceSeconds}s)")
}
// Re-wrap as the typed event so the tag accessors work.
val auth =
HTTPAuthorizationEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
if (!auth.method().equals(method, ignoreCase = true)) {
return Result.Malformed("method mismatch: expected $method, got ${auth.method()}")
}
if (auth.url() != url) {
return Result.Malformed("url mismatch: expected $url, got ${auth.url()}")
}
if (body != null && body.isNotEmpty()) {
val expected = sha256(body).toHexKey()
if (auth.payloadHash() != expected) {
return Result.Malformed("payload hash mismatch")
}
}
return Result.Verified(event.pubKey)
}
sealed interface Result {
data class Verified(
val pubkey: HexKey,
) : Result
object Missing : Result
data class Malformed(
val reason: String,
) : Result
}
companion object {
const val SCHEME = "Nostr "
}
}
@@ -42,6 +42,7 @@ data class RelayConfig(
val options: OptionsSection = OptionsSection(), val options: OptionsSection = OptionsSection(),
val limits: LimitsSection = LimitsSection(), val limits: LimitsSection = LimitsSection(),
val authorization: AuthorizationSection = AuthorizationSection(), val authorization: AuthorizationSection = AuthorizationSection(),
val admin: AdminSection = AdminSection(),
) { ) {
/** /**
* Maps the `[info]` section into a [RelayInfo] used by the NIP-11 * Maps the `[info]` section into a [RelayInfo] used by the NIP-11
@@ -133,6 +134,17 @@ data class RelayConfig(
val kind_blacklist: 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.
*/
data class AdminSection(
val pubkeys: List<String> = emptyList(),
)
companion object { companion object {
private val mapper = tomlMapper { } private val mapper = tomlMapper { }
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.admin
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class BanStoreTest {
@Test
fun pubkeyBanIsCaseInsensitive() {
val s = BanStore()
s.banPubkey("ABCDEF1234".padEnd(64, '0'), "spam")
assertTrue(s.isBanned("abcdef1234".padEnd(64, '0')))
s.unbanPubkey("abcdef1234".padEnd(64, '0'))
assertFalse(s.isBanned("ABCDEF1234".padEnd(64, '0')))
}
@Test
fun allowListEmptyMeansEveryoneAllowed() {
val s = BanStore()
assertFalse(s.hasAllowList())
// No allow list → policy decision is purely deny-based; the
// store doesn't say a pubkey IS allowed unless it's listed.
assertFalse(s.isAllowedPubkey("aaaa".padEnd(64, '0')))
}
@Test
fun allowListNonEmptyTracksMembers() {
val s = BanStore()
s.allowPubkey("aa".padEnd(64, '0'), "trusted")
assertTrue(s.hasAllowList())
assertTrue(s.isAllowedPubkey("aa".padEnd(64, '0')))
assertFalse(s.isAllowedPubkey("bb".padEnd(64, '0')))
s.unallowPubkey("aa".padEnd(64, '0'))
assertFalse(s.hasAllowList())
}
@Test
fun eventBanRoundTrip() {
val s = BanStore()
s.banEvent("ee".padEnd(64, '0'), "policy")
assertTrue(s.isBannedEvent("EE".padEnd(64, '0')))
s.allowEvent("ee".padEnd(64, '0'))
assertFalse(s.isBannedEvent("ee".padEnd(64, '0')))
}
@Test
fun kindAllowDenyRules() {
val s = BanStore()
// Empty allow + empty deny → every kind is allowed.
assertTrue(s.isKindAllowed(1))
s.allowKind(1)
s.allowKind(7)
// Allow non-empty → only listed kinds are allowed.
assertTrue(s.isKindAllowed(1))
assertFalse(s.isKindAllowed(4))
s.disallowKind(7)
// Disallowing a kind removes it from the allow list and blocks.
assertFalse(s.isKindAllowed(7))
assertTrue(s.isKindAllowed(1))
assertEquals(listOf(1), s.listAllowedKinds())
assertEquals(listOf(7), s.listDisallowedKinds())
}
@Test
fun listsReflectStateForAuditTrail() {
val s = BanStore()
s.banPubkey("aa".padEnd(64, '0'), "spam")
s.banPubkey("bb".padEnd(64, '0'), null)
val banned = s.listBannedPubkeys().toMap()
assertEquals("spam", banned["aa".padEnd(64, '0')])
assertEquals(null, banned["bb".padEnd(64, '0')])
}
}
@@ -0,0 +1,232 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.admin
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.relay.LocalRelayServer
import com.vitorpamplona.quartz.relay.Relay
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Drives a real `LocalRelayServer` over HTTP and proves the NIP-86
* admin RPC flow works end-to-end: NIP-98 auth, admin allow-list
* gate, ban mutation, and the resulting policy effect on a follow-up
* EVENT publish.
*/
class Nip86EndToEndTest {
private lateinit var relay: Relay
private lateinit var server: LocalRelayServer
private lateinit var scope: CoroutineScope
private lateinit var nostrClient: NostrClient
private val httpClient = OkHttpClient.Builder().build()
private val admin = NostrSignerSync(KeyPair())
private val outsider = NostrSignerSync(KeyPair())
private val targetUser = NostrSignerSync(KeyPair())
@BeforeTest
fun setup() {
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
relay = Relay(url = placeholder)
server =
LocalRelayServer(
relay = relay,
host = "127.0.0.1",
port = 0,
adminPubkeys = setOf(admin.pubKey),
).start()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient }
nostrClient = NostrClient(builder, scope)
}
@AfterTest
fun teardown() {
nostrClient.disconnect()
scope.cancel()
server.stop(gracePeriodMillis = 200, timeoutMillis = 500)
relay.close()
}
private val httpUrl get() = server.url.replace("ws://", "http://")
/** Sends a NIP-86 RPC request signed by [signer] and returns the raw HTTP response. */
private fun rpc(
request: Nip86Request,
signer: NostrSignerSync,
): okhttp3.Response {
val body = JsonMapper.toJson(request).encodeToByteArray()
val authTemplate =
HTTPAuthorizationEvent.build(url = httpUrl, method = "POST", file = body)
val authToken = signer.sign(authTemplate).toAuthToken()
return httpClient
.newCall(
Request
.Builder()
.url(httpUrl)
.post(body.toRequestBody("application/nostr+json+rpc".toMediaType()))
.header("Authorization", authToken)
.build(),
).execute()
}
@Test
fun supportedMethodsListsTheServersMethods() {
rpc(Nip86Request.supportedMethods(), admin).use {
assertEquals(200, it.code)
val json = JsonMapper.fromJson<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, "DynamicBanPolicy must reject events from banned pubkeys")
}
@Test
fun changeRelayNameFlowsToNip11Endpoint() {
rpc(Nip86Request.changeRelayName("renamed-by-admin"), admin).use {
assertEquals(200, it.code)
}
// Read the NIP-11 endpoint and confirm the new name is live.
val response =
httpClient
.newCall(
Request
.Builder()
.url(httpUrl)
.header("Accept", "application/nostr+json")
.build(),
).execute()
response.use {
val info = Nip11RelayInformation.fromJson(it.body.string())
assertEquals("renamed-by-admin", info.name)
}
}
@Test
fun adminEndpointDisabledWhenNoPubkeysConfigured() =
runBlocking {
// Spin up a *separate* server with no admin pubkeys.
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
val openRelay = Relay(url = placeholder)
val openServer =
LocalRelayServer(openRelay, host = "127.0.0.1", port = 0).start()
try {
val openHttpUrl = openServer.url.replace("ws://", "http://")
val body =
JsonMapper.toJson(Nip86Request.supportedMethods()).encodeToByteArray()
val authToken =
admin
.sign(
HTTPAuthorizationEvent.build(url = openHttpUrl, method = "POST", file = body),
).toAuthToken()
httpClient
.newCall(
Request
.Builder()
.url(openHttpUrl)
.post(body.toRequestBody("application/nostr+json+rpc".toMediaType()))
.header("Authorization", authToken)
.build(),
).execute()
.use {
assertEquals(403, it.code)
}
} finally {
openServer.stop(gracePeriodMillis = 100, timeoutMillis = 500)
openRelay.close()
}
}
}
@@ -0,0 +1,198 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.admin
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.relay.RelayInfo
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class Nip86ServerTest {
private fun fixture(): Triple<Nip86Server, BanStore, Holder> {
val store = BanStore()
val holder =
Holder(RelayInfo(Nip11RelayInformation(name = "before", description = "before-desc")))
val server = Nip86Server(banStore = store, infoHolder = holder, store = null)
return Triple(server, store, holder)
}
private class Holder(
var current: RelayInfo,
) : Nip86Server.InfoHolder {
override fun get() = current
override fun set(info: RelayInfo) {
current = info
}
}
private val pk = "a".repeat(64)
private val pk2 = "b".repeat(64)
private val eventId = "c".repeat(64)
private val relayUrl = RelayUrlNormalizer.normalize("ws://test/")
@Test
fun supportedMethodsRoundTrip() =
runBlocking {
val (server, _, _) = fixture()
val resp = server.dispatch(Nip86Request.supportedMethods())
assertNull(resp.error)
val arr = resp.result as JsonArray
val names = arr.map { it.jsonPrimitive.content }
assertTrue(Nip86Method.SUPPORTED_METHODS in names)
assertTrue(Nip86Method.BAN_PUBKEY in names)
assertTrue(Nip86Method.CHANGE_RELAY_NAME in names)
}
@Test
fun banPubkeyMutatesStoreAndListsRoundTripWithReason() =
runBlocking {
val (server, banStore, _) = fixture()
val ok = server.dispatch(Nip86Request.banPubkey(pk, "spam"))
assertEquals(true, (ok.result as JsonPrimitive).boolean)
assertTrue(banStore.isBanned(pk))
val list = server.dispatch(Nip86Request.listBannedPubkeys())
val parsed =
kotlinx.serialization.json.Json
.decodeFromJsonElement(
kotlinx.serialization.builtins.ListSerializer(BannedPubkey.serializer()),
list.result as JsonArray,
)
assertEquals(1, parsed.size)
assertEquals(pk, parsed[0].pubkey)
assertEquals("spam", parsed[0].reason)
server.dispatch(Nip86Request.unbanPubkey(pk))
assertTrue(banStore.listBannedPubkeys().isEmpty())
}
@Test
fun allowPubkeyAndListRoundTrip() =
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(Nip86Request.allowPubkey(pk, "trusted"))
server.dispatch(Nip86Request.allowPubkey(pk2))
assertTrue(banStore.hasAllowList())
val resp = server.dispatch(Nip86Request.listAllowedPubkeys())
val list =
kotlinx.serialization.json.Json
.decodeFromJsonElement(
kotlinx.serialization.builtins.ListSerializer(AllowedPubkey.serializer()),
resp.result as JsonArray,
)
assertEquals(2, list.size)
assertEquals(setOf(pk, pk2), list.map { it.pubkey }.toSet())
}
@Test
fun banEventMarksIdAndDeletesFromStoreWhenStorePresent() =
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(Nip86Request.banEvent(eventId, "off-topic"))
assertTrue(banStore.isBannedEvent(eventId))
val resp = server.dispatch(Nip86Request.listBannedEvents())
val list =
kotlinx.serialization.json.Json
.decodeFromJsonElement(
kotlinx.serialization.builtins.ListSerializer(BannedEvent.serializer()),
resp.result as JsonArray,
)
assertEquals(1, list.size)
assertEquals(eventId, list[0].id)
assertEquals("off-topic", list[0].reason)
// allowevent (which is "unban") removes the entry.
server.dispatch(Nip86Request.allowEvent(eventId))
assertTrue(banStore.listBannedEvents().isEmpty())
}
@Test
fun allowKindAndDisallowKind() =
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(Nip86Request.allowKind(1))
server.dispatch(Nip86Request.allowKind(7))
server.dispatch(Nip86Request.disallowKind(4))
val list = server.dispatch(Nip86Request.listAllowedKinds())
val ints = (list.result as JsonArray).map { it.jsonPrimitive.int }
assertEquals(listOf(1, 7), ints)
assertTrue(banStore.isKindAllowed(1))
assertTrue(banStore.isKindAllowed(7))
assertEquals(false, banStore.isKindAllowed(4))
assertEquals(false, banStore.isKindAllowed(99))
}
@Test
fun changeRelayNameDescriptionIconRewriteInfoDoc() =
runBlocking {
val (server, _, holder) = fixture()
assertEquals("before", holder.current.document.name)
server.dispatch(Nip86Request.changeRelayName("after"))
assertEquals("after", holder.current.document.name)
server.dispatch(Nip86Request.changeRelayDescription("nice relay"))
assertEquals("nice relay", holder.current.document.description)
server.dispatch(Nip86Request.changeRelayIcon("https://x/icon.png"))
assertEquals("https://x/icon.png", holder.current.document.icon)
}
@Test
fun unsupportedMethodReturnsError() =
runBlocking {
val (server, _, _) = fixture()
val resp = server.dispatch(Nip86Request(method = "frobnicate"))
assertNotNull(resp.error)
assertTrue(resp.error!!.contains("frobnicate"))
}
@Test
fun missingParamsAreReportedAsErrors() =
runBlocking {
val (server, _, _) = fixture()
// banpubkey requires at least one positional param.
val resp = server.dispatch(Nip86Request(method = Nip86Method.BAN_PUBKEY))
assertNotNull(resp.error)
assertTrue(resp.error!!.startsWith("invalid params"))
}
}
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.admin
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class Nip98AuthVerifierTest {
private val verifier = Nip98AuthVerifier(now = { 1_000L })
private fun signedToken(
url: String,
method: String,
body: ByteArray? = null,
signer: NostrSignerSync = NostrSignerSync(KeyPair()),
createdAt: Long = 1_000L,
): Pair<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() {
val r = verifier.verify(null, "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Missing>(r)
}
@Test
fun wrongSchemeIsMalformed() {
val r = verifier.verify("Bearer abc", "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("Nostr"))
}
@Test
fun urlMismatchIsMalformed() {
val (_, header) = signedToken("http://x/", "POST")
val r = verifier.verify(header, "POST", "http://y/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("url mismatch"))
}
@Test
fun methodMismatchIsMalformed() {
val (_, header) = signedToken("http://x/", "POST")
val r = verifier.verify(header, "GET", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("method mismatch"))
}
@Test
fun payloadHashMismatchIsMalformed() {
val (_, header) = signedToken("http://x/", "POST", "alpha".encodeToByteArray())
val r = verifier.verify(header, "POST", "http://x/", "beta".encodeToByteArray())
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("payload hash"))
}
@Test
fun staleCreatedAtIsMalformed() {
// Verifier's clock is fixed at 1_000; sign a token created 5
// minutes earlier — outside the 60s tolerance.
val (_, header) = signedToken("http://x/", "POST", createdAt = 1_000L - 600)
val r = verifier.verify(header, "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("created_at"))
}
@Test
fun nonAuthEventKindIsMalformed() {
// Build a kind-1 event by hand and shove it into the header — it
// must be rejected because NIP-98 specifically uses kind 27235.
val signer = NostrSignerSync(KeyPair())
val template =
com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
.build("not an auth event")
val signed = signer.sign(template)
val token =
"Nostr " +
kotlin.io.encoding.Base64
.encode(signed.toJson().encodeToByteArray())
val r = verifier.verify(token, "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("kind"))
}
}