diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt new file mode 100644 index 0000000000..9a65df954c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/IRelayPolicy.kt @@ -0,0 +1,91 @@ +/* + * 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.nip01Core.relay.server + +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.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +/** + * Defines custom behavior for this relay. + */ +interface IRelayPolicy { + fun onConnect(send: (Message) -> Unit) + + /** + * Evaluates whether an incoming EVENT command should be accepted. + * + * @param cmd The event the client wants to publish. + * @return [PolicyResult.Accepted] to store the event, or [PolicyResult.Rejected] with a reason string. + */ + fun accept(cmd: EventCmd): PolicyResult + + /** + * Evaluates a REQ command, optionally rewriting the filter list. + * + * @param cmd The filters from the REQ command. + * @return [PolicyResult.Accepted] with an optional replacement filter list, or [PolicyResult.Rejected]. + */ + fun accept(cmd: ReqCmd): PolicyResult + + /** + * Evaluates a COUNT command, optionally rewriting the filter list. + * + * @param cmd The filters from the COUNT command. + * @return [PolicyResult.Accepted] to allow counting, or [PolicyResult.Rejected] with a reason. + */ + fun accept(cmd: CountCmd): PolicyResult + + /** + * Evaluates whether an incoming AUTH command should be accepted. + * + * @param cmd The event the client wants to auth. + * @return [PolicyResult.Accepted] to log in, or [PolicyResult.Rejected] with a reason string. + */ + fun accept(cmd: AuthCmd): PolicyResult + + /** + * Filters a live event before it is forwarded to a subscriber. + * + * Called for each event that matches a subscription's filters. Return + * true to deliver the event, false to suppress it for this session. + * + * @param event The event about to be sent. + */ + fun canSendToSession(event: Event): Boolean = true + + operator fun plus(other: IRelayPolicy) = listOf(this, other) +} + +sealed interface PolicyResult { + class Accepted( + val cmd: T, + ) : PolicyResult + + class Rejected( + val reason: String, + ) : PolicyResult +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt index b443d00652..78651df5ef 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt @@ -20,8 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.server -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CoroutineScope @@ -35,17 +34,17 @@ import kotlin.coroutines.CoroutineContext * This class acts as the central coordinator for a relay server, handling the lifecycle of [RelaySession]s * and providing access to the underlying event store. * - * @property store The persistent storage engine for Nostr events. - * @property parentContext The coroutine context used as a base for the server's execution scope. - * @property verify The function used to validate the integrity and signatures of incoming events. + * @param store The [IEventStore] backing this relay. + * @param policyBuilder Controls requirements for relay commands. */ class NostrServer( private val store: IEventStore, + private val policyBuilder: () -> IRelayPolicy = { VerifyPolicy }, private val parentContext: CoroutineContext = SupervisorJob(), - private val verify: (Event) -> Boolean = { it.verify() }, ) { private val subStore = LiveEventStore(store) + /** Scope for all subscriptions. */ private val scope = CoroutineScope(parentContext + SupervisorJob()) /** Active client sessions keyed by an opaque connection id. */ @@ -59,25 +58,22 @@ class NostrServer( */ fun connect(send: (String) -> Unit) = RelaySession( + policy = policyBuilder(), store = subStore, - verify = verify, scope = scope, onSend = send, - onClose = ::disconnect, - ).also { session -> connections.put(session.hashCode(), session) } - - /** - * Removes a client connection and cancels all its subscriptions. - */ - private fun disconnect(session: RelaySession) { - connections.remove(session.hashCode()) - } + onClose = { session -> + connections.remove(session.hashCode()) + }, + ).also { session -> + connections.put(session.hashCode(), session) + } /** * Shuts down the server, cancelling all subscriptions and sessions. */ fun shutdown() { - connections.forEach { id, session -> session.cancelAllSubscriptions() } + connections.forEach { _, session -> session.cancelAllSubscriptions() } connections.clear() scope.cancel() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index 78f22402b8..2a384d8a68 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.server -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage @@ -30,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd @@ -46,27 +46,19 @@ import kotlinx.coroutines.launch */ class RelaySession( private val store: LiveEventStore, - private val verify: (Event) -> Boolean, + val policy: IRelayPolicy, private val scope: CoroutineScope, private val onSend: (String) -> Unit, private val onClose: (RelaySession) -> Unit, ) : AutoCloseable { private val subscriptions = LargeCache() - fun send(message: Message) { - try { - onSend(OptimizedJsonMapper.toJson(message)) - } catch (e: Exception) { - Log.w("ClientSession", "Failed to send to ${e.message}") - } - } - - fun addSubscription( + private fun addSubscription( subId: String, job: Job, ) = subscriptions.put(subId, job) - fun cancelSubscription(subId: String): Boolean = + private fun cancelSubscription(subId: String): Boolean = subscriptions.remove(subId)?.let { it.cancel() true @@ -77,6 +69,14 @@ class RelaySession( subscriptions.clear() } + fun send(message: Message) { + try { + onSend(OptimizedJsonMapper.toJson(message)) + } catch (e: Exception) { + Log.w("ClientSession", "Failed to send to ${e.message}") + } + } + override fun close() { cancelAllSubscriptions() onClose(this) @@ -87,10 +87,10 @@ class RelaySession( * * Parses the message as a NIP-01 command and dispatches it. */ - suspend fun processMessage(message: String) { + suspend fun receive(command: String) { val cmd = try { - OptimizedJsonMapper.fromJsonToCommand(message) + OptimizedJsonMapper.fromJsonToCommand(command) } catch (_: Exception) { send(NoticeMessage("error: could not parse message")) return @@ -102,6 +102,7 @@ class RelaySession( } when (cmd) { + is AuthCmd -> handleAuth(cmd) is EventCmd -> handleEvent(cmd) is ReqCmd -> handleReq(cmd) is CloseCmd -> handleClose(cmd) @@ -110,21 +111,15 @@ class RelaySession( } } - // -- NIP-01: EVENT -------------------------------------------------------- - private fun handleEvent(cmd: EventCmd) { - val event = cmd.event - - if (!verify(event)) { - send(OkMessage(event.id, false, "invalid: bad signature or id")) + // -- NIP-42: AUTH --------------------------------------------------------- + private fun handleAuth(cmd: AuthCmd) { + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(OkMessage(cmd.event.id, false, result.reason)) return } - try { - store.insert(event) - send(OkMessage(event.id, true, "")) - } catch (e: Exception) { - send(OkMessage(event.id, false, e.message ?: e::class.simpleName ?: "unkown error")) - } + send(OkMessage(cmd.event.id, true, "")) } // -- NIP-01: REQ ---------------------------------------------------------- @@ -132,12 +127,25 @@ class RelaySession( // Cancel any existing subscription with the same id (NIP-01 spec). cancelSubscription(cmd.subId) + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(ClosedMessage(cmd.subId, result.reason)) + return + } + + // Policy may rewrite filters to match the user's access level. + val filters = (result as PolicyResult.Accepted).cmd.filters + val job = scope.launch { try { store.query( - filters = cmd.filters, - onEach = { send(EventMessage(cmd.subId, it)) }, + filters = filters, + onEach = { event -> + if (policy.canSendToSession(event)) { + send(EventMessage(cmd.subId, event)) + } + }, onEose = { send(EoseMessage(cmd.subId)) }, ) } catch (_: kotlinx.coroutines.CancellationException) { @@ -156,9 +164,39 @@ class RelaySession( } } + // -- NIP-01: EVENT -------------------------------------------------------- + private fun handleEvent(cmd: EventCmd) { + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(OkMessage(cmd.event.id, false, result.reason)) + return + } + + try { + store.insert(cmd.event) + send(OkMessage(cmd.event.id, true, "")) + } catch (e: Exception) { + send(OkMessage(cmd.event.id, false, e.message ?: e::class.simpleName ?: "unkown error")) + } + } + // -- NIP-45: COUNT -------------------------------------------------------- private fun handleCount(cmd: CountCmd) { - val total = store.count(cmd.filters) + val result = policy.accept(cmd) + if (result is PolicyResult.Rejected) { + send(ClosedMessage(cmd.queryId, result.reason)) + return + } + + // Policy may rewrite filters to match the user's access level. + val filters = (result as PolicyResult.Accepted).cmd.filters + + val total = store.count(filters) + send(CountMessage(cmd.queryId, CountResult(total))) } + + init { + policy.onConnect(::send) + } } 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 new file mode 100644 index 0000000000..fe7429a43e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt @@ -0,0 +1,47 @@ +/* + * 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.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. + */ +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 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt new file mode 100644 index 0000000000..525bfa144c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/FullAuthPolicy.kt @@ -0,0 +1,104 @@ +/* + * 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.nip01Core.relay.server.policies + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +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.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult +import com.vitorpamplona.quartz.nip40Expiration.isExpired +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Requires authentication for all EVENT, REQ, and COUNT commands. + * Replicates the previous `requireAuth = true` behavior. + */ +open class FullAuthPolicy( + val relay: NormalizedRelayUrl, +) : IRelayPolicy { + /** The challenge string sent to this client for NIP-42 authentication. */ + val challenge: String = RandomInstance.randomChars(32) + + /** Set of pubkeys that have successfully authenticated on this session. */ + val authenticatedUsers = mutableSetOf() + + /** Returns true if at least one pubkey has authenticated. */ + fun isAuthenticated(): Boolean = authenticatedUsers.isNotEmpty() + + override fun onConnect(send: (Message) -> Unit) { + send(AuthMessage(challenge)) + } + + override fun accept(cmd: AuthCmd): PolicyResult { + val event = cmd.event + + if (event.isExpired()) { + return PolicyResult.Rejected("invalid: auth event expired") + } + + if (!TimeUtils.withinTenMinutes(event.createdAt)) { + return PolicyResult.Rejected("invalid: created_at is too far from the current time") + } + + if (event.challenge() != challenge) { + return PolicyResult.Rejected("invalid: challenge does not match") + } + + if (event.relay() != relay) { + return PolicyResult.Rejected("invalid: relay url does not match") + } + + authenticatedUsers.add(event.pubKey) + + return PolicyResult.Accepted(cmd) + } + + override fun accept(cmd: EventCmd): PolicyResult = + if (isAuthenticated()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("auth-required: this relay requires authentication") + } + + override fun accept(cmd: ReqCmd) = + if (isAuthenticated()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("auth-required: this relay requires authentication") + } + + override fun accept(cmd: CountCmd) = + if (isAuthenticated()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("auth-required: this relay requires authentication") + } + + override fun canSendToSession(event: Event) = true +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt new file mode 100644 index 0000000000..67b3b0f1ce --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PolicyStack.kt @@ -0,0 +1,64 @@ +/* + * 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.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.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +class PolicyStack( + vararg policies: IRelayPolicy, +) : IRelayPolicy { + val policies = policies.toList() + + override fun onConnect(send: (Message) -> Unit) { + policies.forEach { it.onConnect(send) } + } + + override fun accept(cmd: EventCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + + override fun accept(cmd: ReqCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + + override fun accept(cmd: CountCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + + override fun accept(cmd: AuthCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + + private inline fun runPolicies( + initialCmd: T, + operation: (IRelayPolicy, T) -> PolicyResult, + ): PolicyResult { + var currentCmd = initialCmd + for (policy in policies) { + val result = operation(policy, currentCmd) + if (result is PolicyResult.Rejected) return result + currentCmd = (result as PolicyResult.Accepted).cmd + } + return PolicyResult.Accepted(currentCmd) + } + + override fun canSendToSession(event: Event): Boolean = policies.all { it.canSendToSession(event) } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt new file mode 100644 index 0000000000..283fda6707 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/VerifyPolicy.kt @@ -0,0 +1,58 @@ +/* + * 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.nip01Core.relay.server.policies + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verify +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. + */ +object VerifyPolicy : IRelayPolicy { + override fun onConnect(send: (Message) -> Unit) { } + + override fun accept(cmd: EventCmd) = + if (cmd.event.verify()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("invalid: bad signature or id") + } + + override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: AuthCmd) = + if (cmd.event.verify()) { + PolicyResult.Accepted(cmd) + } else { + PolicyResult.Rejected("invalid: bad signature or id") + } + + override fun canSendToSession(event: Event) = true +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt index c8fcbde7db..90ca1272c8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -24,6 +24,7 @@ object TimeUtils { const val TEN_SECONDS = 10 const val ONE_MINUTE = 60 const val FIVE_MINUTES = 5 * ONE_MINUTE + const val TEN_MINUTES = 10 * ONE_MINUTE const val FIFTEEN_MINUTES = 15 * ONE_MINUTE const val ONE_HOUR = 60 * ONE_MINUTE const val EIGHT_HOURS = 8 * ONE_HOUR @@ -70,4 +71,9 @@ object TimeUtils { fun ninetyDaysFromNow() = now() + NINETY_DAYS fun oneYearAgo() = now() - ONE_YEAR + + fun withinTenMinutes(time: Long): Boolean { + val now = now() + return time > now - TEN_MINUTES && time < now + TEN_MINUTES + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt new file mode 100644 index 0000000000..bc89df39f4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt @@ -0,0 +1,536 @@ +/* + * 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.nip01Core.relay.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +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.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class NostrServerAuthTest { + private val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + private val pubkey2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + private val sig = "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce" + private val relayUrl = NormalizedRelayUrl("wss://relay.example.com/") + + private fun hexId(n: Int): String = n.toString().padStart(64, '0') + + private fun testEvent( + id: String = hexId(1), + kind: Int = 1, + createdAt: Long = 1000L, + content: String = "hello", + tags: Array> = emptyArray(), + ) = Event(id, pubkey, createdAt, kind, tags, content, sig) + + /** + * Creates a server using the given dispatcher so coroutines run eagerly + * in tests (UnconfinedTestDispatcher). + */ + private fun createServer( + dispatcher: CoroutineDispatcher, + store: IEventStore = EventStore(null), + policyBuilder: () -> IRelayPolicy = { FullAuthPolicy(relayUrl) }, + ): NostrServer = + NostrServer( + store = store, + policyBuilder = policyBuilder, + parentContext = dispatcher, + ) + + private suspend fun RelaySession.insert(event: Event) { + val cmd = EventCmd(event) + this.receive(OptimizedJsonMapper.toJson(cmd)) + } + + /** Collects sent JSON messages for a connection. */ + private class MessageCollector { + val messages = mutableListOf() + + val sendCallback: (String) -> Unit = { messages.add(it) } + + /** + * Parses messages that can be round-tripped (EVENT, EOSE, NOTICE, + * CLOSED). OkMessage and CountMessage serialization uses formats + * incompatible with the client-side deserializer, so check those + * via [rawMessagesContaining]. + */ + fun parsedEventMessages() = + messages + .filter { it.startsWith("[\"EVENT\"") || it.startsWith("[\"EOSE\"") } + .map { OptimizedJsonMapper.fromJsonToMessage(it) } + + fun rawMessagesContaining(label: String) = messages.filter { it.contains("\"$label\"") } + } + + /** + * Builds a kind 22242 auth event for testing. Because verify = { true }, + * the id and signature don't need to be real. + */ + private fun authEvent( + challenge: String, + relay: String = relayUrl.url, + pubKey: String = pubkey, + createdAt: Long = TimeUtils.now(), + ) = RelayAuthEvent( + id = hexId(99), + pubKey = pubKey, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("relay", relay), + arrayOf("challenge", challenge), + ), + content = "", + sig = sig, + ) + + private fun authJson(event: RelayAuthEvent): String { + val cmd = AuthCmd(event) + return OptimizedJsonMapper.toJson(cmd) + } + + private fun authJson(event: Event) = """["AUTH",${event.toJson()}]""" + + @Test + fun authChallengeIsSentOnRequest() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + + server.shutdown() + } + + @Test + fun authSucceedsWithValidEvent() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + val event = authEvent(challenge = msg.challenge) + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + assertTrue((session.policy as FullAuthPolicy).isAuthenticated()) + assertTrue(session.policy.authenticatedUsers.contains(pubkey)) + + server.shutdown() + } + + @Test + fun authFailsWithWrongChallenge() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + val event = authEvent(challenge = "wrong-challenge") + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains("challenge")) + assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) + + server.shutdown() + } + + @Test + fun authFailsWithWrongRelay() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + val event = authEvent(challenge = msg.challenge, relay = "wss://wrong.relay.com/") + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains("relay url")) + assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) + + server.shutdown() + } + + @Test + fun authFailsWithExpiredTimestamp() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + val event = + authEvent( + challenge = msg.challenge, + createdAt = TimeUtils.now() - 1200L, // 20 minutes ago + ) + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains("created_at")) + assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) + + server.shutdown() + } + + @Test + fun authFailsWithWrongKind() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // Create an event with wrong kind (1 instead of 22242) + val event = + Event( + id = hexId(99), + pubKey = pubkey, + createdAt = TimeUtils.now(), + kind = 1, + tags = + arrayOf( + arrayOf("relay", relayUrl.url), + arrayOf("challenge", msg.challenge), + ), + content = "", + sig = sig, + ) + session.receive(authJson(event)) + + val okMessages = collector.rawMessagesContaining("NOTICE") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("error")) + assertTrue(okMessages[0].contains("could not parse message")) + assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) + + server.shutdown() + } + + @Test + fun multipleUsersCanAuthenticate() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // First user authenticates + val event1 = authEvent(challenge = msg.challenge, pubKey = pubkey) + session.receive(authJson(event1)) + + // Second user authenticates on the same session + val event2 = authEvent(challenge = msg.challenge, pubKey = pubkey2) + session.receive(authJson(event2)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(2, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[1].contains("\"true\"")) + + val authedPubkeys = (session.policy as FullAuthPolicy).authenticatedUsers + assertEquals(2, authedPubkeys.size) + assertTrue(authedPubkeys.contains(pubkey)) + assertTrue(authedPubkeys.contains(pubkey2)) + + server.shutdown() + } + + // -- NIP-42: requireAuth --------------------------------------------------- + + @Test + fun requireAuthRejectsEventWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains("auth-required:")) + + server.shutdown() + } + + @Test + fun requireAuthRejectsReqWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + session.receive("""["REQ","sub1",{"kinds":[1]}]""") + + val closedMessages = collector.rawMessagesContaining("CLOSED") + assertEquals(1, closedMessages.size) + assertTrue(closedMessages[0].contains("auth-required:")) + + server.shutdown() + } + + @Test + fun requireAuthRejectsCountWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + session.receive("""["COUNT","q1",{"kinds":[1]}]""") + + val closedMessages = collector.rawMessagesContaining("CLOSED") + assertEquals(1, closedMessages.size) + assertTrue(closedMessages[0].contains("auth-required:")) + + server.shutdown() + } + + @Test + fun requireAuthAllowsCommandsAfterAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + val server = createServer(dispatcher = dispatcher) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // Authenticate first + val authEv = authEvent(challenge = msg.challenge) + session.receive(authJson(authEv)) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + + // Now EVENT should work + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + + val allOk = collector.rawMessagesContaining("OK") + assertEquals(2, allOk.size) + assertTrue(allOk[1].contains("\"true\"")) + + // REQ should work + session.receive("""["REQ","sub1",{"kinds":[1]}]""") + + val eoseMessages = collector.rawMessagesContaining("EOSE") + assertTrue(eoseMessages.isNotEmpty()) + + server.shutdown() + } + + @Test + fun noAuthRequiredAllowsCommandsWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { EmptyPolicy }) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + // EVENT should work without auth when using OpenPolicy + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains("\"true\"")) + + server.shutdown() + } + + // -- Custom AuthPolicy tests ----------------------------------------------- + + @Test + fun customPolicyRejectsSpecificEventKinds() = + runTest { + // Policy that blocks kind 4 (DMs) from unauthenticated users. + val policy = + object : FullAuthPolicy(relayUrl) { + override fun accept(cmd: EventCmd) = + if (cmd.event.kind == 4 && authenticatedUsers.isEmpty()) { + PolicyResult.Rejected("auth-required: kind 4 events require authentication") + } else { + PolicyResult.Accepted(cmd) + } + + override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) + } + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { policy }) + val collector = MessageCollector() + val session = server.connect(collector.sendCallback) + + // Kind 1 should be accepted without auth + val note = testEvent(hexId(1), kind = 1) + session.receive("""["EVENT",${note.toJson()}]""") + assertTrue(collector.rawMessagesContaining("OK")[0].contains("\"true\"")) + + // Kind 4 should be rejected without auth + val dm = testEvent(hexId(2), kind = 4) + session.receive("""["EVENT",${dm.toJson()}]""") + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(2, okMessages.size) + assertTrue(okMessages[1].contains("\"false\"")) + assertTrue(okMessages[1].contains("auth-required:")) + + server.shutdown() + } + + @Test + fun customPolicyRewritesFilters() = + runTest { + // Policy that restricts kind 4 queries to the authed user's own messages. + val policy = + object : FullAuthPolicy(relayUrl) { + override fun accept(cmd: EventCmd) = PolicyResult.Accepted(cmd) + + override fun accept(cmd: ReqCmd): PolicyResult { + val hasDmFilter = cmd.filters.any { it.kinds?.contains(4) == true } + if (!hasDmFilter) return PolicyResult.Accepted(cmd) + if (authenticatedUsers.isEmpty()) { + return PolicyResult.Rejected("auth-required: kind 4 requires auth") + } + // Rewrite: restrict to authed user's pubkey as author + val rewritten = + cmd.filters.map { filter -> + if (filter.kinds?.contains(4) == true) { + filter.copy(authors = authenticatedUsers.toList()) + } else { + filter + } + } + return PolicyResult.Accepted(ReqCmd(cmd.subId, rewritten)) + } + + override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd) + } + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val store = EventStore(null) + val server = createServer(store = store, dispatcher = dispatcher, policyBuilder = { policy }) + + // Insert DMs from two different authors + store.insert(testEvent(hexId(1), kind = 4, createdAt = 100L)) // from pubkey + store.insert( + Event(hexId(2), pubkey2, 200L, 4, emptyArray(), "secret", sig), + ) // from pubkey2 + + val collector = MessageCollector() + val session = server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // Authenticate as pubkey + val auth = authEvent(challenge = msg.challenge, pubKey = pubkey) + session.receive(authJson(auth)) + + // Query kind 4 — policy should rewrite to only return pubkey's events + session.receive("""["REQ","sub1",{"kinds":[4]}]""") + + val events = collector.parsedEventMessages().filterIsInstance() + assertEquals(1, events.size) + assertEquals(pubkey, events[0].event.pubKey) + + server.shutdown() + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt index d9eb0e09cf..165bfb0e47 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +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 kotlinx.coroutines.ExperimentalCoroutinesApi @@ -55,18 +56,19 @@ class NostrServerTest { * in tests (UnconfinedTestDispatcher). */ private fun createServer( - store: IEventStore = EventStore(null), dispatcher: kotlinx.coroutines.CoroutineDispatcher, + store: IEventStore = EventStore(null), + policyBuilder: () -> IRelayPolicy = { EmptyPolicy }, ): NostrServer = NostrServer( store = store, + policyBuilder = policyBuilder, parentContext = dispatcher, - verify = { true }, ) private suspend fun RelaySession.insert(event: Event) { val cmd = EventCmd(event) - this.processMessage(OptimizedJsonMapper.toJson(cmd)) + this.receive(OptimizedJsonMapper.toJson(cmd)) } /** Collects sent JSON messages for a connection. */ @@ -96,14 +98,14 @@ class NostrServerTest { runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val store = EventStore(null) - val server = createServer(store, dispatcher) + val server = createServer(dispatcher, store) val collector = MessageCollector() val c1 = server.connect(collector.sendCallback) val event = testEvent() val eventJson = """["EVENT",${event.toJson()}]""" - c1.processMessage(eventJson) + c1.receive(eventJson) val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) @@ -121,15 +123,15 @@ class NostrServerTest { runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val store = EventStore(null) - val server = createServer(store, dispatcher) + val server = createServer(dispatcher, store) val collector = MessageCollector() val c1 = server.connect(collector.sendCallback) val event = testEvent() val eventJson = """["EVENT",${event.toJson()}]""" - c1.processMessage(eventJson) - c1.processMessage(eventJson) + c1.receive(eventJson) + c1.receive(eventJson) val okMessages = collector.rawMessagesContaining("OK") assertEquals(2, okMessages.size) @@ -146,7 +148,7 @@ class NostrServerTest { runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val store = EventStore(null) - val server = createServer(store, dispatcher) + val server = createServer(dispatcher, store) // Pre-populate store store.insert(testEvent(hexId(1), kind = 1, createdAt = 100L)) @@ -157,7 +159,7 @@ class NostrServerTest { val c1 = server.connect(collector.sendCallback) val reqJson = """["REQ","sub1",{"kinds":[1]}]""" - c1.processMessage(reqJson) + c1.receive(reqJson) val parsed = collector.parsedEventMessages() val events = parsed.filterIsInstance() @@ -178,7 +180,7 @@ class NostrServerTest { runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val store = EventStore(null) - val server = createServer(store, dispatcher) + val server = createServer(dispatcher, store) for (i in 1..10) { store.insert(testEvent(hexId(i), createdAt = i.toLong())) @@ -188,7 +190,7 @@ class NostrServerTest { val c1 = server.connect(collector.sendCallback) val reqJson = """["REQ","sub1",{"limit":3}]""" - c1.processMessage(reqJson) + c1.receive(reqJson) val events = collector.parsedEventMessages().filterIsInstance() assertEquals(3, events.size) @@ -202,8 +204,8 @@ class NostrServerTest { fun liveSubscriptionReceivesNewEvents() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val store = EventStore(null) - val server = createServer(store, dispatcher) + + val server = createServer(dispatcher) val collector1 = MessageCollector() val collector2 = MessageCollector() @@ -212,7 +214,7 @@ class NostrServerTest { // Subscribe to kind 1 val reqJson = """["REQ","sub1",{"kinds":[1]}]""" - c1.processMessage(reqJson) + c1.receive(reqJson) // After REQ, we should have EOSE val countAfterEose = collector1.messages.size @@ -231,8 +233,8 @@ class NostrServerTest { fun liveSubscriptionFiltersNonMatchingEvents() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val store = EventStore(null) - val server = createServer(store, dispatcher) + + val server = createServer(dispatcher) val collector1 = MessageCollector() val collector2 = MessageCollector() @@ -240,7 +242,7 @@ class NostrServerTest { val c2 = server.connect(collector2.sendCallback) val reqJson = """["REQ","sub1",{"kinds":[1]}]""" - c1.processMessage(reqJson) + c1.receive(reqJson) val countAfterEose = collector1.messages.size @@ -258,8 +260,8 @@ class NostrServerTest { fun closeStopsSubscription() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val store = EventStore(null) - val server = createServer(store, dispatcher) + + val server = createServer(dispatcher) val collector1 = MessageCollector() val collector2 = MessageCollector() @@ -267,11 +269,11 @@ class NostrServerTest { val c2 = server.connect(collector2.sendCallback) val reqJson = """["REQ","sub1",{"kinds":[1]}]""" - c1.processMessage(reqJson) + c1.receive(reqJson) // Close the subscription val closeJson = """["CLOSE","sub1"]""" - c1.processMessage(closeJson) + c1.receive(closeJson) val countAfterClose = collector1.messages.size @@ -287,7 +289,7 @@ class NostrServerTest { fun replacingSubscriptionCancelsOld() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val server = createServer(EventStore(null), dispatcher) + val server = createServer(dispatcher) val collector1 = MessageCollector() val collector2 = MessageCollector() @@ -295,10 +297,10 @@ class NostrServerTest { val c2 = server.connect(collector2.sendCallback) // First subscription for kind 1 - c1.processMessage("""["REQ","sub1",{"kinds":[1]}]""") + c1.receive("""["REQ","sub1",{"kinds":[1]}]""") // Replace with kind 4 - c1.processMessage("""["REQ","sub1",{"kinds":[4]}]""") + c1.receive("""["REQ","sub1",{"kinds":[4]}]""") val countAfterReplace = collector1.messages.size @@ -326,7 +328,7 @@ class NostrServerTest { runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val store = EventStore(null) - val server = createServer(store, dispatcher) + val server = createServer(dispatcher, store) store.insert(testEvent(hexId(1), kind = 1)) store.insert(testEvent(hexId(2), kind = 1)) @@ -336,7 +338,7 @@ class NostrServerTest { val c1 = server.connect(collector.sendCallback) val countJson = """["COUNT","q1",{"kinds":[1]}]""" - c1.processMessage(countJson) + c1.receive(countJson) val countMessages = collector.rawMessagesContaining("COUNT") assertEquals(1, countMessages.size) @@ -351,16 +353,16 @@ class NostrServerTest { fun disconnectCancelsAllSubscriptions() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val store = EventStore(null) - val server = createServer(store, dispatcher) + + val server = createServer(dispatcher) val collector1 = MessageCollector() val collector2 = MessageCollector() val c1 = server.connect(collector1.sendCallback) val c2 = server.connect(collector2.sendCallback) - c1.processMessage("""["REQ","sub1",{"kinds":[1]}]""") - c1.processMessage("""["REQ","sub2",{"kinds":[4]}]""") + c1.receive("""["REQ","sub1",{"kinds":[1]}]""") + c1.receive("""["REQ","sub2",{"kinds":[4]}]""") c1.close() @@ -385,7 +387,7 @@ class NostrServerTest { val collector = MessageCollector() val c1 = server.connect(collector.sendCallback) - c1.processMessage("not valid json") + c1.receive("not valid json") assertEquals(1, collector.messages.size) assertTrue(collector.messages[0].contains("NOTICE"))