refactor: NIP-FE frames in, frames out, on the relay URL

Follows the NIP revision: the body is one REQ, COUNT or EVENT frame as
the websocket carries it, POSTed to the relay's own URL, and the answer
is the session's frames verbatim, the client's subscription id
included.

quartz:
- HttpRelayHandler parses the body to refuse what HTTP does not carry
  and to know how the answer ends, then feeds the text to the session
  as socket text. The body splicing and the subscription-id stripping
  are gone; refusals are the command's own CLOSED / OK false, and
  pre-run refusals (400/413/503) a NOTICE.
- NIP-98: the token is checked once, against the address its `u` names
  (any of the relay's, trailing slash or not), within 60 seconds, and
  may repeat for the same body.
- HttpRelayCommand is the three kinds, their end frames and refusals;
  HttpRelayAnswerReader parses lines with the socket parser;
  HttpRelayClient sends ReqCmd/CountCmd/EventCmd frames.

geode:
- One POST on the relay path: application/nostr+json+rpc goes to
  NIP-86, anything else is a command. One CORS preflight. With [http]
  off, every POST goes to NIP-86 as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RbNrTdV2e7kW5S9tkMoPgh
This commit is contained in:
Claude
2026-09-27 16:51:41 +00:00
parent 5ab25b89d2
commit bb92054f65
13 changed files with 350 additions and 370 deletions
+13 -11
View File
@@ -103,23 +103,25 @@ file for every knob.
### Commands over HTTP (NIP-FE)
Besides the websocket, geode answers one command per HTTP `POST` beside the
relay path, streamed back as NDJSON — no socket, no subscription left open:
Besides the websocket, geode answers one command per HTTP `POST` to the relay
URL: the body is the frame you would send on the socket, and the answer is the
socket's frames as NDJSON, streamed — no socket, no subscription left open:
```bash
curl -N -d '{"kinds":[1],"limit":2}' http://localhost:7447/req
# ["EVENT",{"id":"…","kind":1,…}]
# ["EVENT",{"id":"…","kind":1,…}]
# ["EOSE"]
curl -d '{"kinds":[1]}' http://localhost:7447/count # ["COUNT",{"count":2}]
curl -d @signed-event.json http://localhost:7447/event # ["OK","<id>",true,""]
curl -N -d '["REQ","q",{"kinds":[1],"limit":2}]' http://localhost:7447/
# ["EVENT","q",{"id":"…","kind":1,…}]
# ["EVENT","q",{"id":"…","kind":1,…}]
# ["EOSE","q"]
curl -d '["COUNT","c",{"kinds":[1]}]' http://localhost:7447/ # ["COUNT","c",{"count":2}]
curl -d "[\"EVENT\",$(cat signed-event.json)]" http://localhost:7447/ # ["OK","<id>",true,""]
```
A body that does not end on `EOSE`/`CLOSED` (REQ), `COUNT`/`CLOSED` (COUNT) or
`OK` (EVENT) was cut off. On an AUTH-gated relay the answer is `401` until the
request carries a NIP-98 `Authorization: Nostr …` header whose `payload` is the
body's sha256. Quartz's `HttpRelayClient` does all of this for JVM/Android
clients.
request carries a NIP-98 `Authorization: Nostr …` header whose `u` is the
relay's http URL and whose `payload` is the body's sha256. NIP-86 admin calls
share the URL, told apart by `Content-Type: application/nostr+json+rpc`.
Quartz's `HttpRelayClient` does all of this for JVM/Android clients.
## Verbs
+9 -7
View File
@@ -167,12 +167,14 @@ require_auth = false
# filter = '{"kinds":[0,1,3,7],"#t":["nostr"]}'
[http]
# NIP-FE: relay commands over HTTP. One command per POST to
# <relay path>/req, /count or /event, answered as NDJSON
# (application/x-ndjson) and streamed as it is found; nothing stays open
# afterwards. NIP-98 `Authorization: Nostr ...` headers sign a request
# in, exactly as NIP-42 AUTH would on the socket. On by default; turning
# it off also drops "FE" from the default NIP-11 list.
# NIP-FE: relay commands over HTTP. One REQ, COUNT or EVENT frame per
# POST to the relay URL, exactly as it would go on the websocket,
# answered as NDJSON (application/x-ndjson) in the socket's own frames and
# streamed as it is found; nothing stays open afterwards. NIP-86 calls
# share the URL, told apart by their application/nostr+json+rpc type.
# NIP-98 `Authorization: Nostr ...` headers sign a request in, exactly as
# NIP-42 AUTH would on the socket. On by default; turning it off also
# drops "FE" from the default NIP-11 list.
enabled = true
# Every request is its own connection, so the websocket's
# per-connection limits don't bound HTTP clients. These do: over the
@@ -185,7 +187,7 @@ deadline_seconds = 30
max_body_bytes = 524288
retry_after_seconds = 1
# Other addresses this relay is reachable at: a NIP-98 token may name
# the endpoint under any of them, as well as under [info].relay_url.
# any of them, as well as [info].relay_url.
# alternate_urls = ["ws://youraddress.onion/"]
# Behind a reverse proxy every request comes from the proxy's address.
# List the proxies here and the per-client cap counts the address the
@@ -29,7 +29,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler
import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayCommand
import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler
import io.ktor.server.application.install
import io.ktor.server.application.serverConfig
@@ -83,8 +82,8 @@ class KtorRelay(
/** Ktor CIO call-handling thread count. `null` keeps Ktor's default. */
val callGroupSize: Int? = null,
/**
* NIP-FE: relay commands over HTTP at `<path>/req`, `/count` and `/event`. On by default; null
* turns the endpoints off (and the operator should then drop `FE` from the NIP-11 doc).
* NIP-FE: relay commands POSTed to the relay's URL, beside NIP-86. On by default; null turns them
* off, every POST going to NIP-86 again (and the operator should then drop `FE` from NIP-11).
*/
val httpCommands: HttpCommandSettings? = HttpCommandSettings(),
) {
@@ -116,8 +115,8 @@ class KtorRelay(
/**
* NIP-FE. Each request runs on its own session of the same engine, so the websocket's policies
* and limits apply. A NIP-98 token must name the endpoint under `relay.url` read as http(s), or
* under one of the configured alternate URLs (a .onion).
* and limits apply. A NIP-98 token must name `relay.url` read as http(s), or one of the
* configured alternate URLs (a .onion).
*/
private val nipFERoute =
httpCommands?.let { settings ->
@@ -188,21 +187,18 @@ class KtorRelay(
get(path) {
nip11Route.handle(call)
}
// NIP-86: POST application/nostr+json+rpc with a NIP-98
// signed Authorization header → JSON-RPC dispatch.
// Always mounted; an empty admin allow-list on the engine just means
// every request fails the allow-list check (403).
// Two POSTs share the relay URL, told apart by Content-Type:
// - NIP-86: application/nostr+json+rpc with a NIP-98 signed
// Authorization header → JSON-RPC dispatch. Always mounted; an
// empty admin allow-list just means every call fails it (403).
// - NIP-FE: anything else is one REQ/COUNT/EVENT frame, answered
// as NDJSON in the socket's own frames.
post(path) {
nip86Route.handle(call)
val commands = nipFERoute
if (commands != null && commands.isCommand(call)) commands.handle(call) else nip86Route.handle(call)
}
// NIP-FE: one command per POST, answered as NDJSON. The paths
// hang off the relay's own path, as the NIP-98 `u` does.
nipFERoute?.let { route ->
HttpRelayCommand.entries.forEach { command ->
val endpoint = path.trimEnd('/') + command.path
post(endpoint) { route.handle(call, command) }
options(endpoint) { route.preflight(call) }
}
options(path) { route.preflight(call) }
}
webSocket(path) {
if (shuttingDown) {
@@ -232,9 +232,9 @@ data class StaticConfig(
)
/**
* NIP-FE: relay commands over HTTP — `POST <path>/req`, `/count`, `/event`, one command per
* request, answered as NDJSON. Each request is its own connection, so the concurrency caps here
* stand in for the websocket's per-connection limits.
* NIP-FE: relay commands over HTTP — one REQ, COUNT or EVENT frame POSTed to the relay's URL,
* answered as NDJSON in the socket's own frames. Each request is its own connection, so the
* concurrency caps here stand in for the websocket's per-connection limits.
*/
data class HttpSection(
val enabled: Boolean = true,
@@ -250,7 +250,7 @@ data class StaticConfig(
val retry_after_seconds: Int = 1,
/**
* Other `ws(s)://` URLs this relay is reachable at (a `.onion` beside the clearnet name).
* A NIP-98 token's `u` may name the endpoint under any of them or under `[info].relay_url`.
* A NIP-98 token's `u` may name any of them, read as http(s), or `[info].relay_url`.
*/
val alternate_urls: List<String> = emptyList(),
/**
@@ -260,7 +260,7 @@ data class StaticConfig(
val trusted_proxies: List<String> = emptyList(),
val client_address_header: String = "X-Forwarded-For",
) {
/** The transport settings, or null when the endpoints are off. */
/** The transport settings, or null when commands over HTTP are off. */
fun toSettings(): HttpCommandSettings? =
if (!enabled) {
null
@@ -25,10 +25,10 @@ import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler
import kotlin.time.Duration
/**
* NIP-FE (relay commands over HTTP) as [com.vitorpamplona.geode.KtorRelay] serves it: `POST` to
* `<relay path>/req`, `/count` and `/event`. The engine's policies and limits apply as they do on
* the websocket; these bound what the websocket's per-connection limits cannot, since every request
* is its own connection.
* NIP-FE (relay commands over HTTP) as [com.vitorpamplona.geode.KtorRelay] serves it: a `POST` to
* the relay's URL carrying one REQ, COUNT or EVENT frame. The engine's policies and limits apply as
* they do on the websocket; these bound what the websocket's per-connection limits cannot, since
* every request is its own connection.
*/
data class HttpCommandSettings(
/** Requests running at once across all clients before the rest get 503; 0 is no limit. */
@@ -21,7 +21,7 @@
package com.vitorpamplona.geode.server
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix
import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayCommand
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler
import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler
import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayLines
import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayRequest
@@ -31,6 +31,7 @@ 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.contentType
import io.ktor.server.request.header
import io.ktor.server.response.header
import io.ktor.server.response.respond
@@ -39,7 +40,8 @@ import io.ktor.server.response.respondText
import io.ktor.utils.io.writeStringUtf8
/**
* NIP-FE over Ktor: the host half of [HttpRelayHandler]. It admits the request, reads the body up to
* NIP-FE over Ktor: the host half of [HttpRelayHandler], on POSTs to the relay's URL that are not
* NIP-86 calls (see [isCommand]). It admits the request, reads the body up to
* the cap, and writes the handler's answer as `application/x-ndjson` — one refusal line with its
* status, or a 200 streamed and flushed frame by frame — with the headers the status calls for
* (`WWW-Authenticate` on 401, `Retry-After` on 429 and 503) and the CORS and no-buffering headers
@@ -65,11 +67,18 @@ internal class NipFEHttpRoute(
call.respond(HttpStatusCode.NoContent)
}
/** Answers one [command]. Admission runs first, so a refused request spends no NIP-98 token. */
suspend fun handle(
call: ApplicationCall,
command: HttpRelayCommand,
) {
/**
* Whether a POST to the relay's URL is a NIP-FE command: anything but NIP-86's
* `application/nostr+json+rpc`, since commands need no `Content-Type` at all.
*/
fun isCommand(call: ApplicationCall): Boolean =
!call.request
.contentType()
.withoutParameters()
.match(NIP86)
/** Answers the command in the body. Admission runs first, so a refused request spends no NIP-98 token. */
suspend fun handle(call: ApplicationCall) {
call.response.header(HttpHeaders.AccessControlAllowOrigin, "*")
call.response.header(HttpHeaders.AccessControlExposeHeaders, "${HttpHeaders.WWWAuthenticate}, ${HttpHeaders.RetryAfter}")
call.response.header(HttpHeaders.CacheControl, "no-store")
@@ -83,9 +92,9 @@ internal class NipFEHttpRoute(
?: return@admit respondLine(
call,
HttpRelayStatus.PAYLOAD_TOO_LARGE,
HttpRelayHandler.refusal(MachineReadablePrefix.INVALID.format("the command exceeds $bodyCap bytes")),
HttpRelayHandler.notice(MachineReadablePrefix.INVALID.format("the command exceeds $bodyCap bytes")),
)
handler.handle(HttpRelayRequest(command, call.request.header(HttpHeaders.Authorization), body), Answer(call))
handler.handle(HttpRelayRequest(call.request.header(HttpHeaders.Authorization), body), Answer(call))
}
when (verdict) {
HttpAdmission.Verdict.ADMITTED -> {}
@@ -94,7 +103,7 @@ internal class NipFEHttpRoute(
respondLine(
call,
HttpRelayStatus.TOO_MANY_REQUESTS,
HttpRelayHandler.refusal(MachineReadablePrefix.RATE_LIMITED.format("over ${settings.maxPerClient} requests at once from this client")),
HttpRelayHandler.notice(MachineReadablePrefix.RATE_LIMITED.format("over ${settings.maxPerClient} requests at once from this client")),
)
}
@@ -102,7 +111,7 @@ internal class NipFEHttpRoute(
respondLine(
call,
HttpRelayStatus.UNAVAILABLE,
HttpRelayHandler.refusal(MachineReadablePrefix.RATE_LIMITED.format("the relay is at capacity")),
HttpRelayHandler.notice(MachineReadablePrefix.RATE_LIMITED.format("the relay is at capacity")),
)
}
}
@@ -161,6 +170,7 @@ internal class NipFEHttpRoute(
companion object {
val NDJSON = ContentType("application", "x-ndjson")
private val NIP86 = ContentType.parse(Nip86HttpHandler.CONTENT_TYPE)
const val WWW_AUTHENTICATE = "Nostr"
const val ACCEL_BUFFERING = "X-Accel-Buffering"
const val PREFLIGHT_MAX_AGE_SECONDS = 86_400
@@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
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.normalizer.toHttp
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
@@ -38,7 +39,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayClient
import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayCommand
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
@@ -57,7 +57,7 @@ import kotlin.time.Duration
/**
* NIP-FE end to end: quartz's [HttpRelayClient] and raw OkHttp requests against a real [KtorRelay],
* covering the answer shape, the status table, the headers each status carries, NIP-98 sign-in
* on an AUTH-gated relay, and where the endpoints live.
* on an AUTH-gated relay, and sharing the relay URL with NIP-86.
*/
class NipFEHttpTest {
private val http = OkHttpClient.Builder().build()
@@ -94,13 +94,14 @@ class NipFEHttpTest {
url: String,
body: String,
authorization: String? = null,
contentType: String = "text/plain",
): Response =
http
.newCall(
Request
.Builder()
.url(url)
.post(body.toRequestBody("text/plain".toMediaType()))
.post(body.toRequestBody(contentType.toMediaType()))
.apply { authorization?.let { header("Authorization", it) } }
.build(),
).execute()
@@ -115,7 +116,7 @@ class NipFEHttpTest {
notes.forEach { assertTrue(assertIs<OkMessage>(client().publish(relay, it).last).success) }
val got = mutableListOf<Event>()
val answer = client().req(relay, listOf(Filter(kinds = listOf(TextNoteEvent.KIND))), got::add)
val answer = client().req(relay, listOf(Filter(kinds = listOf(TextNoteEvent.KIND))), onEvent = got::add)
assertEquals(200, answer.status)
assertTrue(answer.complete)
assertIs<EoseMessage>(answer.last)
@@ -123,18 +124,18 @@ class NipFEHttpTest {
}
@Test
fun theWireIsNdjsonWithoutSubscriptionIds() =
fun theWireIsNdjsonInTheSocketsOwnFrames() =
runBlocking {
val relay = start()
val n = note("hello")
client().publish(relay, n)
post(HttpRelayCommand.REQ.url(relay), """{"ids":["${n.id}"]}""").use { response ->
post(relay.toHttp(), """["REQ","q",{"ids":["${n.id}"]}]""").use { response ->
assertEquals(200, response.code)
assertTrue(response.header("Content-Type")!!.startsWith("application/x-ndjson"))
assertEquals("no", response.header("X-Accel-Buffering"))
assertEquals("*", response.header("Access-Control-Allow-Origin"))
val lines = response.lines()
assertEquals(listOf("""["EVENT",${n.toJson()}]""", """["EOSE"]"""), lines)
assertEquals(listOf("""["EVENT","q",${n.toJson()}]""", """["EOSE","q"]"""), lines)
}
}
@@ -158,7 +159,7 @@ class NipFEHttpTest {
assertEquals(200, client().publish(relay, n).status)
val forged = n.toJson().replace("\"once\"", "\"twice\"")
post(HttpRelayCommand.EVENT.url(relay), forged).use { response ->
post(relay.toHttp(), """["EVENT",$forged]""").use { response ->
assertEquals(400, response.code)
val line = response.lines().single()
assertTrue(line.startsWith("""["OK","${n.id}",false,"invalid:"""), line)
@@ -166,15 +167,17 @@ class NipFEHttpTest {
}
@Test
fun aBodyThatIsNotTheCommandIs400AndOneOverTheCapIs413() {
fun aBodyThatIsNotACommandIs400AndOneOverTheCapIs413() {
val relay = start(settings = HttpCommandSettings(maxBodyBytes = 64))
post(HttpRelayCommand.REQ.url(relay), "hello").use { response ->
assertEquals(400, response.code)
assertTrue(response.lines().single().startsWith("""["CLOSED","invalid:"""))
for (body in listOf("hello", """{"kinds":[1]}""", """["CLOSE","q"]""")) {
post(relay.toHttp(), body).use { response ->
assertEquals(400, response.code, body)
assertTrue(response.lines().single().startsWith("""["NOTICE","invalid:"""))
}
}
post(HttpRelayCommand.REQ.url(relay), """{"authors":["${"a".repeat(64)}"]}""").use { response ->
post(relay.toHttp(), """["REQ","q",{"authors":["${"a".repeat(64)}"]}]""").use { response ->
assertEquals(413, response.code)
assertTrue(response.lines().single().startsWith("""["CLOSED","invalid:"""))
assertTrue(response.lines().single().startsWith("""["NOTICE","invalid:"""))
}
}
@@ -189,10 +192,10 @@ class NipFEHttpTest {
}
},
)
post(HttpRelayCommand.REQ.url(relay), "{}").use { response ->
post(relay.toHttp(), """["REQ","q",{}]""").use { response ->
assertEquals(429, response.code)
assertEquals("7", response.header("Retry-After"))
assertEquals("""["CLOSED","rate-limited: slow down"]""", response.lines().single())
assertEquals("""["CLOSED","q","rate-limited: slow down"]""", response.lines().single())
}
}
@@ -202,7 +205,7 @@ class NipFEHttpTest {
val preflight =
Request
.Builder()
.url(HttpRelayCommand.REQ.url(relay))
.url(relay.toHttp())
.method("OPTIONS", null)
.header("Origin", "https://app.example")
.header("Access-Control-Request-Method", "POST")
@@ -220,10 +223,10 @@ class NipFEHttpTest {
fun anAuthGatedRelayAnswers401UntilANip98TokenSignsTheRequestIn() =
runBlocking {
val relay = start(policy = ::SignInPolicy)
post(HttpRelayCommand.REQ.url(relay), "{}").use { response ->
post(relay.toHttp(), """["REQ","q",{}]""").use { response ->
assertEquals(401, response.code)
assertEquals("Nostr", response.header("WWW-Authenticate"))
assertTrue(response.lines().single().startsWith("""["CLOSED","auth-required:"""))
assertTrue(response.lines().single().startsWith("""["CLOSED","q","auth-required:"""))
}
val unsigned = client().req(relay, listOf(Filter(kinds = listOf(1)))) {}
@@ -236,7 +239,7 @@ class NipFEHttpTest {
assertTrue(assertIs<OkMessage>(published.last).success)
val got = mutableListOf<Event>()
val read = HttpRelayClient(http, alice, signFirst = true).req(relay, listOf(Filter(ids = listOf(n.id))), got::add)
val read = HttpRelayClient(http, alice, signFirst = true).req(relay, listOf(Filter(ids = listOf(n.id))), onEvent = got::add)
assertEquals(200, read.status)
assertTrue(read.complete)
assertEquals(listOf(n.id), got.map { it.id })
@@ -246,13 +249,15 @@ class NipFEHttpTest {
fun aTokenForAnotherBodyDoesNotSignIn() =
runBlocking {
val relay = start(policy = ::SignInPolicy)
val url = HttpRelayCommand.REQ.url(relay)
val token = alice.sign(HTTPAuthorizationEvent.build(url, "POST", """{"kinds":[1]}""".encodeToByteArray())).toAuthToken()
post(url, """{"kinds":[0]}""", token).use { response ->
val url = relay.toHttp()
val signed = """["REQ","q",{"kinds":[1]}]"""
val token = alice.sign(HTTPAuthorizationEvent.build(url, "POST", signed.encodeToByteArray())).toAuthToken()
post(url, """["REQ","q",{"kinds":[0]}]""", token).use { response ->
assertEquals(401, response.code)
assertTrue(response.lines().single().contains("payload"))
}
post(url, """{"kinds":[1]}""", token).use { assertEquals(200, it.code) }
post(url, signed, token).use { assertEquals(200, it.code) }
post(url, signed, token).use { assertEquals(200, it.code, "good again for the same body within its window") }
}
@Test
@@ -260,24 +265,36 @@ class NipFEHttpTest {
runBlocking {
val onion = "ws://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion/".normalizeRelayUrl()
val relay = start(policy = ::SignInPolicy, settings = HttpCommandSettings(alternateUrls = listOf(onion)))
val body = """{"kinds":[1]}"""
val token = alice.sign(HTTPAuthorizationEvent.build(HttpRelayCommand.REQ.url(onion), "POST", body.encodeToByteArray())).toAuthToken()
post(HttpRelayCommand.REQ.url(relay), body, token).use { assertEquals(200, it.code) }
val body = """["REQ","q",{"kinds":[1]}]"""
val token = alice.sign(HTTPAuthorizationEvent.build(onion.toHttp(), "POST", body.encodeToByteArray())).toAuthToken()
post(relay.toHttp(), body, token).use { assertEquals(200, it.code) }
}
@Test
fun theEndpointsHangOffTheRelayPath() =
fun commandsGoToTheRelayUrlPathIncluded() =
runBlocking {
val relay = start(path = "/nostr")
assertTrue(HttpRelayCommand.REQ.url(relay).endsWith("/nostr/req"))
assertTrue(relay.toHttp().trimEnd('/').endsWith("/nostr"))
assertTrue(client().req(relay, listOf(Filter(kinds = listOf(1)))) {}.complete)
post(HttpRelayCommand.REQ.url(relay).replace("/nostr/req", "/req"), "{}").use { assertEquals(404, it.code) }
post(relay.toHttp().replace("/nostr", ""), """["REQ","q",{}]""").use { assertEquals(404, it.code) }
}
@Test
fun turnedOffThereAreNoEndpoints() {
fun nip86CallsKeepTheRelayUrlByTheirContentType() {
val relay = start()
post(relay.toHttp(), """{"method":"supportedmethods","params":[]}""", contentType = "application/nostr+json+rpc").use { response ->
assertEquals(401, response.code, "NIP-86 asks for its own NIP-98 token")
assertFalse(response.header("Content-Type")!!.startsWith("application/x-ndjson"))
}
}
@Test
fun turnedOffEveryPostIsNip86Again() {
val relay = start(settings = null)
post(HttpRelayCommand.REQ.url(relay), "{}").use { assertEquals(404, it.code) }
post(relay.toHttp(), """["REQ","q",{}]""").use { response ->
assertFalse(response.header("Content-Type")!!.startsWith("application/x-ndjson"))
assertEquals(401, response.code)
}
}
@Test
@@ -55,9 +55,8 @@ class HttpRelayAnswerReader(
private var broken = false
/**
* The frame on [line], typed with [HttpRelayCommand.SUB_ID] as its subscription id, or null for
* a blank line. A line that does not parse, or anything after the answer ended, marks the answer
* incomplete: the body is not what this NIP says it is.
* The frame on [line], or null for a blank line. A line that does not parse, or anything after
* the answer ended, marks the answer incomplete: the body is not what this NIP says it is.
*/
fun read(line: String): Message? {
if (line.isBlank()) return null
@@ -67,7 +66,7 @@ class HttpRelayAnswerReader(
}
val message =
try {
OptimizedJsonMapper.fromJsonToMessage(withSubId(line.trim()))
OptimizedJsonMapper.fromJsonToMessage(line)
} catch (_: Exception) {
broken = true
return null
@@ -83,18 +82,3 @@ class HttpRelayAnswerReader(
fun answer(retryAfter: String? = null) = HttpRelayAnswer(status, last, complete, retryAfter)
}
/**
* [frame] with the subscription id NIP-FE leaves out put back, so the websocket's parser reads it:
* `["EVENT",{…}]` → `["EVENT","http",{…}]`, `["EOSE"]` → `["EOSE","http"]`. The inverse of
* [withoutSubId]; frames that carry no subscription id pass as they are.
*/
internal fun withSubId(frame: String): String {
if (!frame.startsWith('[')) return frame
var open = 1
while (open < frame.length && frame[open].isWhitespace()) open++
if (open >= frame.length || frame[open] != '"') return frame
val verbEnd = frame.indexOf('"', open + 1)
if (verbEnd < 0 || frame.substring(open + 1, verbEnd) !in SUBSCRIPTION_FRAMES) return frame
return frame.substring(0, verbEnd + 1) + SUB_ID_FIELD + frame.substring(verbEnd + 1)
}
@@ -26,56 +26,22 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
/**
* NIP-FE: the client commands HTTP carries, one path each. A body is the command's arguments
* after its subscription id (a lone object where the command takes one); the answer ends on the
* first frame [ends] accepts.
* NIP-FE: the client commands an HTTP request may carry, each as the frame a client sends on the
* websocket, and the frame that ends each one's answer.
*/
enum class HttpRelayCommand(
val path: String,
) {
REQ("/req"),
COUNT("/count"),
EVENT("/event"),
enum class HttpRelayCommand {
REQ,
COUNT,
EVENT,
;
/**
* The client frame [body] stands for, or null when it plainly is not this command's arguments.
* The body is spliced in as sent and the engine parses the frame, as it parses socket text, so
* any other malformed body is the engine's NOTICE. The verb and subscription id come first and
* the parser reads one value, so nothing a body holds can make it another command.
*/
fun frameOf(body: String): String? {
val text = body.trim()
return when (this) {
REQ, COUNT -> {
val filters =
when {
text.startsWith('{') -> text
text.startsWith('[') && text.endsWith(']') -> text.substring(1, text.length - 1).trim().ifEmpty { return null }
else -> return null
}
"[\"${if (this == REQ) ReqCmd.LABEL else CountCmd.LABEL}\",\"$SUB_ID\",$filters]"
}
EVENT -> {
if (!text.startsWith('{')) return null
"[\"${EventCmd.LABEL}\",$text]"
}
}
}
/** This command's endpoint on [relay]: the relay URL read as http(s), host and path kept, plus [path]. */
fun url(relay: NormalizedRelayUrl): String = relay.toHttp().trimEnd('/') + path
/** Whether [message] is the last frame of this command's answer. */
/** Whether [message] is the last frame of this command's answer. A NOTICE ends any: the command never ran. */
fun ends(message: Message): Boolean =
message is NoticeMessage ||
when (this) {
@@ -85,15 +51,25 @@ enum class HttpRelayCommand(
}
companion object {
/**
* The subscription id every HTTP command runs under inside the engine. NIP-FE answers carry
* none, so [HttpRelayHandler] takes it back out of each frame before it goes out.
*/
const val SUB_ID = "http"
/** The kind of [cmd], or null for one HTTP does not carry (AUTH, CLOSE, NEG-*). */
fun of(cmd: Command): HttpRelayCommand? =
when (cmd) {
is ReqCmd -> REQ
is CountCmd -> COUNT
is EventCmd -> EVENT
else -> null
}
fun forPath(path: String): HttpRelayCommand? = entries.firstOrNull { it.path == path }
/** A REQ or COUNT body: the filters as the array that follows the subscription id. */
fun body(filters: List<Filter>): String = filters.joinToString(",", "[", "]") { it.toJson() }
/** The frame that refuses [cmd] with [reason], as the socket would: CLOSED for a REQ or COUNT, OK false for an EVENT. */
fun refusal(
cmd: Command,
reason: String,
): Message =
when (cmd) {
is EventCmd -> OkMessage(cmd.event.id, false, reason)
is ReqCmd -> ClosedMessage(cmd.subId, reason)
is CountCmd -> ClosedMessage(cmd.queryId, reason)
else -> NoticeMessage(reason)
}
}
}
@@ -21,13 +21,16 @@
package com.vitorpamplona.quartz.nipFERelayOverHttp
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.server.RelayServerBase
import com.vitorpamplona.quartz.nip01Core.relay.server.SessionSink
import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
import com.vitorpamplona.quartz.nip98HttpAuth.tags.UrlTag
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
@@ -36,18 +39,19 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource
/** One NIP-FE request as the handler needs it. The host routes by [HttpRelayCommand.path]. */
/** One NIP-FE request as the handler needs it: a POST to the relay's URL that is not a NIP-86 call. */
class HttpRelayRequest(
val command: HttpRelayCommand,
/** The `Authorization` header as sent, or null. */
val authorization: String?,
/**
* The body. Hosts bound the read at [HttpRelayHandler.maxBodyBytes]: the engine measures a
* frame in characters, and a UTF-8 character takes up to three bytes.
* The body: one client frame. Hosts bound the read at [HttpRelayHandler.maxBodyBytes]: the
* engine measures a frame in characters, and a UTF-8 character takes up to three bytes.
*/
val body: ByteArray,
)
@@ -82,16 +86,17 @@ interface HttpRelayLines {
class HttpRelayReaderStalled : Exception("the client stopped reading the answer")
/**
* NIP-FE: one relay command per HTTP request, run on its own [RelayServerBase] session, so every
* limit and policy the socket applies applies here, and answered with the relay's own frames up to
* the command's answer, without their subscription id. Nothing outlives the request.
* NIP-FE: one relay command per HTTP request. The body is the frame a client would send on the
* websocket; it runs on its own [RelayServerBase] session, fed as socket text, so every limit and
* policy the socket applies applies here; and the answer is the session's frames as the socket
* would carry them, up to the one that ends the command's answer. Nothing outlives the request.
*
* Admission (how many requests a client may run) is the host's: gate before calling [handle], so a
* refused request does not spend a NIP-98 token the handler would have verified.
*/
class HttpRelayHandler(
private val server: RelayServerBase,
/** The prefixes a NIP-98 `u` may carry (the relay's http origin, its .onion), asked per request; never from the request. */
/** The URLs a NIP-98 `u` may name (the relay's http URL, its .onion), asked per request; never from the request. */
private val origins: () -> List<String>,
/** How long one answer may run, first byte to last. [Duration.INFINITE] turns the deadline off. */
private val deadline: Duration = DEFAULT_DEADLINE,
@@ -107,36 +112,35 @@ class HttpRelayHandler(
request: HttpRelayRequest,
response: HttpRelayResponse,
) {
val command = request.command
val max = server.limits?.maxMessageLength
val tooLarge = "invalid: the command exceeds $max characters"
maxBodyBytes?.let { cap ->
if (request.body.size > cap) {
return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, closed("invalid: the command exceeds $max characters"))
}
if (request.body.size > cap) return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, notice(tooLarge))
}
val frame =
command.frameOf(request.body.decodeToString())
?: return response.single(HttpRelayStatus.BAD_REQUEST, closed("invalid: the body is not ${command.name}'s arguments"))
val text = request.body.decodeToString()
// Characters, as the engine's own limit counts them.
if (max != null && frame.length > max) {
return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, closed("invalid: the command exceeds $max characters"))
}
if (max != null && text.length > max) return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, notice(tooLarge))
// Read here as well as in the engine, to refuse the commands HTTP does not carry and to
// know what ends the answer and how to refuse it. The engine still parses the text itself,
// as socket text, so the policies that judge raw frames run.
val cmd =
try {
OptimizedJsonMapper.fromJsonToCommand(text)
} catch (_: Exception) {
null
}
val command =
cmd?.let { HttpRelayCommand.of(it) }
?: return response.single(HttpRelayStatus.BAD_REQUEST, notice("invalid: the body is not one REQ, COUNT or EVENT frame"))
val signedIn =
when (val proof = proofOf(request)) {
is Proof.Anonymous -> {
null
}
is Proof.Signed -> {
proof.pubkey
}
is Proof.Refused -> {
val reason = proof.reason
return response.single(HttpRelayStatus.forReason(reason), closed(reason))
}
is Proof.Anonymous -> null
is Proof.Signed -> proof.pubkey
is Proof.Refused -> return response.single(HttpRelayStatus.forReason(proof.reason), HttpRelayCommand.refusal(cmd, proof.reason).toJson())
}
exchange(frame, command, signedIn, response)
exchange(text, cmd, command, signedIn, response)
}
/** A frame as queued: its wire text, its type when the engine built one, and whether it ends the answer. */
@@ -147,7 +151,8 @@ class HttpRelayHandler(
)
private suspend fun exchange(
frame: String,
text: String,
cmd: Command,
command: HttpRelayCommand,
signedIn: HexKey?,
response: HttpRelayResponse,
@@ -165,23 +170,25 @@ class HttpRelayHandler(
}
}
fun fail(reason: String) = offer(Frame(closed(reason), ClosedMessage(HttpRelayCommand.SUB_ID, reason), last = true))
fun refusal(reason: String) = HttpRelayCommand.refusal(cmd, reason)
fun fail(reason: String) = refusal(reason).let { offer(Frame(it.toJson(), it, last = true)) }
val sink =
object : SessionSink {
override fun message(message: Message) {
// The challenge every connection opens with; this one proves its key by NIP-98 instead.
if (message is AuthMessage) return
offer(Frame(withoutSubId(message.toJson()), message, command.ends(message)))
offer(Frame(message.toJson(), message, command.ends(message)))
}
override fun raw(json: String) = offer(Frame(withoutSubId(json), null, last = false))
override fun raw(json: String) = offer(Frame(json, null, last = false))
}
val session =
launch {
try {
server.serve(sink) { session ->
val refused = signedIn?.let { session.authenticateByTransport(it) }
if (refused != null) fail(refused) else session.receive(frame)
if (refused != null) fail(refused) else session.receive(text)
ended.await()
}
} catch (e: CancellationException) {
@@ -202,7 +209,7 @@ class HttpRelayHandler(
val status = HttpRelayStatus.of(first?.message)
when {
first == null -> {
single(HttpRelayStatus.UNAVAILABLE, closed("error: no answer within $deadline"))
single(HttpRelayStatus.UNAVAILABLE, notice("error: no answer within $deadline"))
}
first.last || status != HttpRelayStatus.OK -> {
@@ -214,8 +221,8 @@ class HttpRelayHandler(
bounded(due) {
when (drain(first, frames, due)) {
Ending.ANSWERED -> {}
Ending.DEADLINE -> line(closed("error: the answer ran past $deadline"))
Ending.CUT -> line(closed("error: slow reader, over $maxQueuedFrames frames waiting"))
Ending.DEADLINE -> line(refusal("error: the answer ran past $deadline").toJson())
Ending.CUT -> line(refusal("error: slow reader, over $maxQueuedFrames frames waiting").toJson())
}
flush()
}
@@ -284,36 +291,46 @@ class HttpRelayHandler(
}
/**
* A NIP-98 header, checked against every address in [origins], so a token signed at the .onion
* verifies there. It must bind the body's hash: it authorizes one command.
* Another scheme (a proxy's Basic, a client's Bearer) is not addressed to the relay and is ignored.
* A NIP-98 header. Its `u` may name any address in [origins], with or without the trailing
* slash, so a token signed at the .onion verifies there; the token is checked once, against the
* address it names. It must bind the body's hash and be within 60 seconds of now; within that
* window it may come again for the same body. Another scheme (a proxy's Basic, a client's
* Bearer) is not addressed to the relay and is ignored.
*/
private suspend fun proofOf(request: HttpRelayRequest): Proof {
val header = request.authorization?.trim().orEmpty()
val scheme = Nip98AuthVerifier.SCHEME
if (!header.regionMatches(0, scheme, 0, scheme.length, ignoreCase = true)) return Proof.Anonymous
val token = scheme + header.substring(scheme.length).trim()
val accepted = origins().map { it.trimEnd('/') + request.command.path }
if (accepted.isEmpty()) return Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("this relay names no url to sign"))
val addresses = origins()
if (addresses.isEmpty()) return Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("this relay names no url to sign"))
// The address the token names, when it is one of ours; otherwise the first, and the
// verifier refuses the mismatch (or whatever else is wrong with the token) itself.
val signed = claimedUrl(token)
val url = signed?.takeIf { u -> addresses.any { it.trimEnd('/') == u.trimEnd('/') } } ?: addresses.first()
// A fresh verifier each time: it remembers the tokens it accepts, and a NIP-FE token is not
// single-use. A request can land on any instance, which no one process's memory can follow,
// and the body's hash already limits a captured token to the command it signs, in its window.
var refusal: Nip98AuthVerifier.Result.Malformed? = null
for (url in accepted) {
when (val r = Nip98AuthVerifier().verify(token, "POST", url, request.body)) {
is Nip98AuthVerifier.Result.Verified -> return Proof.Signed(r.pubkey)
is Nip98AuthVerifier.Result.Missing -> return Proof.Anonymous
is Nip98AuthVerifier.Result.Malformed -> refusal = refusal ?: r
}
// single-use. Every command it can sign is idempotent, so a repeat only repeats a read or
// re-sends an event the relay has, and a client may retry without signing again.
return when (val r = Nip98AuthVerifier(toleranceSeconds = TOKEN_WINDOW_SECONDS).verify(token, "POST", url, request.body)) {
is Nip98AuthVerifier.Result.Verified -> Proof.Signed(r.pubkey)
is Nip98AuthVerifier.Result.Missing -> Proof.Anonymous
is Nip98AuthVerifier.Result.Malformed -> Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("NIP-98 ${r.reason}"))
}
return Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("NIP-98 ${refusal?.reason}"))
}
private fun closed(reason: String) = refusal(reason)
/** The `u` a NIP-98 [token] names, or null when it does not decode; the verifier then says why. */
@OptIn(ExperimentalEncodingApi::class)
private fun claimedUrl(token: String): String? =
try {
val event = OptimizedJsonMapper.fromJson(Base64.decode(token.substring(Nip98AuthVerifier.SCHEME.length)).decodeToString())
event.tags.firstNotNullOfOrNull(UrlTag::parse)
} catch (_: Exception) {
null
}
companion object {
/** A `CLOSED` line with [reason], as NIP-FE sends it: for a host that refuses before the handler runs (413, 429, 503). */
fun refusal(reason: String) = withoutSubId(ClosedMessage(HttpRelayCommand.SUB_ID, reason).toJson())
/** A `NOTICE` line: how a request is refused before its command runs (400, 413, 429, 503), by the handler or its host. */
fun notice(reason: String) = NoticeMessage(reason).toJson()
val DEFAULT_DEADLINE = 30_000.milliseconds
@@ -321,22 +338,8 @@ class HttpRelayHandler(
const val DEFAULT_MAX_QUEUED_FRAMES = 8192
val DEFAULT_TAIL_GRACE = 5_000.milliseconds
/** NIP-FE: a token is good for 60 seconds either side of its `created_at`. */
const val TOKEN_WINDOW_SECONDS = 60L
}
}
/** The frames that carry a subscription id in the engine; NIP-FE sends them without it. */
internal val SUBSCRIPTION_FRAMES = setOf("EVENT", "EOSE", "CLOSED", "COUNT")
internal const val SUB_ID_FIELD = ",\"" + HttpRelayCommand.SUB_ID + "\""
/**
* [frame] as NIP-FE sends it: the engine's frame with its `"http"` subscription id taken out,
* `["EVENT","http",{…}]` → `["EVENT",{…}]`, `["EOSE","http"]` → `["EOSE"]`. Other frames pass as they are.
*/
internal fun withoutSubId(frame: String): String {
if (!frame.startsWith("[\"")) return frame
val verbEnd = frame.indexOf('"', 2)
if (verbEnd < 0 || frame.substring(2, verbEnd) !in SUBSCRIPTION_FRAMES) return frame
if (!frame.startsWith(SUB_ID_FIELD, verbEnd + 1)) return frame
return frame.substring(0, verbEnd + 1) + frame.substring(verbEnd + 1 + SUB_ID_FIELD.length)
}
@@ -25,8 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage
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.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -46,19 +44,9 @@ class HttpRelayAnswerReaderTest {
vararg lines: String,
) = HttpRelayAnswerReader(command, status).also { reader -> lines.forEach { reader.read(it) } }
@Test
fun theSubscriptionIdGoesBackWhereItWasTakenOut() {
for (frame in listOf("""["EVENT","http",$event]""", """["EOSE","http"]""", """["CLOSED","http","error: x"]""", """["COUNT","http",{"count":3}]""")) {
assertEquals(frame, withSubId(withoutSubId(frame)))
}
assertEquals("""["OK","id",true,""]""", withSubId("""["OK","id",true,""]"""))
assertEquals("""["NOTICE","hi"]""", withSubId("""["NOTICE","hi"]"""))
assertEquals("""[ "EOSE","http"]""", withSubId("""[ "EOSE"]"""))
}
@Test
fun aReqThatEndsOnEoseIsComplete() {
val reader = read(HttpRelayCommand.REQ, 200, """["EVENT",$event]""", """["EOSE"]""")
val reader = read(HttpRelayCommand.REQ, 200, """["EVENT","q",$event]""", """["EOSE","q"]""")
assertTrue(reader.complete)
assertIs<EoseMessage>(reader.last)
}
@@ -66,24 +54,24 @@ class HttpRelayAnswerReaderTest {
@Test
fun aReqWithItsTailMissingIsIncomplete() {
val reader = HttpRelayAnswerReader(HttpRelayCommand.REQ, 200)
val message = reader.read("""["EVENT",$event]""")
val message = reader.read("""["EVENT","q",$event]""")
assertIs<EventMessage>(message)
assertEquals("hi", message.event.content)
assertEquals(HttpRelayCommand.SUB_ID, message.subId)
assertEquals("q", message.subId)
assertFalse(reader.complete)
assertFalse(HttpRelayAnswerReader(HttpRelayCommand.REQ, 200).complete, "an empty body is no answer")
}
@Test
fun aClosedEndsAReqAndACountButNotAnEvent() {
assertTrue(read(HttpRelayCommand.REQ, 200, """["EVENT",$event]""", """["CLOSED","error: the answer ran past 30s"]""").complete)
assertTrue(read(HttpRelayCommand.COUNT, 200, """["CLOSED","error: x"]""").complete)
assertFalse(read(HttpRelayCommand.EVENT, 200, """["CLOSED","error: x"]""").complete)
assertTrue(read(HttpRelayCommand.REQ, 200, """["EVENT","q",$event]""", """["CLOSED","q","error: the answer ran past 30s"]""").complete)
assertTrue(read(HttpRelayCommand.COUNT, 200, """["CLOSED","q","error: x"]""").complete)
assertFalse(read(HttpRelayCommand.EVENT, 200, """["CLOSED","q","error: x"]""").complete)
}
@Test
fun aCountAndAnOkAreWholeAnswers() {
val count = read(HttpRelayCommand.COUNT, 200, """["COUNT",{"count":7}]""")
val count = read(HttpRelayCommand.COUNT, 200, """["COUNT","c",{"count":7}]""")
assertTrue(count.complete)
assertEquals(7, assertIs<CountMessage>(count.last).result.count)
val ok = read(HttpRelayCommand.EVENT, 200, """["OK","abc",true,""]""")
@@ -93,35 +81,21 @@ class HttpRelayAnswerReaderTest {
@Test
fun aRefusalIsOneLineWhateverItsFrame() {
val refused = read(HttpRelayCommand.EVENT, 401, """["CLOSED","auth-required: sign in"]""")
val refused = read(HttpRelayCommand.EVENT, 401, """["CLOSED","q","auth-required: sign in"]""")
assertTrue(refused.complete)
assertEquals("auth-required: sign in", assertIs<ClosedMessage>(refused.last).message)
assertFalse(read(HttpRelayCommand.REQ, 403, """["CLOSED","blocked: no"]""", """["EOSE"]""").complete)
assertFalse(read(HttpRelayCommand.REQ, 403, """["CLOSED","q","blocked: no"]""", """["EOSE","q"]""").complete)
}
@Test
fun anythingAfterTheEndOrALineThatIsNoFrameBreaksTheAnswer() {
assertFalse(read(HttpRelayCommand.REQ, 200, """["EOSE"]""", """["EVENT",$event]""").complete)
assertFalse(read(HttpRelayCommand.REQ, 200, """["EVENT",$event]""", """["EOSE""").complete)
assertTrue(read(HttpRelayCommand.REQ, 200, """["EOSE"]""", "", " ").complete, "blank lines are not frames")
assertFalse(read(HttpRelayCommand.REQ, 200, """["EOSE","q"]""", """["EVENT","q",$event]""").complete)
assertFalse(read(HttpRelayCommand.REQ, 200, """["EVENT","q",$event]""", """["EOSE","q""").complete)
assertTrue(read(HttpRelayCommand.REQ, 200, """["EOSE","q"]""", "", " ").complete, "blank lines are not frames")
}
@Test
fun blankLinesAreSkipped() {
assertNull(HttpRelayAnswerReader(HttpRelayCommand.REQ, 200).read(""))
}
@Test
fun theEndpointsHangOffTheRelayUrl() {
assertEquals("https://relay.example/req", HttpRelayCommand.REQ.url(NormalizedRelayUrl("wss://relay.example/")))
assertEquals("http://127.0.0.1:7447/nostr/count", HttpRelayCommand.COUNT.url(NormalizedRelayUrl("ws://127.0.0.1:7447/nostr")))
assertEquals("http://127.0.0.1:7447/nostr/event", HttpRelayCommand.EVENT.url(NormalizedRelayUrl("ws://127.0.0.1:7447/nostr/")))
}
@Test
fun aFilterBodyIsTheArrayAfterTheSubscriptionId() {
val body = HttpRelayCommand.body(listOf(Filter(kinds = listOf(1), limit = 2), Filter(kinds = listOf(0))))
assertEquals("""[{"kinds":[1],"limit":2},{"kinds":[0]}]""", body)
assertEquals("""["REQ","http",{"kinds":[1],"limit":2},{"kinds":[0]}]""", HttpRelayCommand.REQ.frameOf(body))
}
}
@@ -21,10 +21,16 @@
package com.vitorpamplona.quartz.nipFERelayOverHttp
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import kotlinx.coroutines.Dispatchers
@@ -40,8 +46,9 @@ import okhttp3.coroutines.executeAsync
import okio.IOException
/**
* NIP-FE over OkHttp: one relay command per request, its answer read line by line as the relay
* writes it. Nothing stays open after a call returns.
* NIP-FE over OkHttp: one relay command per request, POSTed to the relay's URL as the frame the
* websocket would carry, its answer read line by line as the relay writes it, in the socket's own
* frames. Nothing stays open after a call returns.
*
* With a [signer], a request the relay refuses with 401 goes once more carrying a NIP-98 token for
* its exact body, as a websocket client answers a NIP-42 challenge; without one, the 401 is the
@@ -57,9 +64,10 @@ class HttpRelayClient(
suspend fun req(
relay: NormalizedRelayUrl,
filters: List<Filter>,
subId: String = newSubId(),
onEvent: (Event) -> Unit,
): HttpRelayAnswer =
send(relay, HttpRelayCommand.REQ, HttpRelayCommand.body(filters)) {
send(relay, ReqCmd(subId, filters)) {
if (it is EventMessage) onEvent(it.event)
}
@@ -67,23 +75,24 @@ class HttpRelayClient(
suspend fun count(
relay: NormalizedRelayUrl,
filters: List<Filter>,
): HttpRelayAnswer = send(relay, HttpRelayCommand.COUNT, HttpRelayCommand.body(filters))
queryId: String = newSubId(),
): HttpRelayAnswer = send(relay, CountCmd(queryId, filters))
/** Publishes [event]: [HttpRelayAnswer.last] is its OK, or the refusal. */
suspend fun publish(
relay: NormalizedRelayUrl,
event: Event,
): HttpRelayAnswer = send(relay, HttpRelayCommand.EVENT, event.toJson())
): HttpRelayAnswer = send(relay, EventCmd(event))
/** Posts [body] to [command]'s endpoint on [relay], handing every frame to [onMessage] as it is read. */
/** Posts [cmd] (a REQ, COUNT or EVENT) to [relay], handing every frame to [onMessage] as it is read. */
suspend fun send(
relay: NormalizedRelayUrl,
command: HttpRelayCommand,
body: String,
cmd: Command,
onMessage: (Message) -> Unit = {},
): HttpRelayAnswer {
val url = command.url(relay)
val bytes = body.encodeToByteArray()
val command = requireNotNull(HttpRelayCommand.of(cmd)) { "NIP-FE carries REQ, COUNT and EVENT, not ${cmd.label()}" }
val url = relay.toHttp()
val bytes = cmd.toJson().encodeToByteArray()
if (signer == null || signFirst) return post(command, url, bytes, token(url, bytes), onMessage, retrying = false)
val first = post(command, url, bytes, null, onMessage, retrying = true)
if (first.status != HttpRelayStatus.UNAUTHORIZED) return first
@@ -160,10 +160,15 @@ class HttpRelayHandlerTest {
) = HttpRelayHandler(MemoryRelay(backend, signedInOnly), origins = { listOf(origin) }, deadline = deadline)
private fun HttpRelayHandler.ask(
command: HttpRelayCommand,
body: String,
frame: String,
authorization: String? = null,
) = Recorded().also { runBlocking { handle(HttpRelayRequest(command, authorization, body.encodeToByteArray()), it) } }
) = Recorded().also { runBlocking { handle(HttpRelayRequest(authorization, frame.encodeToByteArray()), it) } }
private fun req(filter: String) = """["REQ","q",$filter]"""
private fun count(filter: String) = """["COUNT","c",$filter]"""
private fun publish(event: Event) = """["EVENT",${event.toJson()}]"""
private fun note(
content: String,
@@ -171,19 +176,19 @@ class HttpRelayHandlerTest {
) = alice.sign<Event>(at, 1, emptyArray(), content)
private fun token(
command: HttpRelayCommand,
body: String,
) = alice.sign(HTTPAuthorizationEvent.build(origin + command.path, "POST", body.encodeToByteArray(), System.currentTimeMillis() / 1000) {}).toAuthToken()
frame: String,
url: String = origin,
) = alice.sign(HTTPAuthorizationEvent.build(url, "POST", frame.encodeToByteArray(), System.currentTimeMillis() / 1000) {}).toAuthToken()
@Test
fun aReqStreamsItsEventsAndEndsOnEose() {
val a = note("a")
val b = note("b")
backend.events += listOf(a, b)
val answer = handler().ask(HttpRelayCommand.REQ, """{"kinds":[1]}""")
val answer = handler().ask(req("""{"kinds":[1]}"""))
assertEquals(200, answer.status)
assertTrue(answer.streamed)
assertEquals("""["EOSE"]""", answer.lines.last())
assertEquals("""["EOSE","q"]""", answer.lines.last())
assertEquals(
setOf(a.id, b.id),
answer.lines
@@ -193,23 +198,31 @@ class HttpRelayHandlerTest {
)
}
@Test
fun framesGoOutAsTheSocketSendsThemWithTheClientsSubscriptionId() {
val found = note("found")
backend.events += found
val answer = handler().ask("""["REQ","mine",{"kinds":[1]}]""")
assertEquals(listOf("""["EVENT","mine",${found.toJson()}]""", """["EOSE","mine"]"""), answer.lines)
}
@Test
fun anEmptyReqIsOneEoseLine() {
val answer = handler().ask(HttpRelayCommand.REQ, """[{"kinds":[30000]}]""")
val answer = handler().ask(req("""{"kinds":[30000]}"""))
assertEquals(200, answer.status)
assertEquals(listOf("""["EOSE"]"""), answer.lines)
assertEquals(listOf("""["EOSE","q"]"""), answer.lines)
}
@Test
fun anEventIsAnsweredByItsOkAndAForgeryIsRefused() {
val posted = note("posted")
val ok = handler().ask(HttpRelayCommand.EVENT, posted.toJson())
val ok = handler().ask(publish(posted))
assertEquals(200, ok.status)
assertEquals(listOf("""["OK","${posted.id}",true,""]"""), ok.lines)
assertTrue(backend.events.any { it.id == posted.id })
val forged = Event(posted.id, posted.pubKey, posted.createdAt, posted.kind, posted.tags, "tampered", posted.sig)
val refused = handler().ask(HttpRelayCommand.EVENT, forged.toJson())
val refused = handler().ask(publish(forged))
assertEquals(400, refused.status, refused.lines.toString())
assertTrue(refused.lines.single().startsWith("""["OK","${forged.id}",false,"""), refused.lines.toString())
}
@@ -217,76 +230,89 @@ class HttpRelayHandlerTest {
@Test
fun aCountIsOneCountLine() {
backend.events += listOf(note("a"), note("b"), note("c"))
val answer = handler().ask(HttpRelayCommand.COUNT, """{"kinds":[1]}""")
val answer = handler().ask(count("""{"kinds":[1]}"""))
assertEquals(200, answer.status)
assertTrue(answer.lines.single().startsWith("""["COUNT",{"count":3"""), answer.lines.toString())
assertTrue(answer.lines.single().startsWith("""["COUNT","c",{"count":3"""), answer.lines.toString())
}
@Test
fun aBodyThatIsNotTheCommandsArgumentsIsA400AndOneOverTheLimitA413() {
// Not the command's shape: refused before any session opens.
for ((command, body) in listOf(
HttpRelayCommand.REQ to "[]",
HttpRelayCommand.EVENT to """[{"id":"x"}]""",
HttpRelayCommand.COUNT to "not json",
)) {
val answer = handler().ask(command, body)
assertEquals(400, answer.status, "$command '$body'")
assertTrue(answer.lines.single().startsWith("""["CLOSED","invalid:"""), answer.lines.toString())
fun aBodyThatIsNotAReqCountOrEventIsA400AndOneOverTheLimitA413() {
for (body in listOf("[]", """{"kinds":[1]}""", "not json", """["CLOSE","q"]""", """["AUTH",{"id":"x"}]""", """["NEG-CLOSE","n"]""")) {
val answer = handler().ask(body)
assertEquals(400, answer.status, body)
assertTrue(answer.lines.single().startsWith("""["NOTICE","invalid:"""), answer.lines.toString())
}
// The right shape with an inside the engine cannot read: its own NOTICE, the command never ran.
val unreadable = handler().ask(HttpRelayCommand.EVENT, """{"id":"not an event"}""")
assertEquals(400, unreadable.status)
assertTrue(unreadable.lines.single().startsWith("""["NOTICE","""), unreadable.lines.toString())
assertEquals(413, handler().ask(HttpRelayCommand.REQ, """{"search":"${"x".repeat(5_000)}"}""").status)
// Under the byte cap, over it once wrapped in its frame: the engine measures the frame.
assertEquals(413, handler().ask(HttpRelayCommand.REQ, """{"search":"${"x".repeat(4_096 - 20)}"}""").status)
// A REQ the engine refuses as a command: its own NOTICE, the command never ran.
val empty = handler().ask("""["REQ","",{"kinds":[1]}]""")
assertEquals(400, empty.status)
assertTrue(empty.lines.single().startsWith("""["NOTICE","""), empty.lines.toString())
// Over the relay's message length, in characters, as the socket measures it.
val big = handler().ask(req("""{"search":"${"x".repeat(4_096)}"}"""))
assertEquals(413, big.status)
assertTrue(big.lines.single().startsWith("""["NOTICE","invalid:"""), big.lines.toString())
}
@Test
fun aNip98SignatureSignsTheSessionInAndAnotherSchemeDoesNot() {
backend.events += note("gated")
val gated = handler(signedInOnly = true)
val body = """{"kinds":[1]}"""
val frame = req("""{"kinds":[1]}""")
val anonymous = gated.ask(HttpRelayCommand.REQ, body)
val anonymous = gated.ask(frame)
assertEquals(401, anonymous.status)
assertTrue(anonymous.lines.single().startsWith("""["CLOSED","auth-required:"""))
assertTrue(anonymous.lines.single().startsWith("""["CLOSED","q","auth-required:"""))
assertEquals(401, gated.ask(HttpRelayCommand.REQ, body, "Basic dXNlcjpwYXNz").status, "Basic is not addressed to the relay")
assertEquals(401, gated.ask(frame, "Basic dXNlcjpwYXNz").status, "Basic is not addressed to the relay")
val signed = gated.ask(HttpRelayCommand.REQ, body, token(HttpRelayCommand.REQ, body))
val signed = gated.ask(frame, token(frame))
assertEquals(200, signed.status, signed.lines.toString())
assertEquals("""["EOSE"]""", signed.lines.last())
assertEquals("""["EOSE","q"]""", signed.lines.last())
}
@Test
fun aTokenSignsOnlyItsBodyAndIsNotSingleUse() {
fun aTokenSignsOnlyItsBodyAndIsGoodAgainWithinItsWindow() {
val h = handler()
val body = """{"kinds":[1]}"""
val signed = token(HttpRelayCommand.REQ, body)
assertEquals(200, h.ask(HttpRelayCommand.REQ, body, signed).status)
assertEquals(200, h.ask(HttpRelayCommand.REQ, body, signed).status, "any instance may answer it, so none remembers it")
val other = h.ask(HttpRelayCommand.REQ, """{"kinds":[0]}""", token(HttpRelayCommand.REQ, body))
val frame = req("""{"kinds":[1]}""")
val signed = token(frame)
assertEquals(200, h.ask(frame, signed).status)
assertEquals(200, h.ask(frame, signed).status, "the same body again only repeats the read")
val other = h.ask(req("""{"kinds":[0]}"""), signed)
assertEquals(401, other.status)
assertTrue("payload" in other.lines.single(), other.lines.toString())
assertTrue(other.lines.single().startsWith("""["CLOSED","q","auth-required:"""), other.lines.toString())
}
@Test
fun aTokenOutsideItsSixtySecondsIsRefused() {
val frame = req("""{"kinds":[1]}""")
val stale = alice.sign(HTTPAuthorizationEvent.build(origin, "POST", frame.encodeToByteArray(), System.currentTimeMillis() / 1000 - 120) {}).toAuthToken()
assertEquals(401, handler().ask(frame, stale).status)
}
@Test
fun anEventRefusedForItsTokenIsAnOkFalse() {
val posted = publish(note("unsigned"))
val answer = handler().ask(posted, token(req("{}")))
assertEquals(401, answer.status)
assertTrue(answer.lines.single().startsWith("""["OK","""), answer.lines.toString())
assertTrue(""",false,"auth-required:""" in answer.lines.single(), answer.lines.toString())
}
@Test
fun noFirstFrameWithinTheDeadlineIsA503() {
val answer = handler(deadline = 300.milliseconds).ask(HttpRelayCommand.REQ, """{"kinds":[$STALLED_KIND]}""")
val answer = handler(deadline = 300.milliseconds).ask(req("""{"kinds":[$STALLED_KIND]}"""))
assertEquals(503, answer.status)
assertTrue(answer.lines.single().startsWith("""["CLOSED","error: no answer"""))
assertTrue(answer.lines.single().startsWith("""["NOTICE","error: no answer"""))
}
@Test
fun aDeadlineMidAnswerEndsOnAClosedLine() {
val found = note("found")
backend.events += found
val answer = handler(deadline = 300.milliseconds).ask(HttpRelayCommand.REQ, """{"kinds":[1,$TRICKLE_KIND]}""")
val answer = handler(deadline = 300.milliseconds).ask(req("""{"kinds":[1,$TRICKLE_KIND]}"""))
assertEquals(200, answer.status)
assertTrue(found.id in answer.lines.first())
assertTrue(answer.lines.last().startsWith("""["CLOSED","error: the answer ran past"""), answer.lines.toString())
assertTrue(answer.lines.last().startsWith("""["CLOSED","q","error: the answer ran past"""), answer.lines.toString())
}
@Test
@@ -308,7 +334,7 @@ class HttpRelayHandlerTest {
}.lines()
}
assertFailsWith<HttpRelayReaderStalled> {
runBlocking { h.handle(HttpRelayRequest(HttpRelayCommand.REQ, null, """{"kinds":[1,$TRICKLE_KIND]}""".encodeToByteArray()), stalled) }
runBlocking { h.handle(HttpRelayRequest(null, req("""{"kinds":[1,$TRICKLE_KIND]}""").encodeToByteArray()), stalled) }
}
}
@@ -322,26 +348,17 @@ class HttpRelayHandlerTest {
session.close()
}
@Test
fun framesCarryNoSubscriptionId() {
val found = note("found")
backend.events += found
val answer = handler().ask(HttpRelayCommand.REQ, """{"kinds":[1]}""")
assertTrue(answer.lines.first().startsWith("""["EVENT",{"""), answer.lines.toString())
assertEquals("""["EOSE"]""", answer.lines.last())
}
@Test
fun aDeeplyNestedBodyIsA400NotAStackOverflow() {
for (body in listOf("""{"a":""".repeat(2_000) + "1" + "}".repeat(2_000), "[".repeat(20_000) + "]".repeat(20_000))) {
val answer = HttpRelayHandler(MemoryRelay(backend, { VerifyPolicy }, limits = null), origins = { listOf(origin) }).ask(HttpRelayCommand.REQ, body)
for (body in listOf(req("""{"a":""".repeat(2_000) + "1" + "}".repeat(2_000)), "[".repeat(20_000) + "]".repeat(20_000))) {
val answer = HttpRelayHandler(MemoryRelay(backend, { VerifyPolicy }, limits = null), origins = { listOf(origin) }).ask(body)
assertEquals(400, answer.status, body.take(20))
}
}
@Test
fun aFullAuthPolicyRefusesTransportSignInUntilItOptsIn() {
val body = """{"kinds":[1]}"""
val frame = req("""{"kinds":[1]}""")
val relayUrl = RelayUrlNormalizer.normalize("wss://relay.example")
val refusing =
MemoryRelay(backend, {
@@ -349,9 +366,9 @@ class HttpRelayHandlerTest {
override suspend fun authorize(event: RelayAuthEvent): Unit = error("backend rejected user")
}
})
val refused = HttpRelayHandler(refusing, origins = { listOf(origin) }).ask(HttpRelayCommand.REQ, body, token(HttpRelayCommand.REQ, body))
val refused = HttpRelayHandler(refusing, origins = { listOf(origin) }).ask(frame, token(frame))
assertEquals(403, refused.status, refused.lines.toString())
assertTrue(refused.lines.single().startsWith("""["CLOSED","restricted:"""), refused.lines.toString())
assertTrue(refused.lines.single().startsWith("""["CLOSED","q","restricted:"""), refused.lines.toString())
val optingIn =
MemoryRelay(backend, {
@@ -359,14 +376,14 @@ class HttpRelayHandlerTest {
override suspend fun authorizeTransport(pubkey: HexKey): String? = null
}
})
val signed = HttpRelayHandler(optingIn, origins = { listOf(origin) }).ask(HttpRelayCommand.REQ, body, token(HttpRelayCommand.REQ, body))
val signed = HttpRelayHandler(optingIn, origins = { listOf(origin) }).ask(frame, token(frame))
assertEquals(200, signed.status, signed.lines.toString())
}
@Test
fun aMessageLimitInThePolicyChainStillRuns() {
val limited = MemoryRelay(backend, { LimitsPolicy(RelayLimits(maxMessageLength = 4096)) + VerifyPolicy }, limits = null)
val answer = HttpRelayHandler(limited, origins = { listOf(origin) }).ask(HttpRelayCommand.REQ, """{"search":"${"x".repeat(20_000)}"}""")
val answer = HttpRelayHandler(limited, origins = { listOf(origin) }).ask(req("""{"search":"${"x".repeat(20_000)}"}"""))
assertEquals(400, answer.status, answer.lines.toString())
assertTrue(answer.lines.single().startsWith("""["NOTICE","invalid: message too large"""), answer.lines.toString())
}
@@ -374,13 +391,12 @@ class HttpRelayHandlerTest {
@Test
fun aMultiByteEventUnderTheCharacterLimitIsAccepted() {
// 1,500 CJK characters: about 4,500 UTF-8 bytes, well under 4,096 characters as the engine counts.
val posted = note("\u4E2D".repeat(1_500))
val answer = handler().ask(HttpRelayCommand.EVENT, posted.toJson())
val answer = handler().ask(publish(note("中".repeat(1_500))))
assertEquals(200, answer.status, answer.lines.toString())
}
@Test
fun aBackendFailureIsA500Line() {
fun aBackendFailureIsA500OkFalse() {
val failing =
object : SessionBackend by backend {
override suspend fun submit(
@@ -388,17 +404,18 @@ class HttpRelayHandlerTest {
onComplete: (IEventStore.InsertOutcome) -> Unit,
): Unit = error("db is down")
}
val answer = HttpRelayHandler(MemoryRelay(failing, { VerifyPolicy }), origins = { listOf(origin) }).ask(HttpRelayCommand.EVENT, note("lost").toJson())
val lost = note("lost")
val answer = HttpRelayHandler(MemoryRelay(failing, { VerifyPolicy }), origins = { listOf(origin) }).ask(publish(lost))
assertEquals(500, answer.status, answer.lines.toString())
assertTrue(answer.lines.single().startsWith("""["CLOSED","error:"""), answer.lines.toString())
assertTrue(answer.lines.single().startsWith("""["OK","${lost.id}",false,"error:"""), answer.lines.toString())
}
@Test
fun anInfiniteDeadlineStillStreams() {
backend.events += note("forever")
val answer = handler(deadline = Duration.INFINITE).ask(HttpRelayCommand.REQ, """{"kinds":[1]}""")
val answer = handler(deadline = Duration.INFINITE).ask(req("""{"kinds":[1]}"""))
assertEquals(200, answer.status)
assertEquals("""["EOSE"]""", answer.lines.last())
assertEquals("""["EOSE","q"]""", answer.lines.last())
}
@Test
@@ -414,37 +431,27 @@ class HttpRelayHandlerTest {
override suspend fun stream(lines: suspend HttpRelayLines.() -> Unit) = error("single")
}
assertFailsWith<HttpRelayReaderStalled> {
runBlocking { h.handle(HttpRelayRequest(HttpRelayCommand.COUNT, null, """{"kinds":[1]}""".encodeToByteArray()), stalled) }
runBlocking { h.handle(HttpRelayRequest(null, count("""{"kinds":[1]}""").encodeToByteArray()), stalled) }
}
}
@Test
fun aBodyIsSplicedIntoItsFrameAsSent() {
assertEquals("""["REQ","http",{"kinds":[1]}]""", HttpRelayCommand.REQ.frameOf(""" {"kinds":[1]} """))
assertEquals("""["COUNT","http",{"a":"]"},{"b":"\"["}]""", HttpRelayCommand.COUNT.frameOf("""[{"a":"]"},{"b":"\"["}]"""))
assertEquals("""["EVENT",{"id":"x"}]""", HttpRelayCommand.EVENT.frameOf("""{"id":"x"}"""))
for (bad in listOf("", "[]", "[ ]", "[{}", "1", "null", "\"x\"")) assertEquals(null, HttpRelayCommand.REQ.frameOf(bad), "REQ '$bad'")
for (bad in listOf("""[{"id":"x"}]""", "1")) assertEquals(null, HttpRelayCommand.EVENT.frameOf(bad), "EVENT '$bad'")
}
@Test
fun aBodyCannotCarryASecondCommand() {
val smuggled = note("smuggled")
val answer = handler().ask(HttpRelayCommand.REQ, """{"kinds":[1]}],["EVENT",${smuggled.toJson()}""")
assertTrue(answer.lines.last().let { it == """["EOSE"]""" || it.startsWith("""["NOTICE",""") }, answer.lines.toString())
val answer = handler().ask(req("""{"kinds":[1]}""") + publish(smuggled))
assertTrue(answer.lines.last().let { it == """["EOSE","q"]""" || it.startsWith("""["NOTICE",""") }, answer.lines.toString())
assertTrue(backend.events.none { it.id == smuggled.id }, "only the REQ ran")
}
@Test
fun aTokenSignedAtAnyOfTheRelaysAddressesVerifies() {
val onion = "http://relayxyz.onion"
val onion = "http://relayxyz.onion/"
val h = HttpRelayHandler(MemoryRelay(backend, true), origins = { listOf(origin, onion) })
val body = """{"kinds":[1]}"""
fun at(base: String) = alice.sign(HTTPAuthorizationEvent.build(base + "/req", "POST", body.encodeToByteArray(), System.currentTimeMillis() / 1000) {}).toAuthToken()
assertEquals(200, h.ask(HttpRelayCommand.REQ, body, at(onion)).status)
assertEquals(200, h.ask(HttpRelayCommand.REQ, body, at(origin)).status)
assertEquals(401, h.ask(HttpRelayCommand.REQ, body, at("https://elsewhere.example")).status)
val frame = req("""{"kinds":[1]}""")
assertEquals(200, h.ask(frame, token(frame, onion)).status)
assertEquals(200, h.ask(frame, token(frame, "http://relayxyz.onion")).status, "with or without the trailing slash")
assertEquals(200, h.ask(frame, token(frame, "$origin/")).status)
assertEquals(401, h.ask(frame, token(frame, "https://elsewhere.example")).status)
}
private companion object {