refactor: promote relay-server toolkit from quartz-relay to quartz

Generalize the operator-agnostic pieces so any embed of the relay
server can use them without depending on quartz-relay (Ktor, TOML,
operator wrapper). quartz-relay shrinks to its actual job: TOML
config, Ktor wiring, persistence sidecar, and the Relay composition.

- Nip98AuthVerifier -> quartz nip98HttpAuth (verify() now suspend,
  uses kotlinx Mutex for KMP). Pairs with HTTPAuthorizationEvent.
- PassThroughPolicy + KindAllowDenyPolicy + PubkeyAllowDenyPolicy +
  RejectFutureEventsPolicy -> quartz nip01Core/relay/server/policies
  alongside the existing EmptyPolicy / VerifyPolicy / FullAuthPolicy.
- Collapse EmptyPolicy into 'object EmptyPolicy : PassThroughPolicy()'
  (PassThroughPolicy is now an open class instead of abstract).
- BanStore -> quartz nip86RelayManagement/server. Reimplemented
  lock-free with kotlin.concurrent.atomics.AtomicReference + immutable
  state snapshots so it works in commonMain.
- DynamicBanPolicy -> renamed to BanListPolicy; lives next to the
  BanStore it consults. The "Dynamic" qualifier was a contrast to
  static policies in quartz-relay; in its new home the name describes
  what it actually does.
- Nip86Server -> quartz nip86RelayManagement/server next to
  Nip86Client. Drops the RelayInfo wrapper indirection: InfoHolder
  now operates on Nip11RelayInformation directly.
- InProcessWebSocket -> quartz nip01Core/relay/server/inprocess.
  Constructor takes NostrServer (was: the operator-side Relay
  wrapper) so any embed can wire the same in-process bridge.
- Move BanStoreTest, Nip86ServerTest, Nip98AuthVerifierTest into
  quartz/jvmAndroidTest. Adjust runBlocking-bodied tests so JUnit 4
  sees Unit returns. PoliciesTest stays in quartz-relay tests because
  it uses module-local SyntheticEvents fixtures.
