mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
feat(quartz): server-side relay ergonomics — NIP-50 parser, suspend auth hook, wire helpers
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
This commit is contained in:
+78
-5
@@ -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<ReqCmd> {
|
||||
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
|
||||
```
|
||||
|
||||
+10
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+79
@@ -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 `"<code>: <message>"` 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)]
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -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<AuthCmd>
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
|
||||
+15
-1
@@ -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, ""))
|
||||
}
|
||||
|
||||
|
||||
+9
@@ -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,
|
||||
|
||||
+9
@@ -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 <T : Command> runPolicies(
|
||||
initialCmd: T,
|
||||
operation: (IRelayPolicy, T) -> PolicyResult<T>,
|
||||
|
||||
@@ -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<String, String>,
|
||||
) {
|
||||
/** `true` when the query carries the `include:spam` token (NIP-50: disable spam filtering). */
|
||||
val includeSpam: Boolean
|
||||
get() = extensions[INCLUDE] == SPAM
|
||||
|
||||
/** The `domain:<nip05-domain>` value, or null when not present. */
|
||||
val domain: String?
|
||||
get() = extensions[DOMAIN]
|
||||
|
||||
/** The `language:<ISO-639-1>` value, or null when not present. */
|
||||
val language: String?
|
||||
get() = extensions[LANGUAGE]
|
||||
|
||||
/** The parsed `sentiment:<negative|neutral|positive>` value, or null when absent/unrecognized. */
|
||||
val sentiment: Sentiment?
|
||||
get() = extensions[SENTIMENT]?.let(Sentiment::parse)
|
||||
|
||||
/** The parsed `nsfw:<true|false>` 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<String, String>()
|
||||
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 }
|
||||
}
|
||||
}
|
||||
+101
@@ -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)
|
||||
}
|
||||
}
|
||||
+62
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user