Merge pull request #3443 from vitorpamplona/claude/nip11-document-builder-gq7eeq

Add type-safe DSL builder for NIP-11 relay information documents
This commit is contained in:
Vitor Pamplona
2026-07-01 16:45:31 -04:00
committed by GitHub
5 changed files with 540 additions and 11 deletions
+97 -2
View File
@@ -1,6 +1,6 @@
---
name: quartz-integration
description: Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects.
description: Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects, (9) running a relay on Quartz and serving/building its NIP-11 relay information document (application/nostr+json).
---
# Quartz Integration Guide
@@ -755,7 +755,100 @@ val results = store.query<Event>(Filter(search = "bitcoin"))
---
## 15. Quick Reference
## 15. NIP-11 Relay Information Document
If you're standing up a relay on Quartz's relay-server code, serve your NIP-11
document with the **type-safe builder** — don't hand-write the JSON string.
**Package:** `com.vitorpamplona.quartz.nip11RelayInfo`
```kotlin
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip11RelayInfo.relayInformation
val info =
relayInformation {
name = "sot"
description = "NIP-50 profile search ranked by Nostr web-of-trust"
software = "https://github.com/vitorpamplona/sot"
version = "0.1"
supports(1, 11, 42, 50) // ints → spec-compliant [1,11,42,50] in the JSON
}
val json = info.toJson() // null/empty fields are omitted
```
Serve it at the relay root, branching on the `Accept` header (Ktor example):
```kotlin
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import io.ktor.http.ContentType
get("/") {
val accept = call.request.headers[HttpHeaders.Accept].orEmpty()
if (accept.contains(Nip11RelayInformation.CONTENT_TYPE)) { // "application/nostr+json"
call.respondText(json, ContentType.parse(Nip11RelayInformation.CONTENT_TYPE))
} else {
call.respondText("Open a WebSocket (NIP-01) or send Accept: ${Nip11RelayInformation.CONTENT_TYPE}")
}
}
```
### Nested objects, lists, and enforced limits
```kotlin
val info =
relayInformation {
name = "Paid Relay"
supports(1, 11, 42)
supportsExtensions("nip50-search") // supported_nip_extensions
countries("US", "CA") // relay_countries; also languages(...), tags(...)
nip50Features("profile_search") // the `nip50` field
// limitation { } — camelCase maps to NIP-11 snake_case fields
limitation {
maxSubscriptions = 20
maxFilters = 10
authRequired = true
}
// fees { } — each helper is repeatable
fees {
admission(amount = 1000, unit = "msats")
publication(amount = 100, unit = "msats", kinds = listOf(1, 30023))
}
// retention(...) — call once per policy entry
retention(kinds = listOf(0, 3), count = 1)
}
```
**Keep advertised limits in sync with enforced ones.** If you build a
`RelayLimits` for the server's policy chain, hand the *same* object to the
builder so what you publish can never drift from what you enforce:
```kotlin
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits
val limits = RelayLimits(maxSubscriptions = 20, maxFilters = 10, maxLimit = 500, authRequired = true)
val info =
relayInformation {
name = "My Relay"
supports(1, 11, 42, 45)
limitation(limits) // == limits.toNip11Limitation()
}
```
To load an operator-supplied doc from disk or a string instead of building it,
use `Nip11RelayInformation.fromJson(json)`.
> `geode` (Quartz's standalone relay) builds its default document exactly this
> way — see `geode/.../RelayInfo.kt`.
---
## 16. Quick Reference
| Task | API | Package |
|------|-----|---------|
@@ -779,6 +872,8 @@ val results = store.query<Event>(Filter(search = "bitcoin"))
| NIP-44 encrypt | `signer.nip44Encrypt(text, recipientPubKey)` | `nip01Core.signers` |
| Bech32 decode | `Nip19Parser.uriToRoute("npub1...")` | `nip19Bech32` |
| Bech32 encode | `Nip19Bech32.createNPub(pubKeyHex)` | `nip19Bech32` |
| Build NIP-11 doc | `relayInformation { name = ...; supports(1, 11) }` | `nip11RelayInfo` |
| Serialize NIP-11 doc | `info.toJson()` (media type `Nip11RelayInformation.CONTENT_TYPE`) | `nip11RelayInfo` |
## Common Event Kinds
@@ -23,6 +23,7 @@ package com.vitorpamplona.geode
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip11RelayInfo.relayInformation
import java.io.File
/**
@@ -68,13 +69,13 @@ data class RelayInfo(
/** Pre-built default for `RelayEngine(url = ...)` — advertises the supported NIPs. */
fun default(url: NormalizedRelayUrl): RelayInfo =
RelayInfo(
Nip11RelayInformation(
name = NAME,
description = DESCRIPTION,
software = SOFTWARE,
version = VERSION,
supported_nips = SUPPORTED_NIPS,
),
relayInformation {
name = NAME
description = DESCRIPTION
software = SOFTWARE
version = VERSION
supports(*SUPPORTED_NIPS.toTypedArray())
},
)
/** Loads a NIP-11 doc from a JSON file (e.g. a relay operator's config). */
@@ -31,6 +31,7 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
@@ -87,8 +88,21 @@ object FlexibleIntListSerializer : KSerializer<List<String>?> {
) {
if (value == null) {
encoder.encodeNull()
} else {
listSerializer.serialize(encoder, value)
return
}
require(encoder is JsonEncoder) { "This serializer can only be used with Json format" }
// NIP-11 expects `supported_nips` as an array of integers ([1, 11, 42]),
// so emit numeric entries as JSON numbers. Non-numeric ids (rare, but the
// model tolerates them) fall back to JSON strings so nothing is lost.
val array =
JsonArray(
value.map { nip ->
val asLong = nip.toLongOrNull()
if (asLong != null) JsonPrimitive(asLong) else JsonPrimitive(nip)
},
)
encoder.encodeJsonElement(array)
}
}
@@ -0,0 +1,245 @@
/*
* 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.nip11RelayInfo
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFee
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFees
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationLimitation
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationRetentionData
/**
* Scopes the NIP-11 DSL so an inner builder (e.g. `limitation { }`) can't
* accidentally reach the outer builder's members.
*/
@DslMarker annotation class Nip11DslMarker
/**
* Type-safe builder for a [Nip11RelayInformation] document, so a relay operator
* never has to hand-write the JSON.
*
* Instead of the string that smells:
* ```
* private val NIP11 =
* """{"name":"sot","description":"...","supported_nips":[1,11,42,50],"software":"...","version":"0.1"}"""
* ```
* write:
* ```
* val info = relayInformation {
* name = "sot"
* description = "NIP-50 profile search ranked by Nostr web-of-trust"
* software = "https://github.com/vitorpamplona/sot"
* version = "0.1"
* supports(1, 11, 42, 50)
* }
* ```
* and serve it with [Nip11RelayInformation.toJson] under
* [Nip11RelayInformation.CONTENT_TYPE]. Null/empty fields are omitted, and
* numeric NIP numbers serialize as the JSON integers the spec expects
* (`[1,11,42,50]`, not `["1","11","42","50"]`).
*/
inline fun relayInformation(initializer: Nip11RelayInformationBuilder.() -> Unit): Nip11RelayInformation = Nip11RelayInformationBuilder().apply(initializer).build()
@Nip11DslMarker
class Nip11RelayInformationBuilder {
var id: String? = null
var name: String? = null
var description: String? = null
var banner: String? = null
var icon: String? = null
var pubkey: HexKey? = null
var self: HexKey? = null
var contact: String? = null
var software: String? = null
var version: String? = null
var postingPolicy: String? = null
var privacyPolicy: String? = null
var termsOfService: String? = null
var paymentsUrl: String? = null
private val supportedNips = mutableListOf<String>()
private val supportedNipExtensions = mutableListOf<String>()
private val relayCountries = mutableListOf<String>()
private val languageTags = mutableListOf<String>()
private val tags = mutableListOf<String>()
private val nip50Subfeatures = mutableListOf<String>()
private val supportedGrasps = mutableListOf<String>()
private val retention = mutableListOf<RelayInformationRetentionData>()
private var limitation: RelayInformationLimitation? = null
private var fees: RelayInformationFees? = null
/** Advertise supported NIP numbers, e.g. `supports(1, 11, 42, 50)`. Repeatable. */
fun supports(vararg nips: Int) = apply { nips.forEach { supportedNips.add(it.toString()) } }
/** Advertise supported NIPs by string id (for non-numeric extensions). Repeatable. */
fun supports(vararg nips: String) = apply { supportedNips.addAll(nips) }
/** Advertise `supported_nip_extensions` (draft/experimental features). Repeatable. */
fun supportsExtensions(vararg extensions: String) = apply { supportedNipExtensions.addAll(extensions) }
/** ISO-3166 country codes the relay serves under (`relay_countries`). Repeatable. */
fun countries(vararg codes: String) = apply { relayCountries.addAll(codes) }
/** IETF language tags of content on the relay (`language_tags`). Repeatable. */
fun languages(vararg codes: String) = apply { languageTags.addAll(codes) }
/** Free-form topic tags describing the relay's focus. Repeatable. */
fun tags(vararg values: String) = apply { tags.addAll(values) }
/** NIP-50 search sub-features the relay implements (the `nip50` field). Repeatable. */
fun nip50Features(vararg features: String) = apply { nip50Subfeatures.addAll(features) }
/** GRASP git-server capabilities the relay implements (`supported_grasps`). Repeatable. */
fun grasps(vararg values: String) = apply { supportedGrasps.addAll(values) }
/** Declare the relay's `limitation` object via a nested DSL. */
fun limitation(initializer: LimitationBuilder.() -> Unit) =
apply {
limitation = LimitationBuilder().apply(initializer).build()
}
/**
* Advertise the exact limits the relay actually enforces, keeping what is
* published in sync with what is applied. See [RelayLimits.toNip11Limitation].
*/
fun limitation(limits: RelayLimits) = apply { limitation = limits.toNip11Limitation() }
/** Declare the relay's `fees` object via a nested DSL. */
fun fees(initializer: FeesBuilder.() -> Unit) =
apply {
fees = FeesBuilder().apply(initializer).build()
}
/** Add a data-retention policy entry (`retention`). Call multiple times for multiple entries. */
fun retention(
kinds: List<Int>? = null,
time: Int? = null,
count: Int? = null,
) = apply {
retention.add(RelayInformationRetentionData(kinds?.let { ArrayList(it) }, time, count))
}
fun build(): Nip11RelayInformation =
Nip11RelayInformation(
id = id,
name = name,
description = description,
banner = banner,
icon = icon,
pubkey = pubkey,
self = self,
contact = contact,
supported_nips = supportedNips.ifEmpty { null }?.toList(),
supported_nip_extensions = supportedNipExtensions.ifEmpty { null }?.toList(),
software = software,
version = version,
limitation = limitation,
relay_countries = relayCountries.ifEmpty { null }?.toList(),
language_tags = languageTags.ifEmpty { null }?.toList(),
tags = tags.ifEmpty { null }?.toList(),
posting_policy = postingPolicy,
privacy_policy = privacyPolicy,
terms_of_service = termsOfService,
payments_url = paymentsUrl,
retention = retention.ifEmpty { null }?.toList(),
fees = fees,
nip50 = nip50Subfeatures.ifEmpty { null }?.toList(),
supported_grasps = supportedGrasps.ifEmpty { null }?.toList(),
)
@Nip11DslMarker
class LimitationBuilder {
var maxMessageLength: Int? = null
var maxSubscriptions: Int? = null
var maxFilters: Int? = null
var maxLimit: Int? = null
var defaultLimit: Int? = null
var maxSubidLength: Int? = null
var minPrefix: Int? = null
var maxEventTags: Int? = null
var maxContentLength: Int? = null
var minPowDifficulty: Int? = null
var authRequired: Boolean? = null
var paymentRequired: Boolean? = null
var restrictedWrites: Boolean? = null
var createdAtLowerLimit: Int? = null
var createdAtUpperLimit: Int? = null
fun build(): RelayInformationLimitation =
RelayInformationLimitation(
max_message_length = maxMessageLength,
max_subscriptions = maxSubscriptions,
max_filters = maxFilters,
max_limit = maxLimit,
default_limit = defaultLimit,
max_subid_length = maxSubidLength,
min_prefix = minPrefix,
max_event_tags = maxEventTags,
max_content_length = maxContentLength,
min_pow_difficulty = minPowDifficulty,
auth_required = authRequired,
payment_required = paymentRequired,
restricted_writes = restrictedWrites,
created_at_lower_limit = createdAtLowerLimit,
created_at_upper_limit = createdAtUpperLimit,
)
}
@Nip11DslMarker
class FeesBuilder {
private val admission = mutableListOf<RelayInformationFee>()
private val subscription = mutableListOf<RelayInformationFee>()
private val publication = mutableListOf<RelayInformationFee>()
/** A one-time fee to be allowed to write to the relay. Repeatable. */
fun admission(
amount: Int? = null,
unit: String? = null,
period: Int? = null,
kinds: List<Int>? = null,
) = apply { admission.add(RelayInformationFee(amount, unit, period, kinds)) }
/** A recurring fee to keep writing to the relay. Repeatable. */
fun subscription(
amount: Int? = null,
unit: String? = null,
period: Int? = null,
kinds: List<Int>? = null,
) = apply { subscription.add(RelayInformationFee(amount, unit, period, kinds)) }
/** A per-event fee to publish. Repeatable (e.g. one entry per kind group). */
fun publication(
amount: Int? = null,
unit: String? = null,
period: Int? = null,
kinds: List<Int>? = null,
) = apply { publication.add(RelayInformationFee(amount, unit, period, kinds)) }
fun build(): RelayInformationFees =
RelayInformationFees(
admission = admission.ifEmpty { null }?.toList(),
subscription = subscription.ifEmpty { null }?.toList(),
publication = publication.ifEmpty { null }?.toList(),
)
}
}
@@ -0,0 +1,174 @@
/*
* 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.nip11RelayInfo
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class Nip11RelayInformationBuilderTest {
@Test
fun buildsTheSotExampleWithoutHandWrittenJson() {
val info =
relayInformation {
name = "sot"
description = "NIP-50 profile search ranked by Nostr web-of-trust"
software = "https://github.com/vitorpamplona/sot"
version = "0.1"
supports(1, 11, 42, 50)
}
assertEquals("sot", info.name)
assertEquals(listOf("1", "11", "42", "50"), info.supported_nips)
// Numeric NIPs serialize as JSON integers, exactly as the hand-written string had them.
val json = info.toJson()
assertTrue(json.contains("\"supported_nips\":[1,11,42,50]"), json)
assertTrue(json.contains("\"name\":\"sot\""), json)
// Unset fields stay out of the document.
assertTrue(!json.contains("\"limitation\""), json)
assertTrue(!json.contains("\"fees\""), json)
// Round-trips back to the same document.
assertEquals(info, Nip11RelayInformation.fromJson(json))
}
@Test
fun emptyBuilderProducesAllNullFields() {
val info = relayInformation {}
assertNull(info.name)
assertNull(info.supported_nips)
assertNull(info.limitation)
assertNull(info.fees)
assertEquals("{}", info.toJson())
}
@Test
fun nestedLimitationDsl() {
val info =
relayInformation {
name = "R"
limitation {
maxSubscriptions = 20
maxFilters = 10
maxLimit = 500
authRequired = true
}
}
assertEquals(20, info.limitation?.max_subscriptions)
assertEquals(10, info.limitation?.max_filters)
assertEquals(500, info.limitation?.max_limit)
assertEquals(true, info.limitation?.auth_required)
// Untouched limitation fields remain null (and are omitted from JSON).
assertNull(info.limitation?.max_message_length)
}
@Test
fun limitationFromEnforcedRelayLimitsStaysInSync() {
val limits = RelayLimits(maxSubscriptions = 20, maxFilters = 10, maxLimit = 500, authRequired = true)
val info =
relayInformation {
name = "R"
limitation(limits)
}
assertEquals(limits.toNip11Limitation(), info.limitation)
}
@Test
fun feesDslAccumulatesEntries() {
val info =
relayInformation {
name = "Paid Relay"
fees {
admission(amount = 1000, unit = "msats")
publication(amount = 100, unit = "msats", kinds = listOf(1, 30023))
}
}
assertEquals(
1000,
info.fees
?.admission
?.first()
?.amount,
)
assertEquals(
"msats",
info.fees
?.admission
?.first()
?.unit,
)
assertEquals(
listOf(1, 30023),
info.fees
?.publication
?.first()
?.kinds,
)
assertNull(info.fees?.subscription)
}
@Test
fun retentionEntriesAreRepeatable() {
val info =
relayInformation {
name = "R"
retention(kinds = listOf(0, 3), count = 1)
retention(time = 3600)
}
assertEquals(2, info.retention?.size)
assertEquals(arrayListOf(0, 3), info.retention?.get(0)?.kinds)
assertEquals(1, info.retention?.get(0)?.count)
assertEquals(3600, info.retention?.get(1)?.time)
}
@Test
fun listHelpersAreRepeatableAndCollapseWhenEmpty() {
val info =
relayInformation {
name = "R"
supports(1)
supports(11, 42)
supports("custom-ext")
countries("US", "CA")
languages("en")
tags("search")
}
assertEquals(listOf("1", "11", "42", "custom-ext"), info.supported_nips)
assertEquals(listOf("US", "CA"), info.relay_countries)
assertEquals(listOf("en"), info.language_tags)
assertEquals(listOf("search"), info.tags)
// Untouched lists collapse to null instead of empty arrays.
assertNull(info.nip50)
assertNull(info.supported_grasps)
// Mixed numeric + string ids: numbers stay numbers, the extension stays a string.
assertTrue(info.toJson().contains("\"supported_nips\":[1,11,42,\"custom-ext\"]"), info.toJson())
}
}