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:
@@ -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()
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+15
-9
@@ -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
|
||||
+6
-23
@@ -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()
|
||||
|
||||
+4
-4
@@ -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).
|
||||
+6
-2
@@ -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)
|
||||
+1
-1
@@ -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
|
||||
+2
-2
@@ -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
|
||||
+10
-10
@@ -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> {
|
||||
+187
@@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+13
-13
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
+21
-12
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+24
-20
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-39
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user