This commit is contained in:
Claude
2026-05-07 13:02:23 +00:00
parent e78561af67
commit f7d4e33409
22 changed files with 378 additions and 363 deletions
@@ -23,8 +23,9 @@ package com.vitorpamplona.quartz.relay
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.relay.admin.Nip86Server
import com.vitorpamplona.quartz.relay.admin.Nip98AuthVerifier
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
import com.vitorpamplona.quartz.relay.server.Nip86HttpRoute
import com.vitorpamplona.quartz.relay.server.WebSocketSessionPump
import io.ktor.http.ContentType
@@ -47,12 +48,14 @@ import java.util.concurrent.ConcurrentHashMap
/**
* Hosts a [Relay] over a real `ws://` endpoint backed by Ktor + CIO.
*
* Use this when something other than the in-process [InProcessWebSocket] needs
* to talk to the relay — Android instrumented tests, the `cli` tooling,
* external clients, or a standalone "run a Nostr relay" process.
* Use this when something other than the in-process
* [com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket]
* needs to talk to the relay — Android instrumented tests, the `cli`
* tooling, external clients, or a standalone "run a Nostr relay"
* process.
*
* For unit-test wiring inside a single JVM, prefer [RelayHub] +
* [InProcessWebSocket] — same protocol, no socket overhead.
* For unit-test wiring inside a single JVM, prefer [RelayHub] + the
* in-process socket — same protocol, no socket overhead.
*
* Lifecycle:
* ```
@@ -109,10 +112,10 @@ class LocalRelayServer(
) {
private val infoHolder =
object : Nip86Server.InfoHolder {
override fun get() = relay.info
override fun get(): Nip11RelayInformation = relay.info.document
override fun set(info: RelayInfo) {
relay.updateInfo { info.document }
override fun set(info: Nip11RelayInformation) {
relay.updateInfo { info }
}
}
@@ -24,13 +24,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.relay.config.RelayConfig
import com.vitorpamplona.quartz.relay.policies.KindAllowDenyPolicy
import com.vitorpamplona.quartz.relay.policies.PubkeyAllowDenyPolicy
import com.vitorpamplona.quartz.relay.policies.RejectFutureEventsPolicy
import java.io.File
/**
@@ -30,8 +30,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.relay.admin.BanStore
import com.vitorpamplona.quartz.relay.admin.DynamicBanPolicy
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanListPolicy
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore
import com.vitorpamplona.quartz.relay.persistence.BannedEntry
import com.vitorpamplona.quartz.relay.persistence.RelayPersistedState
import com.vitorpamplona.quartz.relay.persistence.RelayStateStore
@@ -49,7 +49,8 @@ import kotlin.coroutines.CoroutineContext
* NIP-45 (COUNT) and NIP-50 (search via the SQLite FTS index).
*
* Two transports:
* - [InProcessWebSocket] / [RelayHub] — no socket, fastest path, ideal
* - [com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket] /
* [RelayHub] — no socket, fastest path, ideal
* for unit tests inside one JVM.
* - [LocalRelayServer] — Ktor `embeddedServer` listening on a real port.
* Use when external clients need to connect (`cli`, instrumented tests,
@@ -91,7 +92,7 @@ class Relay(
stateStore?.load()?.info?.let { RelayInfo(it) } ?: info
private set
/** Mutates the live NIP-11 doc. Called by [admin.Nip86Server]. */
/** Mutates the live NIP-11 doc. Called by [Nip86Server]. */
fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
info = RelayInfo(transform(info.document))
snapshot()
@@ -99,8 +100,8 @@ class Relay(
/**
* Runtime-mutable ban / allow lists. NIP-86 RPC handlers in
* [admin.Nip86Server] mutate this; the policy stack consults it on
* every accept call via [DynamicBanPolicy].
* [Nip86Server] mutate this; the policy stack consults it on
* every accept call via [BanListPolicy].
*/
val banStore: BanStore = BanStore(onMutation = { snapshot() })
@@ -148,13 +149,13 @@ class Relay(
val server =
NostrServer(
store,
// Always prepend a DynamicBanPolicy so NIP-86 admin actions
// Always prepend a BanListPolicy so NIP-86 admin actions
// bite. When the operator-supplied builder returns
// [EmptyPolicy] we use the dynamic policy alone; otherwise
// we stack them so both layers must accept.
policyBuilder = {
val user = policyBuilder()
if (user === EmptyPolicy) DynamicBanPolicy(banStore) else user + DynamicBanPolicy(banStore)
if (user === EmptyPolicy) BanListPolicy(banStore) else user + BanListPolicy(banStore)
},
parentContext,
)
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.relay
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
@@ -72,7 +73,7 @@ class RelayHub(
override fun build(
url: NormalizedRelayUrl,
out: WebSocketListener,
): WebSocket = InProcessWebSocket(getOrCreate(url), out)
): WebSocket = InProcessWebSocket(getOrCreate(url).server, out)
/**
* Idempotent. Sets the closed flag first so concurrent
@@ -1,202 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.admin
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import java.util.concurrent.ConcurrentHashMap
/**
* Mutable, thread-safe runtime state for the NIP-86 management API.
*
* Each entry carries an optional reason string so list-* RPCs can echo
* back why an admin took the action — useful for audit trails.
*
* Today the state is in-memory only; a process restart wipes the bans.
* Wiring a persistent backend is a separate concern (a JSON file
* snapshot on each mutation, or a small SQLite table) and can be
* layered on by replacing this class behind the [DynamicBanPolicy]
* interface.
*/
class BanStore(
/**
* Called after every mutation. The relay uses this to snapshot the
* full state to disk so admin actions survive a restart. `null`
* disables persistence (in-memory only — fine for tests).
*/
private val onMutation: (() -> Unit)? = null,
) {
/**
* Pubkeys whose events the relay rejects. Compared case-insensitive
* (lowercased on insert / lookup) so an admin pasting a hex pubkey
* with mixed case still works. Empty-string value means "no
* reason given" — `ConcurrentHashMap` rejects nulls.
*/
private val bannedPubkeys = ConcurrentHashMap<HexKey, String>()
/**
* Pubkeys explicitly allowed. When non-empty, this acts as a
* whitelist: events from any pubkey not on the list are rejected.
*/
private val allowedPubkeys = ConcurrentHashMap<HexKey, String>()
/** Event ids the relay refuses to store/replay. */
private val bannedEventIds = ConcurrentHashMap<HexKey, String>()
private fun reasonOrEmpty(s: String?): String = s ?: ""
private fun nullIfEmpty(s: String): String? = s.ifEmpty { null }
/**
* Allowed kinds. When non-empty, events whose kind is not in the
* list are rejected. Kind ops mutate two related sets (allow +
* disallow) and need to look symmetric to readers, so we serialise
* all kind reads/writes through [kindLock] rather than rely on
* the per-set thread safety of `ConcurrentHashMap.newKeySet`.
*/
private val allowedKinds = HashSet<Int>()
/** Disallowed kinds. Always blocks regardless of [allowedKinds]. */
private val disallowedKinds = HashSet<Int>()
private val kindLock = Any()
// -- Pubkey ban list -----------------------------------------------------
fun banPubkey(
pubkey: HexKey,
reason: String? = null,
) {
bannedPubkeys[pubkey.lowercase()] = reasonOrEmpty(reason)
fireMutation()
}
fun unbanPubkey(pubkey: HexKey) {
bannedPubkeys.remove(pubkey.lowercase())
fireMutation()
}
fun isBanned(pubkey: HexKey): Boolean = bannedPubkeys.containsKey(pubkey.lowercase())
fun listBannedPubkeys(): List<Pair<HexKey, String?>> = bannedPubkeys.entries.map { it.key to nullIfEmpty(it.value) }
// -- Pubkey allow list ---------------------------------------------------
fun allowPubkey(
pubkey: HexKey,
reason: String? = null,
) {
allowedPubkeys[pubkey.lowercase()] = reasonOrEmpty(reason)
fireMutation()
}
fun unallowPubkey(pubkey: HexKey) {
allowedPubkeys.remove(pubkey.lowercase())
fireMutation()
}
fun isAllowedPubkey(pubkey: HexKey): Boolean = allowedPubkeys.containsKey(pubkey.lowercase())
fun listAllowedPubkeys(): List<Pair<HexKey, String?>> = allowedPubkeys.entries.map { it.key to nullIfEmpty(it.value) }
fun hasAllowList(): Boolean = allowedPubkeys.isNotEmpty()
// -- Event id ban list ---------------------------------------------------
fun banEvent(
eventId: HexKey,
reason: String? = null,
) {
bannedEventIds[eventId.lowercase()] = reasonOrEmpty(reason)
fireMutation()
}
/** Removes an event id from the ban list. Mirrors NIP-86 `allowevent`. */
fun allowEvent(eventId: HexKey) {
bannedEventIds.remove(eventId.lowercase())
fireMutation()
}
fun isBannedEvent(eventId: HexKey): Boolean = bannedEventIds.containsKey(eventId.lowercase())
fun listBannedEvents(): List<Pair<HexKey, String?>> = bannedEventIds.entries.map { it.key to nullIfEmpty(it.value) }
// -- Kind allow / deny --------------------------------------------------
/**
* `allowKind` and `disallowKind` are symmetric: each adds to its
* own set AND removes the kind from the opposite set. Otherwise
* an `allowKind(K)` after a `disallowKind(K)` would leave K in
* both sets and stay blocked, surprising operators.
*/
fun allowKind(kind: Int) {
synchronized(kindLock) {
disallowedKinds.remove(kind)
allowedKinds.add(kind)
}
fireMutation()
}
fun disallowKind(kind: Int) {
synchronized(kindLock) {
allowedKinds.remove(kind)
disallowedKinds.add(kind)
}
fireMutation()
}
fun listAllowedKinds(): List<Int> = synchronized(kindLock) { allowedKinds.sorted() }
fun listDisallowedKinds(): List<Int> = synchronized(kindLock) { disallowedKinds.sorted() }
fun isKindAllowed(kind: Int): Boolean =
synchronized(kindLock) {
if (kind in disallowedKinds) return false
if (allowedKinds.isEmpty()) return true
return kind in allowedKinds
}
/**
* Bulk-load state without firing [onMutation]. Used at startup to
* seed the in-memory state from a persisted snapshot — we don't
* want every individual `put` to trigger another disk write. After
* this call the store behaves exactly as if every entry had been
* mutated through the public API.
*/
internal fun seedFromSnapshot(
bannedPubkeys: List<Pair<HexKey, String?>>,
allowedPubkeys: List<Pair<HexKey, String?>>,
bannedEvents: List<Pair<HexKey, String?>>,
allowedKinds: List<Int>,
disallowedKinds: List<Int>,
) {
bannedPubkeys.forEach { (k, r) -> this.bannedPubkeys[k.lowercase()] = reasonOrEmpty(r) }
allowedPubkeys.forEach { (k, r) -> this.allowedPubkeys[k.lowercase()] = reasonOrEmpty(r) }
bannedEvents.forEach { (k, r) -> this.bannedEventIds[k.lowercase()] = reasonOrEmpty(r) }
synchronized(kindLock) {
this.allowedKinds.addAll(allowedKinds)
this.disallowedKinds.addAll(disallowedKinds)
}
}
private fun fireMutation() {
onMutation?.invoke()
}
}
@@ -24,8 +24,8 @@ 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.relay.admin.Nip86Server
import com.vitorpamplona.quartz.relay.admin.Nip98AuthVerifier
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
@@ -170,7 +170,7 @@ class Nip86EndToEndTest {
// Subsequent EVENT from the banned author is rejected.
val after = nostrClient.publishAndConfirm(targetUser.sign(TextNoteEvent.build("second")), setOf(relayUrl))
assertEquals(false, after, "DynamicBanPolicy must reject events from banned pubkeys")
assertEquals(false, after, "BanListPolicy must reject events from banned pubkeys")
}
@Test
@@ -26,6 +26,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCon
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.relay.RelayHub
@@ -22,6 +22,9 @@ package com.vitorpamplona.quartz.relay.policies
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents
import kotlin.test.Test
import kotlin.test.assertTrue
@@ -18,8 +18,9 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay
package com.vitorpamplona.quartz.nip01Core.relay.server.inprocess
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
@@ -33,21 +34,26 @@ import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.launch
/**
* In-memory implementation of [WebSocket] that talks to a [Relay] without
* touching the network. Each instance opens one [RelaySession] on
* [connect] and routes:
* In-memory implementation of [WebSocket] that talks directly to a
* [NostrServer] without touching the network. Each instance opens one
* [RelaySession] on [connect] and routes:
*
* - Outbound (`send`) server `RelaySession.receive()` via an inbound channel
* drained by a single coroutine, preserving message order per the
* [WebSocketListener] contract.
* - Outbound (`send`) server `RelaySession.receive()` via an inbound
* channel drained by a single coroutine, preserving message order
* per the [WebSocketListener] contract.
* - Server-side `send` callbacks [WebSocketListener.onMessage].
*
* Use this to wire a `NostrClient` to an embedded server in unit tests
* or single-JVM scenarios without paying for a real TCP socket. Because
* it implements [WebSocket], it slots into anywhere a `WebsocketBuilder`
* expects.
*
* Reconnect-after-disconnect is supported: each [connect] creates a
* fresh scope + drain channel so a previous [disconnect] (which
* cancels both) doesn't leave a dead drainer behind.
*/
class InProcessWebSocket(
private val relay: Relay,
private val server: NostrServer,
private val out: WebSocketListener,
) : WebSocket {
private var scope: CoroutineScope? = null
@@ -61,7 +67,7 @@ class InProcessWebSocket(
if (session != null) return
val newScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val newIncoming = Channel<String>(UNLIMITED)
val s = relay.server.connect { json -> out.onMessage(json) }
val s = server.connect { json -> out.onMessage(json) }
scope = newScope
incoming = newIncoming
@@ -20,28 +20,11 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.server.policies
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
/**
* Allows all commands without authentication. This is the default policy.
* Allows all commands without authentication. The default policy.
*
* Singleton form of [PassThroughPolicy] for callers that want a
* shared no-op (saves an allocation and lets the relay shortcut
* `policy === EmptyPolicy` checks when composing stacks).
*/
object EmptyPolicy : IRelayPolicy {
override fun onConnect(send: (Message) -> Unit) { }
override fun accept(cmd: EventCmd) = PolicyResult.Accepted(cmd)
override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd)
override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd)
override fun accept(cmd: AuthCmd) = PolicyResult.Accepted(cmd)
override fun canSendToSession(event: Event) = true
}
object EmptyPolicy : PassThroughPolicy()
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.policies
package com.vitorpamplona.quartz.nip01Core.relay.server.policies
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
@@ -27,10 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
* Operator-controlled kind allow/deny list. Mirrors nostr-rs-relay's
* `[authorization].kind_whitelist` / `kind_blacklist`.
*
* - When [allow] is non-empty, only events whose [Event.kind] is in
* [allow] are accepted; everything else gets `blocked: kind X not allowed`.
* - When [allow] is non-empty, only events whose kind is in [allow]
* are accepted; everything else is rejected.
* - When [deny] is non-empty, events whose kind is in [deny] are
* rejected with `blocked: kind X denied`.
* rejected.
* - Both lists may be empty (no-op pass-through).
* - When both are set, allow is checked first (deny inside allow is
* still denied, matching nostr-rs-relay's precedence).
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.policies
package com.vitorpamplona.quartz.nip01Core.relay.server.policies
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
@@ -33,8 +33,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
* Convenience base that accepts everything by default. Subclasses
* override only the hook(s) they actually enforce so the call sites
* stay readable.
*
* Concrete (not abstract) so [EmptyPolicy] can subclass it as a
* singleton and external code that just wants a no-op policy can
* instantiate this directly.
*/
abstract class PassThroughPolicy : IRelayPolicy {
open class PassThroughPolicy : IRelayPolicy {
override fun onConnect(send: (Message) -> Unit) {}
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> = PolicyResult.Accepted(cmd)
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.policies
package com.vitorpamplona.quartz.nip01Core.relay.server.policies
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.policies
package com.vitorpamplona.quartz.nip01Core.relay.server.policies
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
@@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
* seconds in the future relative to the relay's clock. Mirrors
* nostr-rs-relay's `[options].reject_future_seconds`.
*
* This catches both clock-skew accidents and intentional far-future
* Catches both clock-skew accidents and intentional far-future
* timestamps used to push events to the top of newest-first feeds.
*
* The current time is read from [TimeUtils.now] (epoch seconds), the
@@ -18,26 +18,26 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.admin
package com.vitorpamplona.quartz.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
import com.vitorpamplona.quartz.relay.policies.PassThroughPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy
/**
* Reads the live [BanStore] on every EVENT and rejects events that
* violate any of: banned-event-id, banned-pubkey, missing from a
* non-empty allow list, or kind disallowed / not in the kind allow
* list.
* non-empty pubkey allow list, or kind disallowed / not in the kind
* allow list.
*
* This is the runtime-mutable counterpart of the static
* [com.vitorpamplona.quartz.relay.policies.KindAllowDenyPolicy] +
* [com.vitorpamplona.quartz.relay.policies.PubkeyAllowDenyPolicy]
* both sets compose: the event must clear both layers. NIP-86 admin
* RPC mutations land here; the static policies stay frozen at
* boot-time config values.
* [com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy] +
* [com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy]
* both layers compose: the event must clear all stacked policies.
* NIP-86 admin RPC mutations land in the [BanStore]; the static
* policies stay frozen at boot-time config values.
*/
class DynamicBanPolicy(
class BanListPolicy(
val banStore: BanStore,
) : PassThroughPolicy() {
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
@@ -0,0 +1,187 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Lock-free runtime state for the NIP-86 management API. Holds the
* ban/allow lists that [BanListPolicy] consults on every accept call,
* plus an [onMutation] hook so the relay can persist the latest
* snapshot whenever an admin RPC mutates state.
*
* Each entry carries an optional reason string so list-* RPCs can echo
* back why an admin took the action useful for audit trails.
*
* Persistence is intentionally NOT inside this class; supply
* [onMutation] to flush to disk (or wherever) and use [seedFromSnapshot]
* at boot to load. `null` keeps the store in-memory only.
*
* Concurrency: state is held in a single [AtomicReference] and mutated
* via copy-on-write CAS loops. Reads are wait-free single-load atomic.
* The data structures are tiny (operator-controlled) so the per-write
* map copy is negligible.
*/
@OptIn(ExperimentalAtomicApi::class)
class BanStore(
private val onMutation: (() -> Unit)? = null,
) {
/**
* Single immutable snapshot of all ban/allow state. Combined into
* one object so kind allow/disallow lock-step (allow adds to
* allowedKinds AND removes from disallowedKinds) is naturally
* atomic no possibility of an interleaved reader observing a
* kind in both sets.
*/
private data class State(
val bannedPubkeys: Map<HexKey, String?> = emptyMap(),
val allowedPubkeys: Map<HexKey, String?> = emptyMap(),
val bannedEventIds: Map<HexKey, String?> = emptyMap(),
val allowedKinds: Set<Int> = emptySet(),
val disallowedKinds: Set<Int> = emptySet(),
)
private val state = AtomicReference(State())
private inline fun mutate(transform: (State) -> State) {
while (true) {
val current = state.load()
if (state.compareAndSet(current, transform(current))) break
}
onMutation?.invoke()
}
// -- Pubkey ban list -----------------------------------------------------
fun banPubkey(
pubkey: HexKey,
reason: String? = null,
) = mutate { it.copy(bannedPubkeys = it.bannedPubkeys + (pubkey.lowercase() to reason)) }
fun unbanPubkey(pubkey: HexKey) = mutate { it.copy(bannedPubkeys = it.bannedPubkeys - pubkey.lowercase()) }
fun isBanned(pubkey: HexKey): Boolean = pubkey.lowercase() in state.load().bannedPubkeys
fun listBannedPubkeys(): List<Pair<HexKey, String?>> =
state
.load()
.bannedPubkeys.entries
.map { it.key to it.value }
// -- Pubkey allow list ---------------------------------------------------
fun allowPubkey(
pubkey: HexKey,
reason: String? = null,
) = mutate { it.copy(allowedPubkeys = it.allowedPubkeys + (pubkey.lowercase() to reason)) }
fun unallowPubkey(pubkey: HexKey) = mutate { it.copy(allowedPubkeys = it.allowedPubkeys - pubkey.lowercase()) }
fun isAllowedPubkey(pubkey: HexKey): Boolean = pubkey.lowercase() in state.load().allowedPubkeys
fun listAllowedPubkeys(): List<Pair<HexKey, String?>> =
state
.load()
.allowedPubkeys.entries
.map { it.key to it.value }
fun hasAllowList(): Boolean = state.load().allowedPubkeys.isNotEmpty()
// -- Event id ban list ---------------------------------------------------
fun banEvent(
eventId: HexKey,
reason: String? = null,
) = mutate { it.copy(bannedEventIds = it.bannedEventIds + (eventId.lowercase() to reason)) }
/** Removes an event id from the ban list. Mirrors NIP-86 `allowevent`. */
fun allowEvent(eventId: HexKey) = mutate { it.copy(bannedEventIds = it.bannedEventIds - eventId.lowercase()) }
fun isBannedEvent(eventId: HexKey): Boolean = eventId.lowercase() in state.load().bannedEventIds
fun listBannedEvents(): List<Pair<HexKey, String?>> =
state
.load()
.bannedEventIds.entries
.map { it.key to it.value }
// -- Kind allow / deny --------------------------------------------------
/**
* `allowKind` and `disallowKind` are symmetric: each adds to its
* own set AND removes the kind from the opposite set. Otherwise
* an `allowKind(K)` after a `disallowKind(K)` would leave K in
* both sets and stay blocked, surprising operators.
*/
fun allowKind(kind: Int) =
mutate {
it.copy(
allowedKinds = it.allowedKinds + kind,
disallowedKinds = it.disallowedKinds - kind,
)
}
fun disallowKind(kind: Int) =
mutate {
it.copy(
allowedKinds = it.allowedKinds - kind,
disallowedKinds = it.disallowedKinds + kind,
)
}
fun listAllowedKinds(): List<Int> = state.load().allowedKinds.sorted()
fun listDisallowedKinds(): List<Int> = state.load().disallowedKinds.sorted()
fun isKindAllowed(kind: Int): Boolean {
val s = state.load()
if (kind in s.disallowedKinds) return false
if (s.allowedKinds.isEmpty()) return true
return kind in s.allowedKinds
}
/**
* Bulk-load state without firing [onMutation]. Used at startup to
* seed the in-memory state from a persisted snapshot we don't
* want every individual `put` to trigger another disk write. After
* this call the store behaves exactly as if every entry had been
* mutated through the public API.
*/
fun seedFromSnapshot(
bannedPubkeys: List<Pair<HexKey, String?>> = emptyList(),
allowedPubkeys: List<Pair<HexKey, String?>> = emptyList(),
bannedEvents: List<Pair<HexKey, String?>> = emptyList(),
allowedKinds: List<Int> = emptyList(),
disallowedKinds: List<Int> = emptyList(),
) {
state.store(
State(
bannedPubkeys = bannedPubkeys.associate { (k, r) -> k.lowercase() to r },
allowedPubkeys = allowedPubkeys.associate { (k, r) -> k.lowercase() to r },
bannedEventIds = bannedEvents.associate { (k, r) -> k.lowercase() to r },
allowedKinds = allowedKinds.toSet(),
disallowedKinds = disallowedKinds.toSet(),
),
)
}
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.admin
package com.vitorpamplona.quartz.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
@@ -29,7 +29,6 @@ import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
import com.vitorpamplona.quartz.relay.RelayInfo
import com.vitorpamplona.quartz.utils.Hex
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.KSerializer
@@ -43,14 +42,16 @@ import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.int
/**
* NIP-86 RPC dispatcher. Holds the [BanStore] (mutated by ban/allow
* methods), the live [RelayInfo] handle (mutated by `changerelay*`
* methods, which atomically swap the doc), and the underlying
* [IEventStore] so `banevent` can also delete the offending event.
* Server-side dispatcher for the NIP-86 relay management API.
*
* The dispatcher is transport-agnostic `LocalRelayServer` calls
* [dispatch] from its HTTP route, but the same handler also works for
* in-process tests that build a [Nip86Request] directly.
* Holds the [BanStore] (mutated by ban/allow methods), an [InfoHolder]
* for the live NIP-11 doc (mutated by `changerelay*` methods, which
* atomically swap it), and an optional [IEventStore] so `banevent`
* can also delete the offending event from the store.
*
* Transport-agnostic relay implementations call [dispatch] from
* whatever HTTP route they expose (e.g. POST `application/nostr+json+rpc`),
* and in-process tests can build a [Nip86Request] directly.
*
* [supportedMethods] is the canonical list this server actually
* implements; methods returned outside of it are no-ops and a NIP-86
@@ -70,9 +71,9 @@ class Nip86Server(
) {
/** Pluggable container so the relay's NIP-11 doc can be swapped at runtime. */
interface InfoHolder {
fun get(): RelayInfo
fun get(): Nip11RelayInformation
fun set(info: RelayInfo)
fun set(info: Nip11RelayInformation)
}
val supportedMethods: List<String> =
@@ -225,8 +226,7 @@ class Nip86Server(
}
private fun rewriteInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
val current = infoHolder.get().document
infoHolder.set(RelayInfo(transform(current)))
infoHolder.set(transform(infoHolder.get()))
}
}
@@ -18,33 +18,35 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.admin
package com.vitorpamplona.quartz.nip98HttpAuth
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
/**
* Verifies a NIP-98 `Authorization: Nostr <base64-event>` header.
* Server-side counterpart to [HTTPAuthorizationEvent]. Verifies a
* NIP-98 `Authorization: Nostr <base64-event>` header.
*
* NIP-98 reuses kind 27235 events with `u`, `method`, and (for bodies)
* `payload` tags. The relay must check:
* `payload` tags. Verification checks:
* 1. Header is `Nostr <base64>`.
* 2. Decoded body is a kind-27235 event with a valid Schnorr signature.
* 3. The event's `created_at` is within ±60 s of now (NIP-98 spec).
* 3. The event's `created_at` is within ±[toleranceSeconds] of now.
* 4. The `method` tag matches the HTTP method.
* 5. The `u` tag matches the requested URL.
* 6. If a body is present, the `payload` tag matches `sha256(body)` hex.
*
* Returns the verified pubkey on success; `null` on any failure (the
* caller turns this into a `401 Unauthorized`).
* Returns the verified pubkey on success; a [Result.Malformed] /
* [Result.Missing] otherwise (the caller turns these into 401/403).
*/
class Nip98AuthVerifier(
private val now: () -> Long = { TimeUtils.now() },
@@ -57,16 +59,19 @@ class Nip98AuthVerifier(
* `2 × toleranceSeconds` (twice the accepted window so a token
* can't be reused by an attacker who buffers across the boundary).
*
* `synchronized` access is sufficient the table is small (~hundreds
* of entries at most) and admin RPC traffic is low-rate.
* Guarded by [seenLock] so the eviction sweep + insertion are
* atomic. We use a coroutine [Mutex] so the type works in KMP
* commonMain (no `synchronized` block).
*/
private val seenEventIds: LinkedHashMap<String, Long> =
object : LinkedHashMap<String, Long>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, Long>?): Boolean = size > MAX_REPLAY_ENTRIES
}
private val seenLock = Mutex()
@OptIn(ExperimentalEncodingApi::class)
fun verify(
suspend fun verify(
authorizationHeader: String?,
method: String,
url: String,
@@ -128,8 +133,12 @@ class Nip98AuthVerifier(
// Replay check — done LAST so we don't burn a one-shot id on a
// request that would otherwise have failed signature/url/etc.
val expiry = nowSec + 2 * toleranceSeconds
synchronized(seenEventIds) {
// Evict expired entries while we hold the lock.
seenLock.withLock {
// Evict expired entries while we hold the lock. Insertion
// order (LinkedHashMap default) tracks expiry order
// because every entry's expiry = now + 2·tolerance, so
// the first non-expired entry guarantees no later entry
// is expired either.
val it = seenEventIds.entries.iterator()
while (it.hasNext()) {
if (it.next().value <= nowSec) it.remove() else break
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.admin
package com.vitorpamplona.quartz.nip86RelayManagement.server
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -18,16 +18,14 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.admin
package com.vitorpamplona.quartz.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.relay.RelayInfo
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
@@ -43,18 +41,17 @@ import kotlin.test.assertTrue
class Nip86ServerTest {
private fun fixture(): Triple<Nip86Server, BanStore, Holder> {
val store = BanStore()
val holder =
Holder(RelayInfo(Nip11RelayInformation(name = "before", description = "before-desc")))
val holder = Holder(Nip11RelayInformation(name = "before", description = "before-desc"))
val server = Nip86Server(banStore = store, infoHolder = holder, store = null)
return Triple(server, store, holder)
}
private class Holder(
var current: RelayInfo,
var current: Nip11RelayInformation,
) : Nip86Server.InfoHolder {
override fun get() = current
override fun set(info: RelayInfo) {
override fun set(info: Nip11RelayInformation) {
current = info
}
}
@@ -62,10 +59,9 @@ class Nip86ServerTest {
private val pk = "a".repeat(64)
private val pk2 = "b".repeat(64)
private val eventId = "c".repeat(64)
private val relayUrl = RelayUrlNormalizer.normalize("ws://test/")
@Test
fun supportedMethodsRoundTrip() =
fun supportedMethodsRoundTrip() {
runBlocking {
val (server, _, _) = fixture()
val resp = server.dispatch(Nip86Request.supportedMethods())
@@ -76,9 +72,10 @@ class Nip86ServerTest {
assertTrue(Nip86Method.BAN_PUBKEY in names)
assertTrue(Nip86Method.CHANGE_RELAY_NAME in names)
}
}
@Test
fun banPubkeyMutatesStoreAndListsRoundTripWithReason() =
fun banPubkeyMutatesStoreAndListsRoundTripWithReason() {
runBlocking {
val (server, banStore, _) = fixture()
@@ -100,9 +97,10 @@ class Nip86ServerTest {
server.dispatch(Nip86Request.unbanPubkey(pk))
assertTrue(banStore.listBannedPubkeys().isEmpty())
}
}
@Test
fun allowPubkeyAndListRoundTrip() =
fun allowPubkeyAndListRoundTrip() {
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(Nip86Request.allowPubkey(pk, "trusted"))
@@ -119,9 +117,10 @@ class Nip86ServerTest {
assertEquals(2, list.size)
assertEquals(setOf(pk, pk2), list.map { it.pubkey }.toSet())
}
}
@Test
fun banEventMarksIdAndDeletesFromStoreWhenStorePresent() =
fun banEventMarksIdAndDeletesFromStoreWhenStorePresent() {
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(Nip86Request.banEvent(eventId, "off-topic"))
@@ -142,9 +141,10 @@ class Nip86ServerTest {
server.dispatch(Nip86Request.allowEvent(eventId))
assertTrue(banStore.listBannedEvents().isEmpty())
}
}
@Test
fun allowKindAndDisallowKind() =
fun allowKindAndDisallowKind() {
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(Nip86Request.allowKind(1))
@@ -160,34 +160,37 @@ class Nip86ServerTest {
assertEquals(false, banStore.isKindAllowed(4))
assertEquals(false, banStore.isKindAllowed(99))
}
}
@Test
fun changeRelayNameDescriptionIconRewriteInfoDoc() =
fun changeRelayNameDescriptionIconRewriteInfoDoc() {
runBlocking {
val (server, _, holder) = fixture()
assertEquals("before", holder.current.document.name)
assertEquals("before", holder.current.name)
server.dispatch(Nip86Request.changeRelayName("after"))
assertEquals("after", holder.current.document.name)
assertEquals("after", holder.current.name)
server.dispatch(Nip86Request.changeRelayDescription("nice relay"))
assertEquals("nice relay", holder.current.document.description)
assertEquals("nice relay", holder.current.description)
server.dispatch(Nip86Request.changeRelayIcon("https://x/icon.png"))
assertEquals("https://x/icon.png", holder.current.document.icon)
assertEquals("https://x/icon.png", holder.current.icon)
}
}
@Test
fun unsupportedMethodReturnsError() =
fun unsupportedMethodReturnsError() {
runBlocking {
val (server, _, _) = fixture()
val resp = server.dispatch(Nip86Request(method = "frobnicate"))
assertNotNull(resp.error)
assertTrue(resp.error!!.contains("frobnicate"))
}
}
@Test
fun missingParamsAreReportedAsErrors() =
fun missingParamsAreReportedAsErrors() {
runBlocking {
val (server, _, _) = fixture()
// banpubkey requires at least one positional param.
@@ -195,4 +198,5 @@ class Nip86ServerTest {
assertNotNull(resp.error)
assertTrue(resp.error!!.startsWith("invalid params"))
}
}
}
@@ -18,11 +18,10 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.relay.admin
package com.vitorpamplona.quartz.nip98HttpAuth
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -56,66 +55,80 @@ class Nip98AuthVerifierTest {
@Test
fun missingHeaderReturnsMissing() {
val r = verifier.verify(null, "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Missing>(r)
runBlocking {
val r = verifier.verify(null, "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Missing>(r)
}
}
@Test
fun wrongSchemeIsMalformed() {
val r = verifier.verify("Bearer abc", "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("Nostr"))
runBlocking {
val r = verifier.verify("Bearer abc", "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("Nostr"))
}
}
@Test
fun urlMismatchIsMalformed() {
val (_, header) = signedToken("http://x/", "POST")
val r = verifier.verify(header, "POST", "http://y/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("url mismatch"))
runBlocking {
val (_, header) = signedToken("http://x/", "POST")
val r = verifier.verify(header, "POST", "http://y/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("url mismatch"))
}
}
@Test
fun methodMismatchIsMalformed() {
val (_, header) = signedToken("http://x/", "POST")
val r = verifier.verify(header, "GET", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("method mismatch"))
runBlocking {
val (_, header) = signedToken("http://x/", "POST")
val r = verifier.verify(header, "GET", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("method mismatch"))
}
}
@Test
fun payloadHashMismatchIsMalformed() {
val (_, header) = signedToken("http://x/", "POST", "alpha".encodeToByteArray())
val r = verifier.verify(header, "POST", "http://x/", "beta".encodeToByteArray())
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("payload hash"))
runBlocking {
val (_, header) = signedToken("http://x/", "POST", "alpha".encodeToByteArray())
val r = verifier.verify(header, "POST", "http://x/", "beta".encodeToByteArray())
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("payload hash"))
}
}
@Test
fun staleCreatedAtIsMalformed() {
// Verifier's clock is fixed at 1_000; sign a token created 5
// minutes earlier — outside the 60s tolerance.
val (_, header) = signedToken("http://x/", "POST", createdAt = 1_000L - 600)
val r = verifier.verify(header, "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("created_at"))
runBlocking {
// Verifier's clock is fixed at 1_000; sign a token created 5
// minutes earlier — outside the 60s tolerance.
val (_, header) = signedToken("http://x/", "POST", createdAt = 1_000L - 600)
val r = verifier.verify(header, "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("created_at"))
}
}
@Test
fun nonAuthEventKindIsMalformed() {
// Build a kind-1 event by hand and shove it into the header — it
// must be rejected because NIP-98 specifically uses kind 27235.
val signer = NostrSignerSync(KeyPair())
val template =
com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
.build("not an auth event")
val signed = signer.sign(template)
val token =
"Nostr " +
kotlin.io.encoding.Base64
.encode(signed.toJson().encodeToByteArray())
val r = verifier.verify(token, "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("kind"))
runBlocking {
// Build a kind-1 event by hand and shove it into the header — it
// must be rejected because NIP-98 specifically uses kind 27235.
val signer = NostrSignerSync(KeyPair())
val template =
com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
.build("not an auth event")
val signed = signer.sign(template)
val token =
"Nostr " +
kotlin.io.encoding.Base64
.encode(signed.toJson().encodeToByteArray())
val r = verifier.verify(token, "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("kind"))
}
}
}