refactor(quartz): canonical NIP-86 HTTP flow in Nip86HttpHandler

Move the NIP-86-over-HTTP orchestration (NIP-98 verify → admin
allow-list gate → parse → dispatch → serialize) into quartz so any
relay implementation that wants the standard transport can plug its
HTTP framework in without re-deriving the sequence — and without
accidentally skipping NIP-98 verification or the admin check.

  • Nip86Server (quartz) gains allowList + isEnabled() + isAuthorized();
    dispatch becomes dispatch(pubkey, req) and enforces the allow-list
    itself, so in-process callers can't bypass it either.
  • Nip86HttpHandler (new, quartz) takes only primitives (authHeader,
    url, body) and returns a sealed Response — Disabled, PayloadTooLarge,
    MissingAuth, BadAuth, NotAdmin, BadRequest, Ok(pubkey, req, resp,
    json). Constants for the 1 MiB body cap, application/nostr+json+rpc
    content type, and WWW-Authenticate scheme live here.
  • Nip86HttpRoute (geode) shrinks to a Ktor adapter: bounded read,
    extract URL/auth, call handler, map Response → Ktor status codes.
    Audit logging stays in geode — not a quartz concern.

External relay authors: import quartz, build a Nip86HttpHandler with
their Nip86Server + Nip98AuthVerifier, and write a ~30-line adapter
for their framework. The full security-critical flow is in the box.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-12 17:38:40 -04:00
parent e4e9ae7043
commit a53b3339b3
6 changed files with 502 additions and 144 deletions
@@ -27,8 +27,8 @@ 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.Nip86HttpHandler
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
import io.ktor.http.HttpHeaders
import io.ktor.server.application.install
import io.ktor.server.application.serverConfig
@@ -108,9 +108,7 @@ class KtorRelay(
object : Nip86Server.InfoHolder {
override fun get(): Nip11RelayInformation = relay.info.document
override fun set(info: Nip11RelayInformation) {
relay.updateInfo { info }
}
override fun set(info: Nip11RelayInformation) = relay.updateInfo { info }
}
private val nip86Server =
@@ -118,19 +116,12 @@ class KtorRelay(
banStore = relay.banStore,
infoHolder = infoHolder,
onBan = { filter -> relay.store.delete(filter) },
allowList = adminPubkeys,
)
private val nip86Route =
Nip86HttpRoute(
server = nip86Server,
verifier = Nip98AuthVerifier(),
allowList = adminPubkeys.mapTo(HashSet()) { it.lowercase() },
// 1 MiB. Bounded *before* NIP-98 auth verification — we
// have to read the body to compute its sha256 for the
// payload binding, so an unbounded read would let an
// unauthenticated attacker stream gigabytes and OOM the
// relay. NIP-86 RPC payloads are a few hundred bytes;
// 1 MiB is ~1000× any plausible request.
maxBodyBytes = 1 shl 20,
handler = Nip86HttpHandler(server = nip86Server),
signedUrlFor = { call ->
publicUrl ?: ("http://" + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path)
},
@@ -20,12 +20,7 @@
*/
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 com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
@@ -36,90 +31,119 @@ 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.
* Ktor adapter for the canonical NIP-86 HTTP flow encapsulated by
* [Nip86HttpHandler]. This class is intentionally thin: it pulls the
* Authorization header, signed URL, and body bytes out of the
* [ApplicationCall], hands them to the handler, then maps each
* [Nip86HttpHandler.Response] variant to the Ktor status / header /
* body it expects.
*
* 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.
* The bounded body read happens here (Ktor exposes `ByteReadChannel`,
* which is framework-specific) — we stop reading as soon as we'd
* exceed [Nip86HttpHandler.maxBodyBytes] so an unauthenticated
* attacker can't OOM the relay with a giant stream.
*
* Audit logging also lives here, off [Nip86HttpHandler.Response.Ok] —
* the handler keeps logging policy out of quartz; the geode adapter
* picks a stderr line format and runs with it.
*/
internal class Nip86HttpRoute(
private val server: Nip86Server,
private val verifier: Nip98AuthVerifier,
private val allowList: Set<HexKey>,
private val maxBodyBytes: Int,
private val handler: Nip86HttpHandler,
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 // 413 already sent
val authHeader = call.request.header(HttpHeaders.Authorization)
val url = signedUrlFor(call)
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) {
when (val r = handler.handle(authHeader, url, body)) {
Nip86HttpHandler.Response.Disabled -> {
call.respondText(
"invalid Nip86Request: ${e.message ?: e::class.simpleName}",
"NIP-86 management API is not enabled on this relay.",
ContentType.Text.Plain,
HttpStatusCode.Forbidden,
)
}
is Nip86HttpHandler.Response.PayloadTooLarge -> {
call.respondText(
"request body exceeds ${r.cap}-byte cap",
ContentType.Text.Plain,
HttpStatusCode.PayloadTooLarge,
)
}
Nip86HttpHandler.Response.MissingAuth -> {
call.response.headers.append(HttpHeaders.WWWAuthenticate, Nip86HttpHandler.WWW_AUTHENTICATE)
call.respondText(
"missing Authorization header (NIP-98)",
ContentType.Text.Plain,
HttpStatusCode.Unauthorized,
)
}
is Nip86HttpHandler.Response.BadAuth -> {
call.respondText(
"invalid NIP-98 Authorization: ${r.reason}",
ContentType.Text.Plain,
HttpStatusCode.Unauthorized,
)
}
Nip86HttpHandler.Response.NotAdmin -> {
call.respondText(
"pubkey is not on the admin list",
ContentType.Text.Plain,
HttpStatusCode.Forbidden,
)
}
is Nip86HttpHandler.Response.BadRequest -> {
call.respondText(
r.reason,
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,
)
is Nip86HttpHandler.Response.Ok -> {
audit(r)
call.respondText(
r.json,
ContentType.parse(Nip86HttpHandler.CONTENT_TYPE),
HttpStatusCode.OK,
)
}
}
}
/**
* Bounded read using `handler.maxBodyBytes`. Returns null after
* sending a 413 if the request body exceeds the cap — either the
* declared `Content-Length` or what we actually pull off the wire.
*/
private suspend fun readBoundedBody(call: ApplicationCall): ByteArray? {
val cap = handler.maxBodyBytes
val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull()
if (declared != null && declared > maxBodyBytes) {
if (declared != null && declared > cap) {
call.respondText(
"request body exceeds $maxBodyBytes-byte cap",
"request body exceeds $cap-byte cap",
ContentType.Text.Plain,
HttpStatusCode.PayloadTooLarge,
)
return null
}
val ch = call.receiveChannel()
val buf = ByteArray(maxBodyBytes + 1)
val buf = ByteArray(cap + 1)
var pos = 0
while (pos <= maxBodyBytes) {
while (pos <= cap) {
val read = ch.readAvailable(buf, pos, buf.size - pos)
if (read <= 0) break
pos += read
}
if (pos > maxBodyBytes) {
if (pos > cap) {
call.respondText(
"request body exceeds $maxBodyBytes-byte cap",
"request body exceeds $cap-byte cap",
ContentType.Text.Plain,
HttpStatusCode.PayloadTooLarge,
)
@@ -128,53 +152,16 @@ internal class Nip86HttpRoute(
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.
* Single-line stderr audit. Operators grep on "nip86" / pubkey /
* method without needing a logging framework. Best-effort — a
* failed log line must not fail the response.
*/
private fun audit(
pubkey: HexKey,
req: Nip86Request,
response: Nip86Response,
) {
private fun audit(ok: Nip86HttpHandler.Response.Ok) {
runCatching {
System.err.println(
"nip86 audit pubkey=$pubkey method=${req.method} ok=${response.error == null}" +
(response.error?.let { " error=$it" } ?: ""),
"nip86 audit pubkey=${ok.pubkey} method=${ok.request.method} ok=${ok.response.error == null}" +
(ok.response.error?.let { " error=$it" } ?: ""),
)
}
}
@@ -0,0 +1,155 @@
/*
* 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 com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
/**
* Canonical NIP-86-over-HTTP entry point. Drives the complete flow
* the spec mandates — POST `application/nostr+json+rpc` with a
* NIP-98 `Authorization: Nostr <base64>` header — so any relay
* implementation can plug its HTTP framework in without re-deriving
* the auth / parse / dispatch / serialize sequence:
*
* 1. **Gate.** If `server.isEnabled()` is false (empty admin list),
* reject with [Response.Disabled] (→ 403, "admin API not
* enabled"). No body read, no signature verify.
* 2. **Size cap.** If `body.size > maxBodyBytes`, reject with
* [Response.PayloadTooLarge]. Adapters MUST also bound the read
* itself — NIP-98 forces us to compute sha256 over the full
* body for signature binding, so an unbounded read is a pre-auth
* OOM vector. This check is defense-in-depth.
* 3. **Verify.** Run [Nip98AuthVerifier.verify] (method = `POST`,
* given [url] and [body]). Missing → [Response.MissingAuth]
* (→ 401 + `WWW-Authenticate: Nostr`). Malformed →
* [Response.BadAuth] (→ 401).
* 4. **Admin check.** If the verified pubkey is not in
* `server.isAuthorized`, reject with [Response.NotAdmin] (→ 403).
* 5. **Parse.** Decode [Nip86Request] from the body bytes. Invalid
* → [Response.BadRequest] (→ 400).
* 6. **Dispatch.** Call `server.dispatch(pubkey, req)` and wrap the
* [Nip86Response] in [Response.Ok], pre-serialized as JSON ready
* to write to the wire with `Content-Type: application/nostr+json+rpc`.
*
* Transport-agnostic: takes raw primitives ([authHeader], [url],
* [body]) and returns a sealed [Response]. The adapter maps each
* variant to its framework's status-code / header API — for Ktor,
* see `geode/server/Nip86HttpRoute`.
*
* @param maxBodyBytes Defense-in-depth cap. NIP-86 RPC payloads are
* a few hundred bytes; the 1 MiB default is ~1000× any plausible
* request, but small enough that an attacker can't OOM the relay
* if the adapter forgets its own bound.
*/
class Nip86HttpHandler(
private val server: Nip86Server,
private val verifier: Nip98AuthVerifier = Nip98AuthVerifier(),
val maxBodyBytes: Int = DEFAULT_MAX_BODY_BYTES,
) {
suspend fun handle(
authHeader: String?,
url: String,
body: ByteArray,
): Response {
if (!server.isEnabled()) return Response.Disabled
if (body.size > maxBodyBytes) return Response.PayloadTooLarge(maxBodyBytes)
val pubkey =
when (val v = verifier.verify(authHeader, method = "POST", url = url, body = body)) {
is Nip98AuthVerifier.Result.Verified -> v.pubkey
Nip98AuthVerifier.Result.Missing -> return Response.MissingAuth
is Nip98AuthVerifier.Result.Malformed -> return Response.BadAuth(v.reason)
}
if (!server.isAuthorized(pubkey)) return Response.NotAdmin
val req =
try {
JsonMapper.fromJson<Nip86Request>(body.decodeToString())
} catch (e: Exception) {
return Response.BadRequest("invalid Nip86Request: ${e.message ?: e::class.simpleName}")
}
val response = server.dispatch(pubkey, req)
return Response.Ok(pubkey, req, response, JsonMapper.toJson(response))
}
/**
* Outcome of one canonical NIP-86 HTTP request. Adapters map each
* variant to a status code + body:
*
* | Variant | HTTP | Notes |
* |---|---|---|
* | [Disabled] | 403 | "admin API not enabled" |
* | [PayloadTooLarge] | 413 | adapter SHOULD bound the read itself |
* | [MissingAuth] | 401 | send `WWW-Authenticate: Nostr` |
* | [BadAuth] | 401 | NIP-98 signature/binding failed |
* | [NotAdmin] | 403 | verified pubkey not on allow-list |
* | [BadRequest] | 400 | body wasn't a valid `Nip86Request` |
* | [Ok] | 200 | `Content-Type: application/nostr+json+rpc`; write [Ok.json] |
*/
sealed interface Response {
data object Disabled : Response
data class PayloadTooLarge(
val cap: Int,
) : Response
data object MissingAuth : Response
data class BadAuth(
val reason: String,
) : Response
data object NotAdmin : Response
data class BadRequest(
val reason: String,
) : Response
/**
* Successful round-trip. Carries the verified [pubkey] and
* decoded [request] alongside the serialized response, so
* adapters can audit / log without re-parsing.
*/
data class Ok(
val pubkey: HexKey,
val request: Nip86Request,
val response: Nip86Response,
val json: String,
) : Response
}
companion object {
/** 1 MiB — see class KDoc on the cap rationale. */
const val DEFAULT_MAX_BODY_BYTES: Int = 1 shl 20
/** `application/nostr+json+rpc` — the wire type for both directions. */
const val CONTENT_TYPE: String = "application/nostr+json+rpc"
/** Value for the `WWW-Authenticate` header on 401 responses. Matches NIP-98. */
const val WWW_AUTHENTICATE: String = "Nostr"
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
@@ -45,12 +46,18 @@ import kotlinx.serialization.json.int
*
* 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 [onBan] hook so each ban method can
* retroactively purge matching events from the relay's event store.
* atomically swap it), an [onBan] hook so each ban method can
* retroactively purge matching events from the relay's event store,
* and the [allowList] of admin pubkeys authorized to call admin RPCs.
*
* 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.
* and in-process tests can build a [Nip86Request] directly. The
* [allowList] check is enforced inside [dispatch] so no caller can
* accidentally bypass it; transports also have [isEnabled] and
* [isAuthorized] to distinguish "endpoint disabled" (empty list, e.g.
* 403 + "not enabled") from "not allowed" (valid token but
* unrecognized pubkey, e.g. 403 + "not on admin list").
*
* [supportedMethods] is the canonical list this server actually
* implements; methods returned outside of it are no-ops and a NIP-86
@@ -83,7 +90,21 @@ class Nip86Server(
* `geode/KtorRelay`) wire it to `store.delete(filter)`.
*/
private val onBan: suspend (Filter) -> Unit = {},
/**
* Pubkeys allowed to invoke admin RPCs. Empty disables the admin
* API entirely — [isEnabled] returns false and [dispatch] rejects
* everything. Compared case-insensitively (lowercased on entry).
*/
allowList: Set<HexKey> = emptySet(),
) {
private val allowList: Set<HexKey> = allowList.mapTo(HashSet()) { it.lowercase() }
/** True when at least one admin pubkey is configured. */
fun isEnabled(): Boolean = allowList.isNotEmpty()
/** True when [pubkey] is on the admin allow-list. Case-insensitive. */
fun isAuthorized(pubkey: HexKey): Boolean = pubkey.lowercase() in allowList
/** Pluggable container so the relay's NIP-11 doc can be swapped at runtime. */
interface InfoHolder {
fun get(): Nip11RelayInformation
@@ -112,11 +133,24 @@ class Nip86Server(
)
/**
* Dispatches a single RPC request. Synchronous-looking but does
* suspend internally for the `banevent` event-store delete path.
* Dispatches a single RPC request from [pubkey] (the caller, as
* authenticated by the transport — NIP-98 over HTTP, NIP-42 over
* WS, or in-process trust).
*
* If [pubkey] is not in [allowList], returns a `not authorized`
* error response without executing anything. Transports that
* surface different HTTP statuses for "disabled" vs "not on list"
* should pre-check via [isEnabled] / [isAuthorized] instead of
* relying on this string.
*/
suspend fun dispatch(req: Nip86Request): Nip86Response =
runCatching {
suspend fun dispatch(
pubkey: HexKey,
req: Nip86Request,
): Nip86Response {
if (!isAuthorized(pubkey)) {
return Nip86Response(error = "pubkey is not on the admin list")
}
return runCatching {
when (req.method) {
Nip86Method.SUPPORTED_METHODS -> {
ok(buildJsonArray { supportedMethods.forEach { add(JsonPrimitive(it)) } })
@@ -207,6 +241,7 @@ class Nip86Server(
if (e is CancellationException) throw e
Nip86Response(error = "internal: ${e.message ?: e::class.simpleName}")
}
}
private inline fun withHex(
req: Nip86Request,
@@ -0,0 +1,155 @@
/*
* 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.JsonMapper
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class Nip86HttpHandlerTest {
private val now = 1_000L
private val verifier = Nip98AuthVerifier(now = { now })
private val adminSigner = NostrSignerSync(KeyPair())
private val intruderSigner = NostrSignerSync(KeyPair())
private val url = "http://relay.example.com/"
private fun handlerWith(adminInList: Boolean = true): Pair<Nip86HttpHandler, Nip86Server> {
val server =
Nip86Server(
banStore = BanStore(),
infoHolder =
object : Nip86Server.InfoHolder {
private var doc = Nip11RelayInformation(name = "fixture")
override fun get() = doc
override fun set(info: Nip11RelayInformation) {
doc = info
}
},
allowList = if (adminInList) setOf(adminSigner.pubKey) else emptySet(),
)
return Nip86HttpHandler(server, verifier) to server
}
private fun signedHeader(
body: ByteArray,
signer: NostrSignerSync = adminSigner,
): String {
val template = HTTPAuthorizationEvent.build(url = url, method = "POST", file = body, createdAt = now)
return signer.sign(template).toAuthToken()
}
private val supportedMethodsBody: ByteArray
get() = JsonMapper.toJson(Nip86Request(method = Nip86Method.SUPPORTED_METHODS)).encodeToByteArray()
@Test
fun disabledWhenAllowListIsEmpty() {
runBlocking {
val (handler, _) = handlerWith(adminInList = false)
val body = supportedMethodsBody
val r = handler.handle(signedHeader(body), url, body)
assertIs<Nip86HttpHandler.Response.Disabled>(r)
}
}
@Test
fun payloadTooLargeBeforeAuthCheck() {
runBlocking {
val (handler, _) = handlerWith()
val oversized = ByteArray(handler.maxBodyBytes + 1)
// No need for a valid signature — size check fires first.
val r = handler.handle("anything", url, oversized)
assertIs<Nip86HttpHandler.Response.PayloadTooLarge>(r)
assertEquals(handler.maxBodyBytes, r.cap)
}
}
@Test
fun missingAuthHeader() {
runBlocking {
val (handler, _) = handlerWith()
val r = handler.handle(authHeader = null, url = url, body = supportedMethodsBody)
assertIs<Nip86HttpHandler.Response.MissingAuth>(r)
}
}
@Test
fun malformedAuthIsBadAuth() {
runBlocking {
val (handler, _) = handlerWith()
val r = handler.handle("Bearer not-a-nostr-token", url, supportedMethodsBody)
assertIs<Nip86HttpHandler.Response.BadAuth>(r)
assertTrue(r.reason.contains("Nostr"))
}
}
@Test
fun verifiedButNotAdminIsRejectedAsNotAdmin() {
runBlocking {
val (handler, _) = handlerWith() // admin is adminSigner only
val body = supportedMethodsBody
val header = signedHeader(body, signer = intruderSigner)
val r = handler.handle(header, url, body)
assertIs<Nip86HttpHandler.Response.NotAdmin>(r)
}
}
@Test
fun verifiedAdminButBadJsonBodyIsBadRequest() {
runBlocking {
val (handler, _) = handlerWith()
val body = "not valid json {".encodeToByteArray()
val header = signedHeader(body)
val r = handler.handle(header, url, body)
assertIs<Nip86HttpHandler.Response.BadRequest>(r)
}
}
@Test
fun verifiedAdminWithValidRequestDispatches() {
runBlocking {
val (handler, _) = handlerWith()
val body = supportedMethodsBody
val header = signedHeader(body)
val r = handler.handle(header, url, body)
val ok = assertIs<Nip86HttpHandler.Response.Ok>(r)
assertEquals(adminSigner.pubKey, ok.pubkey)
assertEquals(Nip86Method.SUPPORTED_METHODS, ok.request.method)
assertNull(ok.response.error)
assertNotNull(ok.response.result)
// Pre-serialized JSON ready to write to the wire.
assertTrue(ok.json.contains(Nip86Method.SUPPORTED_METHODS))
}
}
}
@@ -42,10 +42,13 @@ 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)
val server = Nip86Server(banStore = store, infoHolder = holder, allowList = setOf(admin))
return Triple(server, store, holder)
}
/** Admin pubkey used in all dispatch calls — must match the allow-list above. */
private val admin = "d".repeat(64)
private class Holder(
var current: Nip11RelayInformation,
) : Nip86Server.InfoHolder {
@@ -64,7 +67,7 @@ class Nip86ServerTest {
fun supportedMethodsRoundTrip() {
runBlocking {
val (server, _, _) = fixture()
val resp = server.dispatch(Nip86Request.supportedMethods())
val resp = server.dispatch(admin, Nip86Request.supportedMethods())
assertNull(resp.error)
val arr = resp.result as JsonArray
val names = arr.map { it.jsonPrimitive.content }
@@ -79,11 +82,11 @@ class Nip86ServerTest {
runBlocking {
val (server, banStore, _) = fixture()
val ok = server.dispatch(Nip86Request.banPubkey(pk, "spam"))
val ok = server.dispatch(admin, Nip86Request.banPubkey(pk, "spam"))
assertEquals(true, (ok.result as JsonPrimitive).boolean)
assertTrue(banStore.isBanned(pk))
val list = server.dispatch(Nip86Request.listBannedPubkeys())
val list = server.dispatch(admin, Nip86Request.listBannedPubkeys())
val parsed =
kotlinx.serialization.json.Json
.decodeFromJsonElement(
@@ -94,7 +97,7 @@ class Nip86ServerTest {
assertEquals(pk, parsed[0].pubkey)
assertEquals("spam", parsed[0].reason)
server.dispatch(Nip86Request.unbanPubkey(pk))
server.dispatch(admin, Nip86Request.unbanPubkey(pk))
assertTrue(banStore.listBannedPubkeys().isEmpty())
}
}
@@ -103,11 +106,11 @@ class Nip86ServerTest {
fun allowPubkeyAndListRoundTrip() {
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(Nip86Request.allowPubkey(pk, "trusted"))
server.dispatch(Nip86Request.allowPubkey(pk2))
server.dispatch(admin, Nip86Request.allowPubkey(pk, "trusted"))
server.dispatch(admin, Nip86Request.allowPubkey(pk2))
assertTrue(banStore.hasAllowList())
val resp = server.dispatch(Nip86Request.listAllowedPubkeys())
val resp = server.dispatch(admin, Nip86Request.listAllowedPubkeys())
val list =
kotlinx.serialization.json.Json
.decodeFromJsonElement(
@@ -123,10 +126,10 @@ class Nip86ServerTest {
fun banEventMarksIdAndDeletesFromStoreWhenStorePresent() {
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(Nip86Request.banEvent(eventId, "off-topic"))
server.dispatch(admin, Nip86Request.banEvent(eventId, "off-topic"))
assertTrue(banStore.isBannedEvent(eventId))
val resp = server.dispatch(Nip86Request.listBannedEvents())
val resp = server.dispatch(admin, Nip86Request.listBannedEvents())
val list =
kotlinx.serialization.json.Json
.decodeFromJsonElement(
@@ -138,7 +141,7 @@ class Nip86ServerTest {
assertEquals("off-topic", list[0].reason)
// allowevent (which is "unban") removes the entry.
server.dispatch(Nip86Request.allowEvent(eventId))
server.dispatch(admin, Nip86Request.allowEvent(eventId))
assertTrue(banStore.listBannedEvents().isEmpty())
}
}
@@ -147,11 +150,11 @@ class Nip86ServerTest {
fun allowKindAndDisallowKind() {
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(Nip86Request.allowKind(1))
server.dispatch(Nip86Request.allowKind(7))
server.dispatch(Nip86Request.disallowKind(4))
server.dispatch(admin, Nip86Request.allowKind(1))
server.dispatch(admin, Nip86Request.allowKind(7))
server.dispatch(admin, Nip86Request.disallowKind(4))
val list = server.dispatch(Nip86Request.listAllowedKinds())
val list = server.dispatch(admin, Nip86Request.listAllowedKinds())
val ints = (list.result as JsonArray).map { it.jsonPrimitive.int }
assertEquals(listOf(1, 7), ints)
@@ -168,13 +171,13 @@ class Nip86ServerTest {
val (server, _, holder) = fixture()
assertEquals("before", holder.current.name)
server.dispatch(Nip86Request.changeRelayName("after"))
server.dispatch(admin, Nip86Request.changeRelayName("after"))
assertEquals("after", holder.current.name)
server.dispatch(Nip86Request.changeRelayDescription("nice relay"))
server.dispatch(admin, Nip86Request.changeRelayDescription("nice relay"))
assertEquals("nice relay", holder.current.description)
server.dispatch(Nip86Request.changeRelayIcon("https://x/icon.png"))
server.dispatch(admin, Nip86Request.changeRelayIcon("https://x/icon.png"))
assertEquals("https://x/icon.png", holder.current.icon)
}
}
@@ -183,7 +186,7 @@ class Nip86ServerTest {
fun unsupportedMethodReturnsError() {
runBlocking {
val (server, _, _) = fixture()
val resp = server.dispatch(Nip86Request(method = "frobnicate"))
val resp = server.dispatch(admin, Nip86Request(method = "frobnicate"))
assertNotNull(resp.error)
assertTrue(resp.error!!.contains("frobnicate"))
}
@@ -194,9 +197,41 @@ class Nip86ServerTest {
runBlocking {
val (server, _, _) = fixture()
// banpubkey requires at least one positional param.
val resp = server.dispatch(Nip86Request(method = Nip86Method.BAN_PUBKEY))
val resp = server.dispatch(admin, Nip86Request(method = Nip86Method.BAN_PUBKEY))
assertNotNull(resp.error)
assertTrue(resp.error!!.startsWith("invalid params"))
}
}
@Test
fun dispatchRejectsPubkeyNotOnAllowList() {
runBlocking {
val (server, banStore, _) = fixture()
val intruder = "e".repeat(64)
val resp = server.dispatch(intruder, Nip86Request.banPubkey(pk, "spam"))
assertNotNull(resp.error)
assertTrue(resp.error!!.contains("not on the admin list"))
// And no state was mutated.
assertTrue(!banStore.isBanned(pk))
}
}
@Test
fun emptyAllowListDisablesTheServer() {
val store = BanStore()
val holder = Holder(Nip11RelayInformation(name = "x"))
val server = Nip86Server(banStore = store, infoHolder = holder) // no allowList
assertEquals(false, server.isEnabled())
assertEquals(false, server.isAuthorized(admin))
}
@Test
fun allowListIsCaseInsensitive() {
val store = BanStore()
val holder = Holder(Nip11RelayInformation(name = "x"))
// Configure in upper-case; lookups normalize.
val server = Nip86Server(banStore = store, infoHolder = holder, allowList = setOf(admin.uppercase()))
assertTrue(server.isAuthorized(admin))
assertTrue(server.isAuthorized(admin.uppercase()))
}
}