diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt index 3c54b4c27..06262048e 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt @@ -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 } } } diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt index cdfe9890b..74203f4f3 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt @@ -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 /** diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt index 8e535093d..2c17cfaf9 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Relay.kt @@ -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, ) diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt index f1d631520..0f67e82a0 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/RelayHub.kt @@ -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 diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt deleted file mode 100644 index 8d69582b3..000000000 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/BanStore.kt +++ /dev/null @@ -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() - - /** - * 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() - - /** Event ids the relay refuses to store/replay. */ - private val bannedEventIds = ConcurrentHashMap() - - 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() - - /** Disallowed kinds. Always blocks regardless of [allowedKinds]. */ - private val disallowedKinds = HashSet() - - 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> = 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> = 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> = 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 = synchronized(kindLock) { allowedKinds.sorted() } - - fun listDisallowedKinds(): List = 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>, - allowedPubkeys: List>, - bannedEvents: List>, - allowedKinds: List, - disallowedKinds: List, - ) { - 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() - } -} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/Nip86HttpRoute.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/Nip86HttpRoute.kt index e73d1653e..43f89e837 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/Nip86HttpRoute.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/server/Nip86HttpRoute.kt @@ -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 diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt index 0aabacd55..965964677 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86EndToEndTest.kt @@ -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 diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt index c4fcfe8dd..2bb4c4815 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt @@ -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 diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt index 0c544c7f1..5636e837a 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt @@ -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 diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/InProcessWebSocket.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocket.kt similarity index 81% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/InProcessWebSocket.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocket.kt index 0b50351a3..1c4bb53c3 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/InProcessWebSocket.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocket.kt @@ -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(UNLIMITED) - val s = relay.server.connect { json -> out.onMessage(json) } + val s = server.connect { json -> out.onMessage(json) } scope = newScope incoming = newIncoming diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt index fe7429a43..cd1106436 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt @@ -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() diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/KindAllowDenyPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/KindAllowDenyPolicy.kt similarity index 89% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/KindAllowDenyPolicy.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/KindAllowDenyPolicy.kt index b23978ef9..77331db71 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/KindAllowDenyPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/KindAllowDenyPolicy.kt @@ -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). diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PassThroughPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PassThroughPolicy.kt similarity index 89% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PassThroughPolicy.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PassThroughPolicy.kt index f79d2a85b..09e8d4760 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PassThroughPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PassThroughPolicy.kt @@ -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 = PolicyResult.Accepted(cmd) diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PubkeyAllowDenyPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PubkeyAllowDenyPolicy.kt similarity index 97% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PubkeyAllowDenyPolicy.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PubkeyAllowDenyPolicy.kt index d43ceeb0a..199c86a97 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PubkeyAllowDenyPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PubkeyAllowDenyPolicy.kt @@ -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 diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RejectFutureEventsPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/RejectFutureEventsPolicy.kt similarity index 94% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RejectFutureEventsPolicy.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/RejectFutureEventsPolicy.kt index 30affa837..34f008208 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RejectFutureEventsPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/RejectFutureEventsPolicy.kt @@ -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 diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/DynamicBanPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt similarity index 78% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/DynamicBanPolicy.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt index 9bc1aa489..398f93d7d 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/DynamicBanPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt @@ -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 { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt new file mode 100644 index 000000000..ba38eff15 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt @@ -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 = emptyMap(), + val allowedPubkeys: Map = emptyMap(), + val bannedEventIds: Map = emptyMap(), + val allowedKinds: Set = emptySet(), + val disallowedKinds: Set = 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> = + 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> = + 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> = + 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 = state.load().allowedKinds.sorted() + + fun listDisallowedKinds(): List = 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> = emptyList(), + allowedPubkeys: List> = emptyList(), + bannedEvents: List> = emptyList(), + allowedKinds: List = emptyList(), + disallowedKinds: List = 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(), + ), + ) + } +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt similarity index 92% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt index 7d03babef..7b5734f82 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86Server.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt @@ -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 = @@ -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())) } } diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifier.kt similarity index 85% rename from quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifier.kt index 86b3074c9..b88bcccc6 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifier.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifier.kt @@ -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 ` header. + * Server-side counterpart to [HTTPAuthorizationEvent]. Verifies a + * NIP-98 `Authorization: Nostr ` 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 `. * 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 = object : LinkedHashMap(64, 0.75f, true) { override fun removeEldestEntry(eldest: Map.Entry?): 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 diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/BanStoreTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt similarity index 98% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/BanStoreTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt index 3b85c82aa..66bfdb0f2 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/BanStoreTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt @@ -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 diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86ServerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt similarity index 86% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86ServerTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt index 284470dd2..9911f1355 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip86ServerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt @@ -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 { 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")) } + } } diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifierTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifierTest.kt similarity index 51% rename from quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifierTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifierTest.kt index 50aabd6ca..2323410d5 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/admin/Nip98AuthVerifierTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifierTest.kt @@ -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(r) + runBlocking { + val r = verifier.verify(null, "POST", "http://x/", null) + assertIs(r) + } } @Test fun wrongSchemeIsMalformed() { - val r = verifier.verify("Bearer abc", "POST", "http://x/", null) - assertIs(r) - assertTrue(r.reason.contains("Nostr")) + runBlocking { + val r = verifier.verify("Bearer abc", "POST", "http://x/", null) + assertIs(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(r) - assertTrue(r.reason.contains("url mismatch")) + runBlocking { + val (_, header) = signedToken("http://x/", "POST") + val r = verifier.verify(header, "POST", "http://y/", null) + assertIs(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(r) - assertTrue(r.reason.contains("method mismatch")) + runBlocking { + val (_, header) = signedToken("http://x/", "POST") + val r = verifier.verify(header, "GET", "http://x/", null) + assertIs(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(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(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(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(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(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(r) + assertTrue(r.reason.contains("kind")) + } } }