From 69aea299545d689908caafa88a98100b1713c460 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 17:15:13 +0000 Subject: [PATCH] =?UTF-8?q?feat(quartz):=20server-side=20relay=20ergonomic?= =?UTF-8?q?s=20=E2=80=94=20NIP-50=20parser,=20suspend=20auth=20hook,=20wir?= =?UTF-8?q?e=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the self-contained, low-risk items from the relay-ergonomics request: - NIP-50: add SearchQuery to parse Filter.search into free-text terms and the typed key:value extensions (domain/language/sentiment/nsfw/include), preserving unknown extensions and offering a canonical toSearchString(). - NIP-42: add a suspend IRelayPolicy.onAuthenticated(pubKey, event) hook (chained through PolicyStack) so external-auth bridges (e.g. JWT exchange) can live inside FullAuthPolicy instead of leaking into transport code. RelaySession invokes it after the AUTH passes; a throw becomes OK false. - Ergonomics: Command.fromJson/toJson and Message.fromJson/toJson mirroring Event, plus MachineReadablePrefix + OkMessage/ClosedMessage factories for standardized OK/CLOSED reason prefixes. - Docs: RELAY.md sections for the external-auth bridge, NIP-50 search, and the wire helpers. https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr --- quartz/RELAY.md | 83 +++++++- .../relay/commands/toClient/ClosedMessage.kt | 10 + .../toClient/MachineReadablePrefix.kt | 79 ++++++++ .../relay/commands/toClient/Message.kt | 9 + .../relay/commands/toClient/OkMessage.kt | 16 ++ .../relay/commands/toRelay/Command.kt | 9 + .../nip01Core/relay/server/IRelayPolicy.kt | 24 +++ .../nip01Core/relay/server/RelaySession.kt | 16 +- .../relay/server/policies/FullAuthPolicy.kt | 9 + .../relay/server/policies/PolicyStack.kt | 9 + .../quartz/nip50Search/SearchQuery.kt | 181 ++++++++++++++++++ .../relay/commands/RelayWireErgonomicsTest.kt | 101 ++++++++++ .../relay/server/NostrServerAuthTest.kt | 62 ++++++ .../quartz/nip50Search/SearchQueryTest.kt | 147 ++++++++++++++ 14 files changed, 749 insertions(+), 6 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MachineReadablePrefix.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQuery.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQueryTest.kt diff --git a/quartz/RELAY.md b/quartz/RELAY.md index 2532aac38d..80a697adb0 100644 --- a/quartz/RELAY.md +++ b/quartz/RELAY.md @@ -109,6 +109,20 @@ val server = NostrServer( ) ``` +`FullAuthPolicy` already implements the full NIP-42 challenge/verify handshake — you should not re-implement it. To bridge auth to an external system (e.g. exchange the verified event for a backend JWT), override the `suspend` `onAuthenticated` hook. It runs after the NIP-42 checks pass but before the success `OK` is sent, so it can do network/disk I/O; throwing from it turns the AUTH into a failing `OK false`: + +```kotlin +class JwtAuthPolicy( + relay: NormalizedRelayUrl, + private val backend: AuthBackend, +) : FullAuthPolicy(relay) { + override suspend fun onAuthenticated(pubKey: HexKey, event: RelayAuthEvent) { + // Suspends; a thrown exception rejects the login with OK false. + backend.exchangeForSession(pubKey, event) + } +} +``` + ### Composing Policies Chain policies with `+` or `PolicyStack`. All must approve; first rejection wins. @@ -157,6 +171,58 @@ val server = NostrServer( ) ``` +## NIP-50 Search Queries + +`Filter.search` is the raw NIP-50 string. `SearchQuery` parses it into the +free-text terms plus the typed `key:value` extensions (`domain:`, `language:`, +`sentiment:`, `nsfw:`, `include:spam`), so a search relay or redirector doesn't +have to re-parse the string. Unknown extensions are preserved and readable via +`extension(key)`; `toSearchString()` re-assembles a canonical query. + +```kotlin +val q = SearchQuery.parse(filter.search) // "best apps domain:example.com nsfw:false" +q.terms // "best apps" +q.domain // "example.com" +q.nsfwIncluded // false (NIP-50 default is true when the token is absent) +``` + +A search/redirector relay is just a custom policy (or, for computed results, a +custom `IEventStore` whose `query` answers the REQ) that reads the parsed query: + +```kotlin +override fun accept(cmd: ReqCmd): PolicyResult { + cmd.filters.forEach { f -> + val q = SearchQuery.parse(f.search) + if (q.domain != null && q.domain !in allowedDomains) { + return PolicyResult.Rejected( + MachineReadablePrefix.RESTRICTED.format("domain not searchable"), + ) + } + } + return PolicyResult.Accepted(cmd) +} +``` + +## Wire Helpers + +`Command` and `Message` carry symmetric JSON helpers so you don't have to reach +for the mapper directly: + +```kotlin +val cmd = Command.fromJson(text) // ["REQ", "sub", {...}] -> ReqCmd +val json = EoseMessage("sub").toJson() +val msg = Message.fromJson(json) +``` + +Build standardized OK/CLOSED reasons with `MachineReadablePrefix` instead of +hand-writing the NIP-01 prefixes (`auth-required:`, `restricted:`, `error:`, …): + +```kotlin +session.send(OkMessage.rejected(event.id, MachineReadablePrefix.AUTH_REQUIRED, "log in first")) +session.send(ClosedMessage.of(subId, MachineReadablePrefix.RESTRICTED, "not allowed yet")) +MachineReadablePrefix.parse("rate-limited: slow down") // -> RATE_LIMITED +``` + ## Testing ```kotlin @@ -194,19 +260,26 @@ quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/ │ ├── NostrServer.kt # Main entry point │ ├── RelaySession.kt # Per-connection handler │ ├── LiveEventStore.kt # Reactive event streaming -│ ├── IRelayPolicy.kt # Policy interface + PolicyResult +│ ├── IRelayPolicy.kt # Policy interface + PolicyResult + onAuthenticated │ └── policies/ │ ├── EmptyPolicy.kt # Accept everything │ ├── VerifyPolicy.kt # Signature verification (default) -│ ├── FullAuthPolicy.kt # NIP-42 auth required +│ ├── FullAuthPolicy.kt # NIP-42 auth required (override onAuthenticated to bridge) │ └── PolicyStack.kt # Chain multiple policies +├── relay/commands/ +│ ├── toRelay/Command.kt # Command.fromJson / toJson +│ └── toClient/ +│ ├── Message.kt # Message.fromJson / toJson +│ └── MachineReadablePrefix.kt # Typed OK/CLOSED reason prefixes ├── store/ │ ├── IEventStore.kt # Storage interface │ └── sqlite/ │ ├── EventStore.kt # Public SQLite store wrapper │ ├── SQLiteEventStore.kt # Full implementation │ └── IndexingStrategy.kt # Index configuration -└── relay/filters/ - ├── Filter.kt # NIP-01 subscription filters - └── FilterMatcher.kt # Event-to-filter matching +├── relay/filters/ +│ ├── Filter.kt # NIP-01 subscription filters +│ └── FilterMatcher.kt # Event-to-filter matching +└── ../nip50Search/ + └── SearchQuery.kt # NIP-50 search-string parser ``` diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ClosedMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ClosedMessage.kt index 7eaadbf384..7710478344 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ClosedMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/ClosedMessage.kt @@ -28,5 +28,15 @@ class ClosedMessage( companion object { const val LABEL = "CLOSED" + + /** + * A `CLOSED` for [subId] whose reason carries a standardized + * [MachineReadablePrefix] (e.g. `auth-required: ...`). + */ + fun of( + subId: String, + prefix: MachineReadablePrefix, + message: String, + ) = ClosedMessage(subId, prefix.format(message)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MachineReadablePrefix.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MachineReadablePrefix.kt new file mode 100644 index 0000000000..6d3abf2937 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MachineReadablePrefix.kt @@ -0,0 +1,79 @@ +/* + * 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.commands.toClient + +/** + * Standardized machine-readable prefixes for the human-readable `message` + * carried by `OK` and `CLOSED` frames. + * + * NIP-01 defines the convention that the message "SHOULD" begin with a single + * word prefix followed by `: ` so clients can react programmatically — e.g. + * `"blocked: you are banned from posting here"`. NIP-42 adds `auth-required` + * and `restricted` for authentication gating. + * + * Use [format] to build a reason string and [parse] to read one back, instead + * of hand-writing the prefixes. + */ +enum class MachineReadablePrefix( + val code: String, +) { + /** The event was already in the relay's store. */ + DUPLICATE("duplicate"), + + /** Proof-of-work (NIP-13) was missing or insufficient. */ + POW("pow"), + + /** The pubkey or event is blocked by the relay. */ + BLOCKED("blocked"), + + /** The client is being rate limited. */ + RATE_LIMITED("rate-limited"), + + /** The event is malformed or fails validation. */ + INVALID("invalid"), + + /** The action is not permitted for this client (often pre-auth). */ + RESTRICTED("restricted"), + + /** The client must authenticate (NIP-42) before this action is allowed. */ + AUTH_REQUIRED("auth-required"), + + /** A generic, usually transient, server-side failure. */ + ERROR("error"), + ; + + /** Builds a reason string in the NIP-01 `": "` form. */ + fun format(message: String): String = "$code: $message" + + companion object { + private val byCode = entries.associateBy { it.code } + + /** + * Extracts the standardized prefix from a reason string, or null when + * the reason has no recognized machine-readable prefix. + */ + fun parse(reason: String): MachineReadablePrefix? { + val colon = reason.indexOf(':') + if (colon <= 0) return null + return byCode[reason.substring(0, colon)] + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt index 49ce72567f..9e2c9204a0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/Message.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.MessageKSerializer import kotlinx.serialization.Serializable @@ -27,4 +28,12 @@ import kotlinx.serialization.Serializable @Serializable(with = MessageKSerializer::class) interface Message : OptimizedSerializable { fun label(): String + + /** Serializes this message to its NIP-01 wire JSON, e.g. `["EVENT", "sub", {...}]`. */ + fun toJson(): String = OptimizedJsonMapper.toJson(this) + + companion object { + /** Parses a relay-to-client message from its NIP-01 wire JSON. */ + fun fromJson(json: String): Message = OptimizedJsonMapper.fromJsonToMessage(json) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/OkMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/OkMessage.kt index 104231f1e9..8a29794a44 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/OkMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/OkMessage.kt @@ -31,5 +31,21 @@ class OkMessage( companion object { const val LABEL = "OK" + + /** A successful `OK true` for [eventId], with an optional human-readable note. */ + fun accepted( + eventId: HexKey, + message: String = "", + ) = OkMessage(eventId, true, message) + + /** + * A rejecting `OK false` for [eventId] whose reason carries a + * standardized [MachineReadablePrefix] (e.g. `auth-required: ...`). + */ + fun rejected( + eventId: HexKey, + prefix: MachineReadablePrefix, + message: String, + ) = OkMessage(eventId, false, prefix.format(message)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt index 0a46a5dda8..b27771ba4b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/Command.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.CommandKSerializer import kotlinx.serialization.Serializable @@ -29,4 +30,12 @@ interface Command : OptimizedSerializable { fun label(): String fun isValid(): Boolean + + /** Serializes this command to its NIP-01 wire JSON, e.g. `["REQ", "sub", {...}]`. */ + fun toJson(): String = OptimizedJsonMapper.toJson(this) + + companion object { + /** Parses a client-to-relay command from its NIP-01 wire JSON. */ + fun fromJson(json: String): Command = OptimizedJsonMapper.fromJsonToCommand(json) + } } 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 index 249ac7c757..af4ee38eb6 100644 --- 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 @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.server import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey 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 @@ -28,6 +29,7 @@ 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.policies.PolicyStack +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent /** * Defines custom behavior for this relay. @@ -67,6 +69,28 @@ interface IRelayPolicy { */ fun accept(cmd: AuthCmd): PolicyResult + /** + * Called after an AUTH command has been [accept]ed, before the success + * `OK` is sent. This is the place to run any post-authentication side + * effects that need network or disk I/O — for example, exchanging the + * verified NIP-42 event for a backend session token — without leaking + * that logic out of the policy and into the transport layer. + * + * The synchronous [accept] does the cheap, deterministic NIP-42 checks + * (challenge, relay, timestamp); this suspend hook does the expensive, + * external part. Throwing here turns the AUTH into a failing `OK false`, + * so a bridge can reject a verified-but-unauthorized user by throwing. + * + * The default implementation does nothing. + * + * @param pubKey The pubkey that just authenticated. + * @param event The verified NIP-42 auth event. + */ + suspend fun onAuthenticated( + pubKey: HexKey, + event: RelayAuthEvent, + ) {} + /** * Filters a live event before it is forwarded to a subscriber. * 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 7a4d2d973d..39996c88cd 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 @@ -175,13 +175,27 @@ class RelaySession( } // -- NIP-42: AUTH --------------------------------------------------------- - private fun handleAuth(cmd: AuthCmd) { + private suspend fun handleAuth(cmd: AuthCmd) { val result = policy.accept(cmd) if (result is PolicyResult.Rejected) { send(OkMessage(cmd.event.id, false, result.reason)) return } + // Cheap NIP-42 checks passed. Run the policy's post-auth hook + // (which may do network/disk I/O, e.g. exchange the verified + // event for a backend session token) before confirming. A + // throw turns the AUTH into a failing OK so the client knows + // the login did not complete. + try { + policy.onAuthenticated(cmd.event.pubKey, cmd.event) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + send(OkMessage(cmd.event.id, false, "error: ${e.message ?: "authentication failed"}")) + return + } + send(OkMessage(cmd.event.id, 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 index 525bfa144c..e1fc896baf 100644 --- 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 @@ -38,6 +38,15 @@ import com.vitorpamplona.quartz.utils.TimeUtils /** * Requires authentication for all EVENT, REQ, and COUNT commands. * Replicates the previous `requireAuth = true` behavior. + * + * This class already implements the full NIP-42 challenge/verify handshake: + * [onConnect] sends the [challenge] and [accept] (AuthCmd) validates the + * returned event (expiration, freshness, challenge match, relay match) before + * recording the pubkey in [authenticatedUsers]. Subclasses generally should + * NOT re-implement that — to bridge to an external auth system, override + * [onAuthenticated] (a `suspend` hook) and do the post-verification I/O there, + * e.g. exchange the verified event for a backend session token. Throwing from + * that hook turns the AUTH into a failing `OK false`. */ open class FullAuthPolicy( val relay: NormalizedRelayUrl, 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 index 67b3b0f1ce..21a0e7adaa 100644 --- 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 @@ -21,6 +21,7 @@ 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.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command @@ -29,6 +30,7 @@ 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 +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent class PolicyStack( vararg policies: IRelayPolicy, @@ -47,6 +49,13 @@ class PolicyStack( override fun accept(cmd: AuthCmd) = runPolicies(cmd) { p, c -> p.accept(c) } + override suspend fun onAuthenticated( + pubKey: HexKey, + event: RelayAuthEvent, + ) { + policies.forEach { it.onAuthenticated(pubKey, event) } + } + private inline fun runPolicies( initialCmd: T, operation: (IRelayPolicy, T) -> PolicyResult, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQuery.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQuery.kt new file mode 100644 index 0000000000..0945a50a2f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQuery.kt @@ -0,0 +1,181 @@ +/* + * 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.nip50Search + +/** + * Parsed representation of a NIP-50 `search` filter string. + * + * NIP-50 defines the [com.vitorpamplona.quartz.nip01Core.relay.filters.Filter.search] + * field as "a string describing a query in a human-readable form", optionally + * carrying `key:value` extension tokens such as `domain:example.com` or + * `language:en`. This class splits that raw string into the free-text [terms] + * and the recognized [extensions], giving relays (and search redirectors) a + * typed view of the query instead of forcing each one to re-parse the string. + * + * Example: + * ``` + * val q = SearchQuery.parse("best nostr apps domain:example.com language:en") + * q.terms // "best nostr apps" + * q.domain // "example.com" + * q.language // "en" + * ``` + * + * ## Tokenization + * + * The string is split on whitespace. A token is treated as an extension when: + * - it contains a `:`, + * - the part before the `:` is a non-empty run of lowercase ASCII letters + * (`a`–`z`), and + * - the part after the `:` is non-empty and does not start with `//` (so URLs + * like `https://example.com` stay in [terms]). + * + * Everything else is free text. Per NIP-50 unknown extensions are kept (so they + * can be forwarded to a backend) — relays "SHOULD ignore extensions they don't + * support", which this models by simply not having a typed accessor for them; + * they remain readable through [extensions] / [extension]. + * + * Extension keys are matched case-sensitively against the lowercase forms + * documented by NIP-50. Duplicate keys keep the last occurrence. + */ +class SearchQuery( + /** The human-readable search terms with all extension tokens removed. */ + val terms: String, + /** + * All recognized `key:value` extension tokens, in the order they appeared. + * Known keys: [INCLUDE], [DOMAIN], [LANGUAGE], [SENTIMENT], [NSFW]. + */ + val extensions: Map, +) { + /** `true` when the query carries the `include:spam` token (NIP-50: disable spam filtering). */ + val includeSpam: Boolean + get() = extensions[INCLUDE] == SPAM + + /** The `domain:` value, or null when not present. */ + val domain: String? + get() = extensions[DOMAIN] + + /** The `language:` value, or null when not present. */ + val language: String? + get() = extensions[LANGUAGE] + + /** The parsed `sentiment:` value, or null when absent/unrecognized. */ + val sentiment: Sentiment? + get() = extensions[SENTIMENT]?.let(Sentiment::parse) + + /** The parsed `nsfw:` value, or null when not present. See [nsfwIncluded]. */ + val nsfw: Boolean? + get() = extensions[NSFW]?.toBooleanStrictOrNull() + + /** + * Whether nsfw events should be included, applying NIP-50's documented + * default of `true` when the `nsfw` token is absent. + */ + val nsfwIncluded: Boolean + get() = nsfw ?: true + + /** Returns the raw value of an arbitrary extension key (including unknown ones), or null. */ + fun extension(key: String): String? = extensions[key] + + /** Returns true when there are no free-text terms (the query is extensions-only or empty). */ + fun isTermsEmpty(): Boolean = terms.isEmpty() + + /** + * Re-assembles a canonical NIP-50 search string: the free-text [terms] + * followed by each `key:value` extension. Useful for a redirector that + * normalizes the incoming query before forwarding it to a backend. + */ + fun toSearchString(): String = + buildString { + append(terms) + for ((key, value) in extensions) { + if (isNotEmpty()) append(' ') + append(key).append(':').append(value) + } + } + + companion object { + /** NIP-50 extension key `include` (only documented value is [SPAM]). */ + const val INCLUDE = "include" + + /** Documented value for the [INCLUDE] key. */ + const val SPAM = "spam" + + /** NIP-50 extension key `domain`. */ + const val DOMAIN = "domain" + + /** NIP-50 extension key `language`. */ + const val LANGUAGE = "language" + + /** NIP-50 extension key `sentiment`. */ + const val SENTIMENT = "sentiment" + + /** NIP-50 extension key `nsfw`. */ + const val NSFW = "nsfw" + + private val WHITESPACE = Regex("\\s+") + + /** Empty query — no terms and no extensions. */ + val EMPTY = SearchQuery("", emptyMap()) + + /** + * Parses a raw NIP-50 [search] string into a [SearchQuery]. A null or + * blank input yields [EMPTY]. + */ + fun parse(search: String?): SearchQuery { + if (search.isNullOrBlank()) return EMPTY + + val extensions = LinkedHashMap() + val terms = StringBuilder() + + for (token in search.trim().split(WHITESPACE)) { + val colon = token.indexOf(':') + if (colon > 0 && colon < token.length - 1) { + val key = token.substring(0, colon) + val value = token.substring(colon + 1) + if (isExtensionKey(key) && !value.startsWith("//")) { + extensions[key] = value + continue + } + } + if (terms.isNotEmpty()) terms.append(' ') + terms.append(token) + } + + return SearchQuery(terms.toString(), extensions) + } + + private fun isExtensionKey(key: String): Boolean = key.isNotEmpty() && key.all { it in 'a'..'z' } + } +} + +/** NIP-50 `sentiment:` extension values. */ +enum class Sentiment( + val code: String, +) { + NEGATIVE("negative"), + NEUTRAL("neutral"), + POSITIVE("positive"), + ; + + companion object { + fun parse(value: String): Sentiment? = entries.firstOrNull { it.code == value } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt new file mode 100644 index 0000000000..2eb20459b0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/RelayWireErgonomicsTest.kt @@ -0,0 +1,101 @@ +/* + * 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.commands + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RelayWireErgonomicsTest { + @Test + fun commandFromJsonParsesReq() { + val cmd = Command.fromJson("""["REQ","sub1",{"kinds":[1]}]""") + assertTrue(cmd is ReqCmd) + assertEquals("sub1", cmd.subId) + assertEquals(listOf(1), cmd.filters.single().kinds) + } + + @Test + fun commandToJsonRoundTrips() { + val json = ReqCmd("sub1", listOf()).toJson() + assertTrue(json.contains("\"REQ\"")) + val parsed = Command.fromJson(json) as ReqCmd + assertEquals("sub1", parsed.subId) + } + + @Test + fun messageToJsonAndBack() { + val json = EoseMessage("sub1").toJson() + assertEquals("""["EOSE","sub1"]""", json) + val parsed = Message.fromJson(json) + assertTrue(parsed is EoseMessage) + assertEquals("sub1", parsed.subId) + } + + @Test + fun machineReadablePrefixFormats() { + assertEquals( + "auth-required: please log in", + MachineReadablePrefix.AUTH_REQUIRED.format("please log in"), + ) + assertEquals("error: boom", MachineReadablePrefix.ERROR.format("boom")) + } + + @Test + fun machineReadablePrefixParses() { + assertEquals( + MachineReadablePrefix.RESTRICTED, + MachineReadablePrefix.parse("restricted: not allowed yet"), + ) + assertEquals( + MachineReadablePrefix.RATE_LIMITED, + MachineReadablePrefix.parse("rate-limited: slow down"), + ) + assertNull(MachineReadablePrefix.parse("no prefix here")) + assertNull(MachineReadablePrefix.parse("unknownword: hello")) + assertNull(MachineReadablePrefix.parse(":leading colon")) + } + + @Test + fun okMessageFactories() { + val ok = OkMessage.accepted("a".repeat(64)) + assertTrue(ok.success) + + val bad = OkMessage.rejected("a".repeat(64), MachineReadablePrefix.AUTH_REQUIRED, "log in") + assertTrue(!bad.success) + assertEquals("auth-required: log in", bad.message) + } + + @Test + fun closedMessageFactory() { + val closed = ClosedMessage.of("sub1", MachineReadablePrefix.RESTRICTED, "nope") + assertEquals("sub1", closed.subId) + assertEquals("restricted: nope", closed.message) + } +} 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 index 76d2ddd789..aeaa885758 100644 --- 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 @@ -531,6 +531,68 @@ class NostrServerAuthTest { assertEquals(1, events.size) assertEquals(pubkey, events[0].event.pubKey) + server.close() + } + + // -- NIP-42: onAuthenticated suspend hook ---------------------------------- + + @Test + fun onAuthenticatedHookRunsAfterSuccessfulAuth() = + runTest { + var hookPubkey: String? = null + val policy = + object : FullAuthPolicy(relayUrl) { + override suspend fun onAuthenticated( + pubKey: String, + event: RelayAuthEvent, + ) { + hookPubkey = pubKey + } + } + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { policy }) + val collector = MessageCollector() + val session = server.connect(collector.sendCallback) + + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + session.receive(authJson(authEvent(challenge = msg.challenge))) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains(",true,")) + assertEquals(pubkey, hookPubkey) + + server.close() + } + + @Test + fun onAuthenticatedThrowTurnsAuthIntoFailingOk() = + runTest { + val policy = + object : FullAuthPolicy(relayUrl) { + override suspend fun onAuthenticated( + pubKey: String, + event: RelayAuthEvent, + ): Unit = throw IllegalStateException("backend rejected user") + } + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { policy }) + val collector = MessageCollector() + val session = server.connect(collector.sendCallback) + + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + session.receive(authJson(authEvent(challenge = msg.challenge))) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains(",false,")) + assertTrue(okMessages[0].contains("backend rejected user")) + // The pubkey is still recorded by accept(); the hook governs the OK, + // not the authenticated-set membership. + assertTrue((session.policy as FullAuthPolicy).authenticatedUsers.contains(pubkey)) + server.close() } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQueryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQueryTest.kt new file mode 100644 index 0000000000..23bdcee0ce --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchQueryTest.kt @@ -0,0 +1,147 @@ +/* + * 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.nip50Search + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SearchQueryTest { + @Test + fun plainTextHasNoExtensions() { + val q = SearchQuery.parse("best nostr apps") + assertEquals("best nostr apps", q.terms) + assertTrue(q.extensions.isEmpty()) + assertNull(q.domain) + assertNull(q.language) + assertNull(q.sentiment) + assertNull(q.nsfw) + // NIP-50 default: nsfw included unless explicitly excluded. + assertTrue(q.nsfwIncluded) + } + + @Test + fun nullAndBlankYieldEmpty() { + assertEquals(SearchQuery.EMPTY, SearchQuery.parse(null)) + assertTrue(SearchQuery.parse(" ").terms.isEmpty()) + assertTrue(SearchQuery.parse(" ").extensions.isEmpty()) + } + + @Test + fun extractsKnownExtensions() { + val q = SearchQuery.parse("bitcoin domain:example.com language:en") + assertEquals("bitcoin", q.terms) + assertEquals("example.com", q.domain) + assertEquals("en", q.language) + } + + @Test + fun includeSpamFlag() { + val q = SearchQuery.parse("memes include:spam") + assertEquals("memes", q.terms) + assertTrue(q.includeSpam) + + assertFalse(SearchQuery.parse("memes").includeSpam) + } + + @Test + fun sentimentParsing() { + assertEquals(Sentiment.NEGATIVE, SearchQuery.parse("vurtnec sentiment:negative").sentiment) + assertEquals(Sentiment.NEUTRAL, SearchQuery.parse("x sentiment:neutral").sentiment) + assertEquals(Sentiment.POSITIVE, SearchQuery.parse("x sentiment:positive").sentiment) + // Unrecognized sentiment value -> null, token still captured raw. + val bad = SearchQuery.parse("x sentiment:angry") + assertNull(bad.sentiment) + assertEquals("angry", bad.extension("sentiment")) + } + + @Test + fun nsfwBooleanParsing() { + assertEquals(false, SearchQuery.parse("x nsfw:false").nsfw) + assertFalse(SearchQuery.parse("x nsfw:false").nsfwIncluded) + assertEquals(true, SearchQuery.parse("x nsfw:true").nsfw) + assertTrue(SearchQuery.parse("x nsfw:true").nsfwIncluded) + // Non-boolean value is not coerced. + assertNull(SearchQuery.parse("x nsfw:maybe").nsfw) + } + + @Test + fun unknownExtensionsArePreserved() { + val q = SearchQuery.parse("hello foo:bar") + assertEquals("hello", q.terms) + assertEquals("bar", q.extension("foo")) + } + + @Test + fun urlsStayInTerms() { + // "https://example.com" must not be parsed as a `https:` extension. + val q = SearchQuery.parse("check https://example.com out") + assertEquals("check https://example.com out", q.terms) + assertTrue(q.extensions.isEmpty()) + } + + @Test + fun uppercaseKeyIsNotAnExtension() { + val q = SearchQuery.parse("NASA:cool stuff") + assertEquals("NASA:cool stuff", q.terms) + assertTrue(q.extensions.isEmpty()) + } + + @Test + fun emptyKeyOrValueStaysInTerms() { + assertEquals(":value", SearchQuery.parse(":value").terms) + assertEquals("key:", SearchQuery.parse("key:").terms) + } + + @Test + fun extensionsOnlyHasEmptyTerms() { + val q = SearchQuery.parse("domain:example.com") + assertTrue(q.isTermsEmpty()) + assertEquals("example.com", q.domain) + } + + @Test + fun duplicateKeyKeepsLast() { + val q = SearchQuery.parse("x language:en language:pt") + assertEquals("pt", q.language) + } + + @Test + fun extensionsCanInterleaveWithTerms() { + val q = SearchQuery.parse("best domain:example.com nostr apps") + assertEquals("best nostr apps", q.terms) + assertEquals("example.com", q.domain) + } + + @Test + fun toSearchStringRoundTrips() { + val q = SearchQuery.parse("best nostr apps domain:example.com language:en") + assertEquals("best nostr apps domain:example.com language:en", q.toSearchString()) + } + + @Test + fun toSearchStringWithExtensionsOnly() { + val q = SearchQuery.parse("nsfw:false") + assertEquals("nsfw:false", q.toSearchString()) + } +}