feat(quartz): NIP-86 event allow list, roles and claims; NIP-43 roles

NIP-86 server semantics now follow the spec: banpubkey/banevent drop the
entry from the allow list, allowpubkey/allowevent lift the ban, and the
un* methods never touch the opposite list. `allowevent` no longer means
"unban": it adds the id to a new event allow list (with reason), which
BanListPolicy honours by accepting that event without the pubkey/kind
checks. An empty event allow list changes nothing.

New methods on both client and server: unbanevent, unallowevent,
listallowedevents, listdisallowedkinds, createrole/editrole/deleterole,
assignrole/unassignrole, listclaims/createclaim/deleteclaim. Role and
claim state lives in BanStore and is persisted in geode's state file.
Requests now always serialize `params`, even when empty.

NIP-43: kind 33534 RelayRoleEvent (label/description/color/order),
role ids on kind 13534 member tags, join requests (28934) require the
claim and carry the `-` tag, and kind 28935 is deprecated in favour of
NIP-86 createclaim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc
This commit is contained in:
Claude
2026-09-27 17:05:19 +00:00
parent 5be5e3cae4
commit 2465474d0d
30 changed files with 1627 additions and 32 deletions
@@ -21,6 +21,7 @@
package com.vitorpamplona.geode.config
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -36,7 +37,9 @@ import java.nio.file.StandardCopyOption
* - the live NIP-11 info doc (so `changerelayname/description/icon`
* survive a restart), and
* - the NIP-86 ban / allow / kind lists for pubkeys, events, and
* kinds (NIP-86 admin RPC mutates these directly).
* kinds (NIP-86 admin RPC mutates these directly), and
* - the NIP-43 role definitions, role assignments and invite codes
* managed through the NIP-86 role / claim methods.
*
* One JSON file per relay. Lives next to the SQLite event store by
* convention, but the path is configurable independently via
@@ -133,6 +136,33 @@ data class RuntimeConfigData(
val bannedEvents: List<BannedEntry> = emptyList(),
val allowedKinds: List<Int> = emptyList(),
val disallowedKinds: List<Int> = emptyList(),
val allowedEvents: List<BannedEntry> = emptyList(),
val roles: List<RoleEntry> = emptyList(),
val roleAssignments: List<RoleAssignmentEntry> = emptyList(),
val claims: List<String> = emptyList(),
)
/** A NIP-43 role definition (NIP-86 `createrole` params). */
@Serializable
data class RoleEntry(
val id: String,
val label: String? = null,
val description: String? = null,
val color: Int? = null,
val order: Int? = null,
) {
fun toRole() = RelayRole(id, label, description, color, order)
companion object {
fun of(role: RelayRole) = RoleEntry(role.id, role.label, role.description, role.color, role.order)
}
}
/** The NIP-43 role ids assigned to one pubkey. */
@Serializable
data class RoleAssignmentEntry(
val pubkey: String,
val roles: List<String>,
)
@Serializable
@@ -149,6 +179,10 @@ fun RuntimeConfigData.seedInto(banStore: BanStore) {
bannedEvents = bannedEvents.map { it.key to it.reason },
allowedKinds = allowedKinds,
disallowedKinds = disallowedKinds,
allowedEvents = allowedEvents.map { it.key to it.reason },
roles = roles.map { it.toRole() },
roleAssignments = roleAssignments.map { it.pubkey to it.roles },
claims = claims,
)
}
@@ -164,4 +198,8 @@ fun snapshotOf(
bannedEvents = banStore.listBannedEvents().map { (k, r) -> BannedEntry(k, r) },
allowedKinds = banStore.listAllowedKinds(),
disallowedKinds = banStore.listDisallowedKinds(),
allowedEvents = banStore.listAllowedEvents().map { (k, r) -> BannedEntry(k, r) },
roles = banStore.listRoles().map { RoleEntry.of(it) },
roleAssignments = banStore.listRoleAssignments().map { (pk, ids) -> RoleAssignmentEntry(pk, ids) },
claims = banStore.listClaims(),
)
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -39,6 +40,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonPrimitive
@@ -125,7 +127,7 @@ class Nip86EndToEndTest {
fun supportedMethodsListsTheServersMethods() {
rpc(Nip86Request.supportedMethods(), admin).use {
assertEquals(200, it.code)
val json = JsonMapper.fromJson<com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response>(it.body.string())
val json = JsonMapper.fromJson<Nip86Response>(it.body.string())
val arr = json.result as JsonArray
val names = arr.map { e -> e.jsonPrimitive.content }
assertTrue(names.contains("supportedmethods"))
@@ -169,7 +171,7 @@ class Nip86EndToEndTest {
// Admin bans them.
rpc(Nip86Request.banPubkey(targetUser.pubKey, "spam"), admin).use {
assertEquals(200, it.code)
val resp = JsonMapper.fromJson<com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response>(it.body.string())
val resp = JsonMapper.fromJson<Nip86Response>(it.body.string())
assertEquals(true, (resp.result as JsonPrimitive).boolean)
}
@@ -178,6 +180,33 @@ class Nip86EndToEndTest {
assertEquals(false, after, "BanListPolicy must reject events from banned pubkeys")
}
@Test
fun allowEventLetsOneEventPastABanUntilUnallowed() =
runBlocking {
val relayUrl = server.url.normalizeRelayUrl()
rpc(Nip86Request.banPubkey(targetUser.pubKey, "spam"), admin).use { assertEquals(200, it.code) }
// Admin approves one specific event from the banned author.
val approved = targetUser.sign(TextNoteEvent.build("approved"))
rpc(Nip86Request.allowEvent(approved.id, "reviewed"), admin).use { assertEquals(200, it.code) }
assertEquals(true, nostrClient.publishAndConfirm(approved, setOf(relayUrl)), "allow-listed event bypasses the pubkey ban")
// Any other event from that author is still blocked.
val other = targetUser.sign(TextNoteEvent.build("other"))
assertEquals(false, nostrClient.publishAndConfirm(other, setOf(relayUrl)))
rpc(Nip86Request.listAllowedEvents(), admin).use {
val resp = JsonMapper.fromJson<Nip86Response>(it.body.string())
val ids = (resp.result as JsonArray).map { e -> (e as JsonObject)["id"]!!.jsonPrimitive.content }
assertEquals(listOf(approved.id), ids)
}
// unallowevent drops the exemption without banning the event.
rpc(Nip86Request.unallowEvent(approved.id), admin).use { assertEquals(200, it.code) }
assertTrue(!relay.banStore.isAllowedEvent(approved.id))
assertTrue(!relay.banStore.isBannedEvent(approved.id))
}
@Test
fun changeRelayNameFlowsToNip11Endpoint() {
rpc(Nip86Request.changeRelayName("renamed-by-admin"), admin).use {
@@ -24,6 +24,7 @@ import com.vitorpamplona.geode.RelayEngine
import com.vitorpamplona.geode.RelayInfo
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
import java.io.File
import java.nio.file.Files
import kotlin.test.AfterTest
@@ -124,6 +125,47 @@ class RuntimeConfigTest {
}
}
@Test
fun eventAllowListRolesAndClaimsSurviveRestart() {
val pk = "b".repeat(64)
val allowedId = "c".repeat(64)
val r1 = relayPersisted()
try {
r1.banStore.allowEvent(allowedId, "approved")
r1.banStore.createRole(RelayRole("mod", label = "Moderator", description = "keeps order", color = 120, order = 2))
r1.banStore.assignRole(pk, "mod")
r1.banStore.createClaim("invite-123")
} finally {
r1.close()
}
val r2 = relayPersisted()
try {
assertEquals(listOf(allowedId to "approved"), r2.banStore.listAllowedEvents())
assertTrue(r2.banStore.isAllowedEvent(allowedId))
assertEquals(RelayRole("mod", "Moderator", "keeps order", 120, 2), r2.banStore.getRole("mod"))
assertEquals(listOf("mod"), r2.banStore.rolesOf(pk))
assertEquals(listOf("invite-123"), r2.banStore.listClaims())
} finally {
r2.close()
}
}
@Test
fun oldStateFileWithoutNewSectionsStillLoads() {
// A snapshot written before the event allow list / roles / claims existed.
stateFile.writeText("""{"info":{"name":"old"},"bannedEvents":[{"key":"${"d".repeat(64)}","reason":"x"}]}""")
val r = relayPersisted()
try {
assertTrue(r.banStore.isBannedEvent("d".repeat(64)))
assertEquals(emptyList(), r.banStore.listAllowedEvents())
assertEquals(emptyList(), r.banStore.listRoles())
assertEquals(emptyList(), r.banStore.listClaims())
} finally {
r.close()
}
}
@Test
fun corruptStateFileIsTolerated() {
stateFile.writeText("not valid json {")
@@ -150,6 +150,7 @@ import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEv
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
@@ -614,6 +615,7 @@ object KindNames {
Ps1SaveEvent.KIND to KindName("PS1 Save", null),
NwcInfoEvent.KIND to KindName("NWC Info", "47"),
RelayMembershipListEvent.KIND to KindName("Relay Memberships", "43"),
RelayRoleEvent.KIND to KindName("Relay Role", "43"),
RootSiteEvent.KIND to KindName("Website Root", "5A"),
RootNappletEvent.KIND to KindName("Napplet Root", "5D"),
CashuWalletEvent.KIND to KindName("Cashu Wallet", "60"),
@@ -27,7 +27,15 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-43 kind 28935: a relay-signed ephemeral event carrying an invite code.
*
* Removed from NIP-43: invite codes are now minted with the NIP-86
* `createclaim` method ([com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request.createClaim]).
* Kept so events from relays that still emit it keep parsing.
*/
@Immutable
@Deprecated("Removed from NIP-43. Mint invite codes with the NIP-86 `createclaim` method (Nip86Request.createClaim).")
class RelayInviteRequestEvent(
id: HexKey,
pubKey: HexKey,
@@ -24,9 +24,17 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip70ProtectedEvts.protect
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-43 kind 28934: a request to join a relay. Must carry a NIP-70 `-` tag and
* a `claim` tag with the invite code; the relay answers with an `OK`. Invite
* codes are minted by the relay (NIP-86 `createclaim`), not requested with the
* deprecated kind 28935.
*/
@Immutable
class RelayJoinRequestEvent(
id: HexKey,
@@ -42,12 +50,16 @@ class RelayJoinRequestEvent(
const val KIND = 28934
fun build(
claim: String? = null,
claim: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RelayJoinRequestEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
claim?.let { claim(it) }
initializer()
): EventTemplate<RelayJoinRequestEvent> {
require(claim.isNotBlank()) { "NIP-43 join requests require an invite code (claim)" }
return eventTemplate(KIND, "", createdAt) {
protect()
claim(claim)
initializer()
}
}
}
}
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember
import com.vitorpamplona.quartz.nip70ProtectedEvts.protect
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -39,6 +40,9 @@ class RelayMembershipListEvent(
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun members() = tags.members()
/** Members with the role ids (NIP-43 kind 33534 `d` tags) the relay assigned to each. */
fun membersWithRoles() = tags.membersWithRoles()
companion object {
const val KIND = 13534
@@ -51,5 +55,16 @@ class RelayMembershipListEvent(
members(members)
initializer()
}
/** Like [build], but each member may carry its assigned role ids. */
fun buildWithRoles(
members: List<RelayMember>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RelayMembershipListEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
protect()
membersWithRoles(members)
initializer()
}
}
}
@@ -23,5 +23,8 @@ package com.vitorpamplona.quartz.nip43RelayMembers.list
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.MemberTag
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember
fun TagArrayBuilder<RelayMembershipListEvent>.members(pubKeys: List<HexKey>) = addAll(MemberTag.assemble(pubKeys))
fun TagArrayBuilder<RelayMembershipListEvent>.membersWithRoles(members: List<RelayMember>) = addAll(members.map { MemberTag.assemble(it) })
@@ -21,6 +21,9 @@
package com.vitorpamplona.quartz.nip43RelayMembers.list
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastMapNotNullDense
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.MemberTag
fun TagArray.members() = mapNotNull(MemberTag::parse)
fun TagArray.members() = fastMapNotNullDense(MemberTag::parse)
fun TagArray.membersWithRoles() = fastMapNotNullDense(MemberTag::parseMember)
@@ -20,10 +20,27 @@
*/
package com.vitorpamplona.quartz.nip43RelayMembers.list.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* A kind 13534 entry: the member's pubkey plus the NIP-43 role ids (kind
* 33534 `d` tags) assigned to them, in tag order. [roles] is empty for a
* member without roles, which is also what pre-roles relays publish.
*/
@Immutable
data class RelayMember(
val pubKey: HexKey,
val roles: List<String> = emptyList(),
)
/**
* NIP-43 `["member", <pubkey>, <role-id>...]`. Role ids after the pubkey are
* optional: [parse] ignores them (backward compatible) and [parseMember]
* returns them.
*/
class MemberTag {
companion object {
const val TAG_NAME = "member"
@@ -35,8 +52,26 @@ class MemberTag {
return tag[1]
}
fun parseMember(tag: Array<String>): RelayMember? {
val pubKey = parse(tag) ?: return null
if (tag.size <= 2) return RelayMember(pubKey)
val roles = ArrayList<String>(tag.size - 2)
for (i in 2 until tag.size) {
val role = tag[i]
if (role.isNotEmpty() && role !in roles) roles.add(role)
}
return RelayMember(pubKey, roles)
}
fun assemble(pubKey: HexKey) = arrayOf(TAG_NAME, pubKey)
fun assemble(
pubKey: HexKey,
roles: List<String>,
): Array<String> = arrayOf(TAG_NAME, pubKey) + roles
fun assemble(member: RelayMember) = assemble(member.pubKey, member.roles)
fun assemble(pubKeys: List<HexKey>) = pubKeys.map { assemble(it) }
}
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip43RelayMembers.roles
import androidx.compose.runtime.Immutable
/**
* A NIP-43 role as the relay defines it: the content of a kind 33534
* [RelayRoleEvent], and the `[id, label, description, color, order]` params
* of the NIP-86 `createrole` / `editrole` methods.
*
* [color] is a hue in `0..360` (see [isValidHue]); [order] is a display-only
* sort key. Everything but [id] is optional.
*/
@Immutable
data class RelayRole(
val id: String,
val label: String? = null,
val description: String? = null,
val color: Int? = null,
val order: Int? = null,
) {
companion object {
const val MIN_HUE = 0
const val MAX_HUE = 360
fun isValidHue(hue: Int) = hue in MIN_HUE..MAX_HUE
}
}
@@ -0,0 +1,89 @@
/*
* 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.nip43RelayMembers.roles
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip70ProtectedEvts.protect
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-43 kind 33534: a role the relay defines and may assign to members.
*
* Signed by the relay's NIP-11 `self` pubkey and protected (NIP-70 `-` tag).
* The `d` tag is the role id — the value a kind 13534 `member` tag lists after
* the pubkey. `label`, `description`, `color` (a hue, 0..360) and `order`
* (display-only sort key) are optional.
*/
@Immutable
class RelayRoleEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun roleId() = dTag()
fun label() = tags.roleLabel()
fun description() = tags.roleDescription()
/** Hue in `0..360`, or null when absent or out of range. */
fun color() = tags.roleColor()
fun order() = tags.roleOrder()
fun role() =
RelayRole(
id = roleId(),
label = label(),
description = description(),
color = color(),
order = order(),
)
companion object {
const val KIND = 33534
fun build(
role: RelayRole,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RelayRoleEvent>.() -> Unit = {},
) = eventTemplate<RelayRoleEvent>(KIND, "", createdAt) {
protect()
dTag(role.id)
role.label?.let { roleLabel(it) }
role.description?.let { roleDescription(it) }
role.color?.let {
require(RelayRole.isValidHue(it)) { "role color must be a hue between 0 and 360, got $it" }
roleColor(it)
}
role.order?.let { roleOrder(it) }
initializer()
}
}
}
@@ -0,0 +1,35 @@
/*
* 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.nip43RelayMembers.roles
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleColorTag
import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleDescriptionTag
import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleLabelTag
import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleOrderTag
fun TagArrayBuilder<RelayRoleEvent>.roleLabel(label: String) = addUnique(RoleLabelTag.assemble(label))
fun TagArrayBuilder<RelayRoleEvent>.roleDescription(description: String) = addUnique(RoleDescriptionTag.assemble(description))
fun TagArrayBuilder<RelayRoleEvent>.roleColor(hue: Int) = addUnique(RoleColorTag.assemble(hue))
fun TagArrayBuilder<RelayRoleEvent>.roleOrder(order: Int) = addUnique(RoleOrderTag.assemble(order))
@@ -0,0 +1,36 @@
/*
* 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.nip43RelayMembers.roles
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleColorTag
import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleDescriptionTag
import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleLabelTag
import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleOrderTag
fun TagArray.roleLabel() = fastFirstNotNullOfOrNull(RoleLabelTag::parse)
fun TagArray.roleDescription() = fastFirstNotNullOfOrNull(RoleDescriptionTag::parse)
fun TagArray.roleColor() = fastFirstNotNullOfOrNull(RoleColorTag::parse)
fun TagArray.roleOrder() = fastFirstNotNullOfOrNull(RoleOrderTag::parse)
@@ -0,0 +1,42 @@
/*
* 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.nip43RelayMembers.roles.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
import com.vitorpamplona.quartz.utils.ensure
/** NIP-43 kind 33534 `color` tag: a hue from 0 to 360. Non-numeric or out-of-range values parse as null. */
class RoleColorTag {
companion object {
const val TAG_NAME = "color"
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val hue = tag[1].trim().toIntOrNull() ?: return null
ensure(RelayRole.isValidHue(hue)) { return null }
return hue
}
fun assemble(hue: Int) = arrayOf(TAG_NAME, hue.toString())
}
}
@@ -0,0 +1,40 @@
/*
* 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.nip43RelayMembers.roles.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** NIP-43 kind 33534 `description` tag. */
class RoleDescriptionTag {
companion object {
const val TAG_NAME = "description"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(description: String) = arrayOf(TAG_NAME, description)
}
}
@@ -0,0 +1,40 @@
/*
* 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.nip43RelayMembers.roles.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** NIP-43 kind 33534 `label` tag: the role's display name. */
class RoleLabelTag {
companion object {
const val TAG_NAME = "label"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(label: String) = arrayOf(TAG_NAME, label)
}
}
@@ -0,0 +1,39 @@
/*
* 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.nip43RelayMembers.roles.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** NIP-43 kind 33534 `order` tag: a display-only integer sort key. */
class RoleOrderTag {
companion object {
const val TAG_NAME = "order"
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return tag[1].trim().toIntOrNull()
}
fun assemble(order: Int) = arrayOf(TAG_NAME, order.toString())
}
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
@@ -84,16 +85,31 @@ class Nip86Client(
return JsonMapper.fromJson<List<BannedEvent>>(result.toString())
}
fun parseAllowedEvents(response: Nip86Response): List<AllowedEvent>? {
val result = response.result ?: return null
return JsonMapper.fromJson<List<AllowedEvent>>(result.toString())
}
fun parseEventsNeedingModeration(response: Nip86Response): List<EventNeedingModeration>? {
val result = response.result ?: return null
return JsonMapper.fromJson<List<EventNeedingModeration>>(result.toString())
}
fun parseAllowedKinds(response: Nip86Response): List<Int>? {
fun parseAllowedKinds(response: Nip86Response): List<Int>? = parseKinds(response)
fun parseDisallowedKinds(response: Nip86Response): List<Int>? = parseKinds(response)
private fun parseKinds(response: Nip86Response): List<Int>? {
val result = response.result ?: return null
return (result as? JsonArray)?.map { it.jsonPrimitive.int }
}
/** `listclaims` result: a plain array of NIP-43 invite codes. */
fun parseClaims(response: Nip86Response): List<String>? {
val result = response.result ?: return null
return (result as? JsonArray)?.map { it.jsonPrimitive.content }
}
fun parseBlockedIps(response: Nip86Response): List<BlockedIp>? {
val result = response.result ?: return null
return JsonMapper.fromJson<List<BlockedIp>>(result.toString())
@@ -134,13 +150,59 @@ class Nip86Client(
reason: String? = null,
) = Nip86Request.allowEvent(eventId, reason)
fun unallowEventRequest(
eventId: String,
reason: String? = null,
) = Nip86Request.unallowEvent(eventId, reason)
fun banEventRequest(
eventId: String,
reason: String? = null,
) = Nip86Request.banEvent(eventId, reason)
fun unbanEventRequest(
eventId: String,
reason: String? = null,
) = Nip86Request.unbanEvent(eventId, reason)
fun listBannedEventsRequest() = Nip86Request.listBannedEvents()
fun listAllowedEventsRequest() = Nip86Request.listAllowedEvents()
fun createRoleRequest(
id: String,
label: String? = null,
description: String? = null,
color: Int? = null,
order: Int? = null,
) = Nip86Request.createRole(id, label, description, color, order)
fun editRoleRequest(
id: String,
label: String? = null,
description: String? = null,
color: Int? = null,
order: Int? = null,
) = Nip86Request.editRole(id, label, description, color, order)
fun deleteRoleRequest(id: String) = Nip86Request.deleteRole(id)
fun assignRoleRequest(
pubkey: String,
roleId: String,
) = Nip86Request.assignRole(pubkey, roleId)
fun unassignRoleRequest(
pubkey: String,
roleId: String,
) = Nip86Request.unassignRole(pubkey, roleId)
fun listClaimsRequest() = Nip86Request.listClaims()
fun createClaimRequest(claim: String) = Nip86Request.createClaim(claim)
fun deleteClaimRequest(claim: String) = Nip86Request.deleteClaim(claim)
fun changeRelayNameRequest(newName: String) = Nip86Request.changeRelayName(newName)
fun changeRelayDescriptionRequest(newDescription: String) = Nip86Request.changeRelayDescription(newDescription)
@@ -153,6 +215,8 @@ class Nip86Client(
fun listAllowedKindsRequest() = Nip86Request.listAllowedKinds()
fun listDisallowedKindsRequest() = Nip86Request.listDisallowedKinds()
fun blockIpRequest(
ip: String,
reason: String? = null,
@@ -28,16 +28,36 @@ object Nip86Method {
const val ALLOW_PUBKEY = "allowpubkey"
const val UNALLOW_PUBKEY = "unallowpubkey"
const val LIST_ALLOWED_PUBKEYS = "listallowedpubkeys"
const val CREATE_ROLE = "createrole"
const val EDIT_ROLE = "editrole"
const val DELETE_ROLE = "deleterole"
const val ASSIGN_ROLE = "assignrole"
const val UNASSIGN_ROLE = "unassignrole"
const val LIST_CLAIMS = "listclaims"
const val CREATE_CLAIM = "createclaim"
const val DELETE_CLAIM = "deleteclaim"
const val LIST_EVENTS_NEEDING_MODERATION = "listeventsneedingmoderation"
/** Adds an event to the relay's allow list (and removes it from the ban list). */
const val ALLOW_EVENT = "allowevent"
/** Removes an event from the allow list without banning it. */
const val UNALLOW_EVENT = "unallowevent"
/** Bans an event (and removes it from the allow list). */
const val BAN_EVENT = "banevent"
/** Removes an event from the ban list without allow-listing it. */
const val UNBAN_EVENT = "unbanevent"
const val LIST_BANNED_EVENTS = "listbannedevents"
const val LIST_ALLOWED_EVENTS = "listallowedevents"
const val CHANGE_RELAY_NAME = "changerelayname"
const val CHANGE_RELAY_DESCRIPTION = "changerelaydescription"
const val CHANGE_RELAY_ICON = "changerelayicon"
const val ALLOW_KIND = "allowkind"
const val DISALLOW_KIND = "disallowkind"
const val LIST_ALLOWED_KINDS = "listallowedkinds"
const val LIST_DISALLOWED_KINDS = "listdisallowedkinds"
const val BLOCK_IP = "blockip"
const val UNBLOCK_IP = "unblockip"
const val LIST_BLOCKED_IPS = "listblockedips"
@@ -20,14 +20,20 @@
*/
package com.vitorpamplona.quartz.nip86RelayManagement.rpc
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
@Serializable
class Nip86Request(
val method: String,
// NIP-86 requests always carry `params`, even when empty (`[]`).
@OptIn(ExperimentalSerializationApi::class)
@EncodeDefault
val params: JsonArray = JsonArray(emptyList()),
) {
companion object {
@@ -78,6 +84,74 @@ class Nip86Request(
method = Nip86Method.LIST_ALLOWED_PUBKEYS,
)
/**
* NIP-86 `createrole`: `[id, label, description, color, order]`. Every
* position is always sent so the relay can read them positionally; a
* missing optional value goes out as JSON `null`. [color] is a hue
* (0..360) and [order] a display-only sort key, both sent as numbers.
*/
fun createRole(
id: String,
label: String? = null,
description: String? = null,
color: Int? = null,
order: Int? = null,
) = Nip86Request(
method = Nip86Method.CREATE_ROLE,
params = roleParams(id, label, description, color, order),
)
/** NIP-86 `editrole`: same `[id, label, description, color, order]` shape as [createRole]. */
fun editRole(
id: String,
label: String? = null,
description: String? = null,
color: Int? = null,
order: Int? = null,
) = Nip86Request(
method = Nip86Method.EDIT_ROLE,
params = roleParams(id, label, description, color, order),
)
fun deleteRole(id: String) =
Nip86Request(
method = Nip86Method.DELETE_ROLE,
params = buildJsonArray { add(JsonPrimitive(id)) },
)
fun assignRole(
pubkey: String,
roleId: String,
) = Nip86Request(
method = Nip86Method.ASSIGN_ROLE,
params = buildParams(pubkey, roleId),
)
fun unassignRole(
pubkey: String,
roleId: String,
) = Nip86Request(
method = Nip86Method.UNASSIGN_ROLE,
params = buildParams(pubkey, roleId),
)
fun listClaims() =
Nip86Request(
method = Nip86Method.LIST_CLAIMS,
)
fun createClaim(claim: String) =
Nip86Request(
method = Nip86Method.CREATE_CLAIM,
params = buildJsonArray { add(JsonPrimitive(claim)) },
)
fun deleteClaim(claim: String) =
Nip86Request(
method = Nip86Method.DELETE_CLAIM,
params = buildJsonArray { add(JsonPrimitive(claim)) },
)
fun listEventsNeedingModeration() =
Nip86Request(
method = Nip86Method.LIST_EVENTS_NEEDING_MODERATION,
@@ -91,6 +165,14 @@ class Nip86Request(
params = buildParams(eventId, reason),
)
fun unallowEvent(
eventId: String,
reason: String? = null,
) = Nip86Request(
method = Nip86Method.UNALLOW_EVENT,
params = buildParams(eventId, reason),
)
fun banEvent(
eventId: String,
reason: String? = null,
@@ -99,11 +181,24 @@ class Nip86Request(
params = buildParams(eventId, reason),
)
fun unbanEvent(
eventId: String,
reason: String? = null,
) = Nip86Request(
method = Nip86Method.UNBAN_EVENT,
params = buildParams(eventId, reason),
)
fun listBannedEvents() =
Nip86Request(
method = Nip86Method.LIST_BANNED_EVENTS,
)
fun listAllowedEvents() =
Nip86Request(
method = Nip86Method.LIST_ALLOWED_EVENTS,
)
fun changeRelayName(newName: String) =
Nip86Request(
method = Nip86Method.CHANGE_RELAY_NAME,
@@ -139,6 +234,11 @@ class Nip86Request(
method = Nip86Method.LIST_ALLOWED_KINDS,
)
fun listDisallowedKinds() =
Nip86Request(
method = Nip86Method.LIST_DISALLOWED_KINDS,
)
fun blockIp(
ip: String,
reason: String? = null,
@@ -158,6 +258,21 @@ class Nip86Request(
method = Nip86Method.LIST_BLOCKED_IPS,
)
private fun roleParams(
id: String,
label: String?,
description: String?,
color: Int?,
order: Int?,
): JsonArray =
buildJsonArray {
add(JsonPrimitive(id))
add(label?.let { JsonPrimitive(it) } ?: JsonNull)
add(description?.let { JsonPrimitive(it) } ?: JsonNull)
add(color?.let { JsonPrimitive(it) } ?: JsonNull)
add(order?.let { JsonPrimitive(it) } ?: JsonNull)
}
private fun buildParams(
primary: String,
reason: String? = null,
@@ -47,6 +47,12 @@ class BannedEvent(
val reason: String? = null,
)
@Serializable
class AllowedEvent(
val id: String,
val reason: String? = null,
)
@Serializable
class EventNeedingModeration(
val id: String,
@@ -30,6 +30,15 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
* non-empty pubkey allow list, or kind disallowed / not in the kind
* allow list.
*
* An event id on the NIP-86 event allow list (`allowevent`) is an
* explicit, per-event operator approval, so it is accepted without
* consulting the pubkey and kind rules — the most specific decision
* wins, the same way `banevent` rejects an event from an otherwise
* allowed author. The event allow list never restricts anything: with
* it empty (the default) this policy behaves exactly as before. It only
* short-circuits this policy; other policies stacked next to it still
* apply.
*
* Functionally equivalent to (and a superset of) the static
* [com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy] +
* [com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy].
@@ -47,6 +56,9 @@ class BanListPolicy(
if (banStore.isBannedEvent(ev.id)) {
return PolicyResult.Rejected("blocked: event id is banned")
}
if (banStore.isAllowedEvent(ev.id)) {
return PolicyResult.Accepted(cmd)
}
if (banStore.isBanned(ev.pubKey)) {
return PolicyResult.Rejected("blocked: pubkey is banned")
}
@@ -21,17 +21,26 @@
package com.vitorpamplona.quartz.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Lock-free runtime state for the NIP-86 management API. Holds the
* ban/allow lists that [BanListPolicy] consults on every accept call,
* plus an [onMutation] hook so the relay can persist the latest
* snapshot whenever an admin RPC mutates state.
* the NIP-43 role definitions / assignments and invite codes (claims)
* that the role and claim RPCs manage, plus an [onMutation] hook so the
* relay can persist the latest snapshot whenever an admin RPC mutates
* state.
*
* Each entry carries an optional reason string so list-* RPCs can echo
* back why an admin took the action — useful for audit trails.
* Each list entry carries an optional reason string so list-* RPCs can
* echo back why an admin took the action — useful for audit trails.
*
* Ban / allow lists follow NIP-86: `ban*` removes the entry from the
* matching allow list and `allow*` removes it from the ban list, while
* `unban*` / `unallow*` only clear their own list — they never add the
* entry to the opposite one. So a pubkey or event id is never on both
* lists at once.
*
* Persistence is intentionally NOT inside this class; supply
* [onMutation] to flush to disk (or wherever) and use [seedFromSnapshot]
@@ -47,18 +56,21 @@ class BanStore(
private val onMutation: (() -> Unit)? = null,
) {
/**
* Single immutable snapshot of all ban/allow state. Combined into
* one object so kind allow/disallow lock-step (allow adds to
* allowedKinds AND removes from disallowedKinds) is naturally
* atomic — no possibility of an interleaved reader observing a
* kind in both sets.
* Single immutable snapshot of all state. Combined into one object so
* the lock-step moves (ban removes from allow, allow removes from
* ban, deleting a role unassigns it) are naturally atomic — no
* interleaved reader can observe an entry on both lists.
*/
private data class State(
val bannedPubkeys: Map<HexKey, String?> = emptyMap(),
val allowedPubkeys: Map<HexKey, String?> = emptyMap(),
val bannedEventIds: Map<HexKey, String?> = emptyMap(),
val allowedEventIds: Map<HexKey, String?> = emptyMap(),
val allowedKinds: Set<Int> = emptySet(),
val disallowedKinds: Set<Int> = emptySet(),
val roles: Map<String, RelayRole> = emptyMap(),
val memberRoles: Map<HexKey, List<String>> = emptyMap(),
val claims: Set<String> = emptySet(),
)
private val state = AtomicReference(State())
@@ -71,13 +83,33 @@ class BanStore(
onMutation?.invoke()
}
/**
* Like [mutate], but [transform] may return `null` to leave the state
* untouched (e.g. editing a role that doesn't exist). Returns whether
* the state changed; [onMutation] only fires when it did.
*/
private inline fun mutateIf(transform: (State) -> State?): Boolean {
while (true) {
val current = state.load()
val next = transform(current) ?: return false
if (state.compareAndSet(current, next)) break
}
onMutation?.invoke()
return true
}
// -- Pubkey ban list -----------------------------------------------------
/** Bans [pubkey] and, per NIP-86, drops it from the allow list. */
fun banPubkey(
pubkey: HexKey,
reason: String? = null,
) = mutate { it.copy(bannedPubkeys = it.bannedPubkeys + (pubkey.lowercase() to reason)) }
) = mutate {
val pk = pubkey.lowercase()
it.copy(bannedPubkeys = it.bannedPubkeys + (pk to reason), allowedPubkeys = it.allowedPubkeys - pk)
}
/** Lifts a ban. Does not add [pubkey] to the allow list. */
fun unbanPubkey(pubkey: HexKey) = mutate { it.copy(bannedPubkeys = it.bannedPubkeys - pubkey.lowercase()) }
fun isBanned(pubkey: HexKey): Boolean = pubkey.lowercase() in state.load().bannedPubkeys
@@ -90,11 +122,16 @@ class BanStore(
// -- Pubkey allow list ---------------------------------------------------
/** Allow-lists [pubkey] and, per NIP-86, drops it from the ban list. */
fun allowPubkey(
pubkey: HexKey,
reason: String? = null,
) = mutate { it.copy(allowedPubkeys = it.allowedPubkeys + (pubkey.lowercase() to reason)) }
) = mutate {
val pk = pubkey.lowercase()
it.copy(allowedPubkeys = it.allowedPubkeys + (pk to reason), bannedPubkeys = it.bannedPubkeys - pk)
}
/** Removes [pubkey] from the allow list. Does not ban it. */
fun unallowPubkey(pubkey: HexKey) = mutate { it.copy(allowedPubkeys = it.allowedPubkeys - pubkey.lowercase()) }
fun isAllowedPubkey(pubkey: HexKey): Boolean = pubkey.lowercase() in state.load().allowedPubkeys
@@ -107,24 +144,52 @@ class BanStore(
fun hasAllowList(): Boolean = state.load().allowedPubkeys.isNotEmpty()
// -- Event id ban list ---------------------------------------------------
// -- Event id ban / allow lists -----------------------------------------
/** Bans [eventId] and, per NIP-86, drops it from the event allow list. */
fun banEvent(
eventId: HexKey,
reason: String? = null,
) = mutate { it.copy(bannedEventIds = it.bannedEventIds + (eventId.lowercase() to reason)) }
) = mutate {
val id = eventId.lowercase()
it.copy(bannedEventIds = it.bannedEventIds + (id to reason), allowedEventIds = it.allowedEventIds - id)
}
/** Removes an event id from the ban list. Mirrors NIP-86 `allowevent`. */
fun allowEvent(eventId: HexKey) = mutate { it.copy(bannedEventIds = it.bannedEventIds - eventId.lowercase()) }
/** NIP-86 `unbanevent`: removes [eventId] from the ban list without allow-listing it. */
fun unbanEvent(eventId: HexKey) = mutate { it.copy(bannedEventIds = it.bannedEventIds - eventId.lowercase()) }
/**
* NIP-86 `allowevent`: adds [eventId] to the event allow list and drops
* it from the ban list. See [BanListPolicy] for what an allow-listed
* event is exempt from.
*/
fun allowEvent(
eventId: HexKey,
reason: String? = null,
) = mutate {
val id = eventId.lowercase()
it.copy(allowedEventIds = it.allowedEventIds + (id to reason), bannedEventIds = it.bannedEventIds - id)
}
/** NIP-86 `unallowevent`: removes [eventId] from the allow list without banning it. */
fun unallowEvent(eventId: HexKey) = mutate { it.copy(allowedEventIds = it.allowedEventIds - eventId.lowercase()) }
fun isBannedEvent(eventId: HexKey): Boolean = eventId.lowercase() in state.load().bannedEventIds
fun isAllowedEvent(eventId: HexKey): Boolean = eventId.lowercase() in state.load().allowedEventIds
fun listBannedEvents(): List<Pair<HexKey, String?>> =
state
.load()
.bannedEventIds.entries
.map { it.key to it.value }
fun listAllowedEvents(): List<Pair<HexKey, String?>> =
state
.load()
.allowedEventIds.entries
.map { it.key to it.value }
// -- Kind allow / deny --------------------------------------------------
/**
@@ -160,12 +225,94 @@ class BanStore(
return kind in s.allowedKinds
}
// -- NIP-43 roles -------------------------------------------------------
/** NIP-86 `createrole`. Returns false (and changes nothing) if a role with that id exists. */
fun createRole(role: RelayRole): Boolean =
mutateIf {
if (role.id in it.roles) null else it.copy(roles = it.roles + (role.id to role))
}
/** NIP-86 `editrole`. Returns false (and changes nothing) if no role has that id. */
fun editRole(role: RelayRole): Boolean =
mutateIf {
if (role.id !in it.roles) null else it.copy(roles = it.roles + (role.id to role))
}
/** NIP-86 `deleterole`. Also unassigns the role from every member. Idempotent. */
fun deleteRole(roleId: String) =
mutate { s ->
s.copy(
roles = s.roles - roleId,
memberRoles =
s.memberRoles
.mapValues { (_, roles) -> roles - roleId }
.filterValues { it.isNotEmpty() },
)
}
fun getRole(roleId: String): RelayRole? = state.load().roles[roleId]
/** Roles sorted by their display [RelayRole.order] (unordered last), then id. */
fun listRoles(): List<RelayRole> =
state
.load()
.roles.values
.sortedWith(compareBy<RelayRole>({ it.order ?: Int.MAX_VALUE }, { it.id }))
/** NIP-86 `assignrole`. Returns false (and changes nothing) if the role doesn't exist. */
fun assignRole(
pubkey: HexKey,
roleId: String,
): Boolean =
mutateIf { s ->
if (roleId !in s.roles) return@mutateIf null
val pk = pubkey.lowercase()
val current = s.memberRoles[pk].orEmpty()
if (roleId in current) s else s.copy(memberRoles = s.memberRoles + (pk to current + roleId))
}
/** NIP-86 `unassignrole`. Idempotent. */
fun unassignRole(
pubkey: HexKey,
roleId: String,
) = mutate { s ->
val pk = pubkey.lowercase()
val remaining = s.memberRoles[pk].orEmpty() - roleId
s.copy(memberRoles = if (remaining.isEmpty()) s.memberRoles - pk else s.memberRoles + (pk to remaining))
}
fun rolesOf(pubkey: HexKey): List<String> = state.load().memberRoles[pubkey.lowercase()].orEmpty()
/** Every pubkey with at least one role, with its role ids in assignment order. */
fun listRoleAssignments(): List<Pair<HexKey, List<String>>> =
state
.load()
.memberRoles.entries
.map { it.key to it.value }
// -- NIP-43 invite codes (claims) ---------------------------------------
/** NIP-86 `createclaim`. Idempotent. */
fun createClaim(claim: String) = mutate { it.copy(claims = it.claims + claim) }
/** NIP-86 `deleteclaim`. Idempotent. */
fun deleteClaim(claim: String) = mutate { it.copy(claims = it.claims - claim) }
/** True when [claim] is an invite code the relay currently accepts. */
fun isValidClaim(claim: String): Boolean = claim in state.load().claims
fun listClaims(): List<String> = state.load().claims.toList()
/**
* Bulk-load state without firing [onMutation]. Used at startup to
* seed the in-memory state from a persisted snapshot — we don't
* want every individual `put` to trigger another disk write. After
* this call the store behaves exactly as if every entry had been
* mutated through the public API.
*
* A snapshot that lists an id on both a ban and an allow list (only
* possible from a hand-edited file) keeps the ban and drops the allow.
*/
fun seedFromSnapshot(
bannedPubkeys: List<Pair<HexKey, String?>> = emptyList(),
@@ -173,14 +320,28 @@ class BanStore(
bannedEvents: List<Pair<HexKey, String?>> = emptyList(),
allowedKinds: List<Int> = emptyList(),
disallowedKinds: List<Int> = emptyList(),
allowedEvents: List<Pair<HexKey, String?>> = emptyList(),
roles: List<RelayRole> = emptyList(),
roleAssignments: List<Pair<HexKey, List<String>>> = emptyList(),
claims: List<String> = emptyList(),
) {
val banned = bannedPubkeys.associate { (k, r) -> k.lowercase() to r }
val bannedEv = bannedEvents.associate { (k, r) -> k.lowercase() to r }
val roleMap = roles.associateBy { it.id }
state.store(
State(
bannedPubkeys = bannedPubkeys.associate { (k, r) -> k.lowercase() to r },
allowedPubkeys = allowedPubkeys.associate { (k, r) -> k.lowercase() to r },
bannedEventIds = bannedEvents.associate { (k, r) -> k.lowercase() to r },
bannedPubkeys = banned,
allowedPubkeys = allowedPubkeys.associate { (k, r) -> k.lowercase() to r } - banned.keys,
bannedEventIds = bannedEv,
allowedEventIds = allowedEvents.associate { (k, r) -> k.lowercase() to r } - bannedEv.keys,
allowedKinds = allowedKinds.toSet(),
disallowedKinds = disallowedKinds.toSet(),
roles = roleMap,
memberRoles =
roleAssignments
.associate { (pk, ids) -> pk.lowercase() to ids.filter { it in roleMap }.distinct() }
.filterValues { it.isNotEmpty() },
claims = claims.toSet(),
),
)
}
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
@@ -60,7 +62,16 @@ import kotlinx.serialization.json.int
*
* [supportedMethods] is the canonical list this server actually
* implements; methods returned outside of it are no-ops and a NIP-86
* client must not advertise them.
* client must not advertise them. Every admin on the [allowList] holds
* every permission, so the list is the same for every authorized caller
* (NIP-86 lets it be tailored per caller; there is nothing to tailor).
*
* The NIP-43 role (`createrole`, `editrole`, `deleterole`, `assignrole`,
* `unassignrole`) and invite-code (`listclaims`, `createclaim`,
* `deleteclaim`) methods only maintain the [BanStore]'s records. A relay
* that publishes kind 13534 / 33534 events or admits kind 28934 join
* requests reads them from there ([BanStore.listRoles],
* [BanStore.rolesOf], [BanStore.isValidClaim]).
*/
class Nip86Server(
val banStore: BanStore,
@@ -118,12 +129,24 @@ class Nip86Server(
Nip86Method.ALLOW_PUBKEY,
Nip86Method.UNALLOW_PUBKEY,
Nip86Method.LIST_ALLOWED_PUBKEYS,
Nip86Method.CREATE_ROLE,
Nip86Method.EDIT_ROLE,
Nip86Method.DELETE_ROLE,
Nip86Method.ASSIGN_ROLE,
Nip86Method.UNASSIGN_ROLE,
Nip86Method.LIST_CLAIMS,
Nip86Method.CREATE_CLAIM,
Nip86Method.DELETE_CLAIM,
Nip86Method.BAN_EVENT,
Nip86Method.UNBAN_EVENT,
Nip86Method.ALLOW_EVENT,
Nip86Method.UNALLOW_EVENT,
Nip86Method.LIST_BANNED_EVENTS,
Nip86Method.LIST_ALLOWED_EVENTS,
Nip86Method.ALLOW_KIND,
Nip86Method.DISALLOW_KIND,
Nip86Method.LIST_ALLOWED_KINDS,
Nip86Method.LIST_DISALLOWED_KINDS,
Nip86Method.CHANGE_RELAY_NAME,
Nip86Method.CHANGE_RELAY_DESCRIPTION,
Nip86Method.CHANGE_RELAY_ICON,
@@ -190,14 +213,67 @@ class Nip86Server(
}
}
Nip86Method.UNBAN_EVENT -> {
withHex(req, "event_id") { id -> banStore.unbanEvent(id) }
}
Nip86Method.ALLOW_EVENT -> {
withHex(req, "event_id") { id -> banStore.allowEvent(id) }
withHexAndReason(req, "event_id") { id, reason -> banStore.allowEvent(id, reason) }
}
Nip86Method.UNALLOW_EVENT -> {
withHex(req, "event_id") { id -> banStore.unallowEvent(id) }
}
Nip86Method.LIST_BANNED_EVENTS -> {
ok(banStore.listBannedEvents().map { (id, r) -> BannedEvent(id, r) }.toJsonArray(BannedEvent.serializer()))
}
Nip86Method.LIST_ALLOWED_EVENTS -> {
ok(banStore.listAllowedEvents().map { (id, r) -> AllowedEvent(id, r) }.toJsonArray(AllowedEvent.serializer()))
}
Nip86Method.CREATE_ROLE -> {
withRole(req) { role ->
if (banStore.createRole(role)) okTrue else Nip86Response(error = "role already exists: ${role.id}")
}
}
Nip86Method.EDIT_ROLE -> {
withRole(req) { role ->
if (banStore.editRole(role)) okTrue else Nip86Response(error = "unknown role: ${role.id}")
}
}
Nip86Method.DELETE_ROLE -> {
withNonBlankString(req, "id") { id -> banStore.deleteRole(id) }
}
Nip86Method.ASSIGN_ROLE -> {
withPubkeyAndRole(req) { pk, roleId ->
if (banStore.assignRole(pk, roleId)) okTrue else Nip86Response(error = "unknown role: $roleId")
}
}
Nip86Method.UNASSIGN_ROLE -> {
withPubkeyAndRole(req) { pk, roleId ->
banStore.unassignRole(pk, roleId)
okTrue
}
}
Nip86Method.LIST_CLAIMS -> {
ok(buildJsonArray { banStore.listClaims().forEach { add(JsonPrimitive(it)) } })
}
Nip86Method.CREATE_CLAIM -> {
withNonBlankString(req, "claim") { claim -> banStore.createClaim(claim) }
}
Nip86Method.DELETE_CLAIM -> {
withNonBlankString(req, "claim") { claim -> banStore.deleteClaim(claim) }
}
Nip86Method.ALLOW_KIND -> {
withInt(req, "kind") { k -> banStore.allowKind(k) }
}
@@ -214,6 +290,10 @@ class Nip86Server(
ok(buildJsonArray { banStore.listAllowedKinds().forEach { add(JsonPrimitive(it)) } })
}
Nip86Method.LIST_DISALLOWED_KINDS -> {
ok(buildJsonArray { banStore.listDisallowedKinds().forEach { add(JsonPrimitive(it)) } })
}
Nip86Method.CHANGE_RELAY_NAME -> {
withString(req, "name") { name -> rewriteInfo { it.copy(name = name) } }
}
@@ -281,6 +361,45 @@ class Nip86Server(
return okTrue
}
private inline fun withNonBlankString(
req: Nip86Request,
label: String,
action: (String) -> Unit,
): Nip86Response {
val v = req.params.firstString()?.takeIf { it.isNotBlank() } ?: return malformed("expected [$label]")
action(v)
return okTrue
}
/**
* Parses NIP-86 `[id, label, description, color, order]`. Only `id` is
* required; trailing params may be omitted or `null`. `color` (a hue,
* 0..360) and `order` accept a JSON number or a numeric string, since
* the kind 33534 tags carry them as strings.
*/
private inline fun withRole(
req: Nip86Request,
action: (RelayRole) -> Nip86Response,
): Nip86Response {
val id = req.params.firstString()?.takeIf { it.isNotBlank() } ?: return malformed("expected [id, label?, description?, color?, order?]")
val label = req.params.optString(1) ?: return malformed("label must be a string")
val description = req.params.optString(2) ?: return malformed("description must be a string")
val color = req.params.optInt(3) ?: return malformed("color must be an integer hue")
if (color.value != null && !RelayRole.isValidHue(color.value)) return malformed("color must be a hue between 0 and 360")
val order = req.params.optInt(4) ?: return malformed("order must be an integer")
return action(RelayRole(id, label.value?.ifEmpty { null }, description.value?.ifEmpty { null }, color.value, order.value))
}
private inline fun withPubkeyAndRole(
req: Nip86Request,
action: (HexKey, String) -> Nip86Response,
): Nip86Response {
val (pk, roleId) = req.params.stringPair() ?: return malformed("expected [pubkey, role_id]")
if (!Hex.isHex64(pk)) return malformed("pubkey must be 64-char hex")
if (roleId.isNullOrBlank()) return malformed("expected [pubkey, role_id]")
return action(pk.lowercase(), roleId)
}
private fun rewriteInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
infoHolder.set(transform(infoHolder.get()))
}
@@ -304,6 +423,30 @@ private fun JsonArray.stringPair(): Pair<String, String?>? {
private fun JsonArray.firstString(): String? = (getOrNull(0) as? JsonPrimitive)?.contentOrNull()
/** An optional positional param: [value] is null when the param is missing or JSON `null`. */
private class OptionalParam<T>(
val value: T?,
)
/** Missing / `null` -> empty; a JSON string -> its content; anything else -> null (malformed). */
private fun JsonArray.optString(index: Int): OptionalParam<String>? {
val el = getOrNull(index) ?: return OptionalParam(null)
if (el == JsonNull) return OptionalParam(null)
val prim = el as? JsonPrimitive ?: return null
if (!prim.isString) return null
return OptionalParam(prim.content)
}
/** Missing / `null` / "" -> empty; an integer number or numeric string -> its value; anything else -> null (malformed). */
private fun JsonArray.optInt(index: Int): OptionalParam<Int>? {
val el = getOrNull(index) ?: return OptionalParam(null)
if (el == JsonNull) return OptionalParam(null)
val prim = el as? JsonPrimitive ?: return null
val text = prim.content.trim()
if (prim.isString && text.isEmpty()) return OptionalParam(null)
return text.toIntOrNull()?.let { OptionalParam(it) }
}
private fun JsonArray.firstInt(): Int? =
runCatching {
(this[0] as? JsonPrimitive)?.int
@@ -250,6 +250,7 @@ import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEv
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
@@ -857,6 +858,7 @@ class EventFactory {
RelayAddMemberEvent.KIND -> RelayAddMemberEvent(id, pubKey, createdAt, tags, content, sig)
RelayRemoveMemberEvent.KIND -> RelayRemoveMemberEvent(id, pubKey, createdAt, tags, content, sig)
RelayMembershipListEvent.KIND -> RelayMembershipListEvent(id, pubKey, createdAt, tags, content, sig)
RelayRoleEvent.KIND -> RelayRoleEvent(id, pubKey, createdAt, tags, content, sig)
RelayJoinRequestEvent.KIND -> RelayJoinRequestEvent(id, pubKey, createdAt, tags, content, sig)
RelayInviteRequestEvent.KIND -> RelayInviteRequestEvent(id, pubKey, createdAt, tags, content, sig)
RelayLeaveRequestEvent.KIND -> RelayLeaveRequestEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,128 @@
/*
* 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.nip43RelayMembers
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip43RelayMembers.inviteRequest.RelayInviteRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.MemberTag
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent
import com.vitorpamplona.quartz.nip70ProtectedEvts.isProtected
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class RelayRolesTest {
private val signer = NostrSignerSync(KeyPair())
private val alice = "c308e1f882c1f1dff2a43d4294239ddeec04e575f2d1aad1fa21ea7684e61fb5"
private val bob = "ee1d336e13779e4d4c527b988429d96de16088f958cbf6c074676ac9cfd9c958"
@Test
fun roleEventBuildsTheSpecExampleAndParsesBack() {
val template = RelayRoleEvent.build(RelayRole("28b7e50f", "king", "ruler of the relay", 37, 1), createdAt = 1000)
assertEquals(RelayRoleEvent.KIND, template.kind)
assertContentEquals(arrayOf("-"), template.tags[0])
assertContentEquals(arrayOf("d", "28b7e50f"), template.tags[1])
assertContentEquals(arrayOf("label", "king"), template.tags[2])
assertContentEquals(arrayOf("description", "ruler of the relay"), template.tags[3])
assertContentEquals(arrayOf("color", "37"), template.tags[4])
assertContentEquals(arrayOf("order", "1"), template.tags[5])
val signed = assertIs<RelayRoleEvent>(signer.sign(template))
assertTrue(signed.isProtected())
assertEquals("28b7e50f", signed.roleId())
assertEquals(RelayRole("28b7e50f", "king", "ruler of the relay", 37, 1), signed.role())
}
@Test
fun roleEventOptionalTagsAndBadValues() {
val minimal = assertIs<RelayRoleEvent>(signer.sign(RelayRoleEvent.build(RelayRole("r1"))))
assertEquals(RelayRole("r1"), minimal.role())
// A foreign event with an out-of-range hue and a non-numeric order.
val odd =
assertIs<RelayRoleEvent>(
signer.sign<Event>(
1000,
RelayRoleEvent.KIND,
arrayOf(arrayOf("-"), arrayOf("d", "r2"), arrayOf("color", "400"), arrayOf("order", "first")),
"",
),
)
assertNull(odd.color())
assertNull(odd.order())
assertFailsWith<IllegalArgumentException> { RelayRoleEvent.build(RelayRole("r3", color = 361)) }
}
@Test
fun memberTagCarriesOptionalRoles() {
val withRoles = arrayOf("member", bob, "28b7e50f", "", "abc", "28b7e50f")
assertEquals(bob, MemberTag.parse(withRoles))
assertEquals(RelayMember(bob, listOf("28b7e50f", "abc")), MemberTag.parseMember(withRoles))
assertEquals(RelayMember(alice), MemberTag.parseMember(arrayOf("member", alice)))
assertNull(MemberTag.parseMember(arrayOf("member", "short")))
assertContentEquals(arrayOf("member", bob, "r1", "r2"), MemberTag.assemble(bob, listOf("r1", "r2")))
assertContentEquals(arrayOf("member", alice), MemberTag.assemble(RelayMember(alice)))
}
@Test
fun membershipListWithRolesKeepsPlainMembersBackwardCompatible() {
val template =
RelayMembershipListEvent.buildWithRoles(
listOf(RelayMember(alice), RelayMember(bob, listOf("28b7e50f"))),
)
val signed = assertIs<RelayMembershipListEvent>(signer.sign(template))
assertTrue(signed.isProtected())
assertEquals(listOf(alice, bob), signed.members())
assertEquals(listOf(RelayMember(alice), RelayMember(bob, listOf("28b7e50f"))), signed.membersWithRoles())
val legacy = assertIs<RelayMembershipListEvent>(signer.sign(RelayMembershipListEvent.build(listOf(alice))))
assertEquals(listOf(RelayMember(alice)), legacy.membersWithRoles())
}
@Test
fun joinRequestCarriesClaimAndProtectedTag() {
val signed = assertIs<RelayJoinRequestEvent>(signer.sign(RelayJoinRequestEvent.build("invite-code")))
assertTrue(signed.isProtected())
assertEquals("invite-code", signed.claim())
assertFailsWith<IllegalArgumentException> { RelayJoinRequestEvent.build(" ") }
}
@Suppress("DEPRECATION")
@Test
fun deprecatedInviteRequestStillParses() {
val signed =
signer.sign<Event>(1000, RelayInviteRequestEvent.KIND, arrayOf(arrayOf("-"), arrayOf("claim", "abc")), "")
assertIs<RelayInviteRequestEvent>(signed)
}
}
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip86RelayManagement
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import kotlin.test.Test
import kotlin.test.assertEquals
class Nip86RequestResponseTest {
private val client = Nip86Client(RelayUrlNormalizer.normalize("wss://relay.example.com"), NostrSignerInternal(KeyPair()))
private val id = "c".repeat(64)
private val pk = "a".repeat(64)
private fun json(req: Nip86Request) = client.serializeRequest(req)
@Test
fun eventAllowAndBanMethods() {
assertEquals("""{"method":"allowevent","params":["$id","ok"]}""", json(Nip86Request.allowEvent(id, "ok")))
assertEquals("""{"method":"unallowevent","params":["$id"]}""", json(Nip86Request.unallowEvent(id)))
assertEquals("""{"method":"unbanevent","params":["$id","oops"]}""", json(Nip86Request.unbanEvent(id, "oops")))
assertEquals("""{"method":"listallowedevents","params":[]}""", json(Nip86Request.listAllowedEvents()))
assertEquals("""{"method":"listdisallowedkinds","params":[]}""", json(Nip86Request.listDisallowedKinds()))
}
@Test
fun roleMethods() {
assertEquals(
"""{"method":"createrole","params":["28b7e50f","king","ruler of the relay",37,1]}""",
json(Nip86Request.createRole("28b7e50f", "king", "ruler of the relay", 37, 1)),
)
assertEquals("""{"method":"editrole","params":["r",null,null,null,null]}""", json(Nip86Request.editRole("r")))
assertEquals("""{"method":"deleterole","params":["r"]}""", json(Nip86Request.deleteRole("r")))
assertEquals("""{"method":"assignrole","params":["$pk","r"]}""", json(Nip86Request.assignRole(pk, "r")))
assertEquals("""{"method":"unassignrole","params":["$pk","r"]}""", json(Nip86Request.unassignRole(pk, "r")))
}
@Test
fun claimMethods() {
assertEquals("""{"method":"listclaims","params":[]}""", json(Nip86Request.listClaims()))
assertEquals("""{"method":"createclaim","params":["abc"]}""", json(Nip86Request.createClaim("abc")))
assertEquals("""{"method":"deleteclaim","params":["abc"]}""", json(Nip86Request.deleteClaim("abc")))
assertEquals(Nip86Method.CREATE_CLAIM, JsonMapper.fromJson<Nip86Request>(json(Nip86Request.createClaim("abc"))).method)
}
@Test
fun parsesNewResponses() {
val allowed = client.parseAllowedEvents(client.parseResponse("""{"result":[{"id":"$id","reason":"ok"},{"id":"$id"}]}"""))!!
assertEquals(listOf(id, id), allowed.map { it.id })
assertEquals(listOf("ok", null), allowed.map { it.reason })
assertEquals(listOf(4, 1059), client.parseDisallowedKinds(client.parseResponse("""{"result":[4,1059]}""")))
assertEquals(listOf("a", "b"), client.parseClaims(client.parseResponse("""{"result":["a","b"]}""")))
assertEquals(true, client.parseBooleanResult(client.parseResponse("""{"result":true}""")))
}
}
@@ -20,6 +20,10 @@
*/
package com.vitorpamplona.quartz.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -60,8 +64,151 @@ class BanStoreTest {
val s = BanStore()
s.banEvent("ee".padEnd(64, '0'), "policy")
assertTrue(s.isBannedEvent("EE".padEnd(64, '0')))
s.allowEvent("ee".padEnd(64, '0'))
s.unbanEvent("ee".padEnd(64, '0'))
assertFalse(s.isBannedEvent("ee".padEnd(64, '0')))
assertFalse(s.isAllowedEvent("ee".padEnd(64, '0')), "unban must not allow-list")
}
@Test
fun banAndAllowAreMutuallyExclusiveForPubkeys() {
val s = BanStore()
val pk = "aa".padEnd(64, '0')
s.allowPubkey(pk, "trusted")
s.banPubkey(pk, "spam")
assertTrue(s.isBanned(pk))
assertFalse(s.isAllowedPubkey(pk), "banpubkey must remove from the allow list")
s.allowPubkey(pk.uppercase(), "trusted again")
assertTrue(s.isAllowedPubkey(pk))
assertFalse(s.isBanned(pk), "allowpubkey must remove from the ban list")
s.unallowPubkey(pk)
assertFalse(s.isAllowedPubkey(pk))
assertFalse(s.isBanned(pk), "unallowpubkey must not ban")
s.banPubkey(pk)
s.unbanPubkey(pk)
assertFalse(s.isBanned(pk))
assertFalse(s.isAllowedPubkey(pk), "unbanpubkey must not allow-list")
}
@Test
fun banAndAllowAreMutuallyExclusiveForEvents() {
val s = BanStore()
val id = "ee".padEnd(64, '0')
s.allowEvent(id, "approved")
assertTrue(s.isAllowedEvent(id))
assertEquals(listOf(id to "approved"), s.listAllowedEvents())
s.banEvent(id, "spam")
assertTrue(s.isBannedEvent(id))
assertFalse(s.isAllowedEvent(id), "banevent must remove from the allow list")
s.allowEvent(id)
assertTrue(s.isAllowedEvent(id))
assertFalse(s.isBannedEvent(id), "allowevent must remove from the ban list")
s.unallowEvent(id)
assertFalse(s.isAllowedEvent(id))
assertFalse(s.isBannedEvent(id), "unallowevent must not ban")
}
@Test
fun rolesAssignmentsAndDeletion() {
val s = BanStore()
val pk = "aa".padEnd(64, '0')
assertTrue(s.createRole(RelayRole("b", label = "B", order = 2)))
assertTrue(s.createRole(RelayRole("a", label = "A", order = 1)))
assertTrue(s.createRole(RelayRole("z")))
assertFalse(s.createRole(RelayRole("a", label = "dup")))
assertEquals(listOf("a", "b", "z"), s.listRoles().map { it.id }, "sorted by order, unordered last")
assertFalse(s.editRole(RelayRole("missing")))
assertTrue(s.editRole(RelayRole("a", label = "Alpha", color = 30)))
assertEquals(RelayRole("a", label = "Alpha", color = 30), s.getRole("a"))
assertFalse(s.assignRole(pk, "missing"))
assertTrue(s.assignRole(pk.uppercase(), "a"))
assertTrue(s.assignRole(pk, "b"))
assertTrue(s.assignRole(pk, "a"))
assertEquals(listOf("a", "b"), s.rolesOf(pk))
s.deleteRole("a")
assertEquals(listOf("b"), s.rolesOf(pk))
s.unassignRole(pk, "b")
assertEquals(emptyList(), s.rolesOf(pk))
assertEquals(emptyList(), s.listRoleAssignments())
}
@Test
fun claims() {
val s = BanStore()
s.createClaim("code-1")
s.createClaim("code-1")
s.createClaim("code-2")
assertEquals(listOf("code-1", "code-2"), s.listClaims())
assertTrue(s.isValidClaim("code-2"))
s.deleteClaim("code-2")
assertFalse(s.isValidClaim("code-2"))
}
@Test
fun mutationsFireHookButFailedRoleEditsDoNot() {
var count = 0
val s = BanStore(onMutation = { count++ })
s.allowEvent("ee".padEnd(64, '0'))
assertEquals(1, count)
s.editRole(RelayRole("missing"))
s.assignRole("aa".padEnd(64, '0'), "missing")
assertEquals(1, count)
}
@Test
fun seedFromSnapshotRestoresNewSectionsAndResolvesConflicts() {
val s = BanStore()
val pk = "aa".padEnd(64, '0')
val id = "ee".padEnd(64, '0')
s.seedFromSnapshot(
bannedPubkeys = listOf(pk to "spam"),
allowedPubkeys = listOf(pk to "hand-edited conflict"),
bannedEvents = listOf(id to "x"),
allowedEvents = listOf(id to "conflict", "ff".padEnd(64, '0') to "ok"),
roles = listOf(RelayRole("mod")),
roleAssignments = listOf(pk to listOf("mod", "ghost")),
claims = listOf("c"),
)
assertTrue(s.isBanned(pk))
assertFalse(s.isAllowedPubkey(pk))
assertTrue(s.isBannedEvent(id))
assertFalse(s.isAllowedEvent(id))
assertTrue(s.isAllowedEvent("ff".padEnd(64, '0')))
assertEquals(listOf("mod"), s.rolesOf(pk), "assignments to unknown roles are dropped")
assertTrue(s.isValidClaim("c"))
}
@Test
fun policyLetsAllowListedEventsBypassPubkeyAndKindRules() {
val s = BanStore()
val policy = BanListPolicy(s)
val author = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d"
val allowedId = "a".repeat(64)
val otherId = "b".repeat(64)
fun event(id: String) = Event(id, author, 1000L, 1, emptyArray(), "hi", "0".repeat(128))
// Empty event allow list changes nothing.
assertTrue(policy.accept(EventCmd(event(otherId))) is PolicyResult.Accepted)
s.banPubkey(author)
s.disallowKind(1)
assertTrue(policy.accept(EventCmd(event(otherId))) is PolicyResult.Rejected)
s.allowEvent(allowedId)
assertTrue(policy.accept(EventCmd(event(allowedId))) is PolicyResult.Accepted)
assertTrue(policy.accept(EventCmd(event(otherId))) is PolicyResult.Rejected)
s.banEvent(allowedId)
assertTrue(policy.accept(EventCmd(event(allowedId))) is PolicyResult.Rejected)
}
@Test
@@ -21,19 +21,25 @@
package com.vitorpamplona.quartz.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -140,9 +146,167 @@ class Nip86ServerTest {
assertEquals(eventId, list[0].id)
assertEquals("off-topic", list[0].reason)
// allowevent (which is "unban") removes the entry.
server.dispatch(admin, Nip86Request.allowEvent(eventId))
// unbanevent removes the entry without allow-listing it.
server.dispatch(admin, Nip86Request.unbanEvent(eventId))
assertTrue(banStore.listBannedEvents().isEmpty())
assertTrue(banStore.listAllowedEvents().isEmpty())
}
}
@Test
fun allowEventAddsToAllowListWithReasonAndLiftsBan() {
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(admin, Nip86Request.banEvent(eventId, "spam"))
val ok = server.dispatch(admin, Nip86Request.allowEvent(eventId, "reviewed"))
assertEquals(true, (ok.result as JsonPrimitive).boolean)
assertTrue(banStore.isAllowedEvent(eventId))
assertFalse(banStore.isBannedEvent(eventId))
val resp = server.dispatch(admin, Nip86Request.listAllowedEvents())
val list = Json.decodeFromJsonElement(ListSerializer(AllowedEvent.serializer()), resp.result as JsonArray)
assertEquals(1, list.size)
assertEquals(eventId, list[0].id)
assertEquals("reviewed", list[0].reason)
// banevent moves it back off the allow list.
server.dispatch(admin, Nip86Request.banEvent(eventId, "again"))
assertTrue(banStore.isBannedEvent(eventId))
assertFalse(banStore.isAllowedEvent(eventId))
// unallowevent / unbanevent never add to the opposite list.
server.dispatch(admin, Nip86Request.allowEvent(eventId))
server.dispatch(admin, Nip86Request.unallowEvent(eventId))
assertFalse(banStore.isAllowedEvent(eventId))
assertFalse(banStore.isBannedEvent(eventId))
}
}
@Test
fun banAndAllowPubkeyAreMutuallyExclusive() {
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(admin, Nip86Request.allowPubkey(pk, "trusted"))
server.dispatch(admin, Nip86Request.banPubkey(pk, "spam"))
assertTrue(banStore.isBanned(pk))
assertFalse(banStore.isAllowedPubkey(pk))
server.dispatch(admin, Nip86Request.allowPubkey(pk))
assertTrue(banStore.isAllowedPubkey(pk))
assertFalse(banStore.isBanned(pk))
server.dispatch(admin, Nip86Request.unallowPubkey(pk))
assertFalse(banStore.isAllowedPubkey(pk))
assertFalse(banStore.isBanned(pk))
}
}
@Test
fun listDisallowedKinds() {
runBlocking {
val (server, _, _) = fixture()
server.dispatch(admin, Nip86Request.disallowKind(4))
server.dispatch(admin, Nip86Request.disallowKind(1059))
val resp = server.dispatch(admin, Nip86Request.listDisallowedKinds())
assertEquals(listOf(4, 1059), (resp.result as JsonArray).map { it.jsonPrimitive.int })
}
}
@Test
fun roleLifecycle() {
runBlocking {
val (server, banStore, _) = fixture()
val created = server.dispatch(admin, Nip86Request.createRole("mod", "Moderator", "keeps order", 120, 1))
assertNull(created.error)
assertEquals(RelayRole("mod", "Moderator", "keeps order", 120, 1), banStore.getRole("mod"))
// Creating an existing id is refused; edit replaces it.
assertNotNull(server.dispatch(admin, Nip86Request.createRole("mod")).error)
assertNull(server.dispatch(admin, Nip86Request.editRole("mod", "Mods", null, 200, null)).error)
assertEquals(RelayRole("mod", "Mods", null, 200, null), banStore.getRole("mod"))
assertNotNull(server.dispatch(admin, Nip86Request.editRole("nope", "x")).error)
assertNull(server.dispatch(admin, Nip86Request.assignRole(pk, "mod")).error)
assertEquals(listOf("mod"), banStore.rolesOf(pk))
assertNotNull(server.dispatch(admin, Nip86Request.assignRole(pk, "nope")).error)
assertNull(server.dispatch(admin, Nip86Request.unassignRole(pk, "mod")).error)
assertEquals(emptyList(), banStore.rolesOf(pk))
server.dispatch(admin, Nip86Request.assignRole(pk2, "mod"))
assertNull(server.dispatch(admin, Nip86Request.deleteRole("mod")).error)
assertNull(banStore.getRole("mod"))
assertEquals(emptyList(), banStore.rolesOf(pk2), "deleting a role unassigns it")
}
}
@Test
fun roleParamsAcceptStringNumbersAndRejectBadHues() {
runBlocking {
val (server, banStore, _) = fixture()
val stringy =
Nip86Request(
method = Nip86Method.CREATE_ROLE,
params =
buildJsonArray {
add(JsonPrimitive("king"))
add(JsonPrimitive("king"))
add(JsonPrimitive("ruler of the relay"))
add(JsonPrimitive("37"))
add(JsonPrimitive("1"))
},
)
assertNull(server.dispatch(admin, stringy).error)
assertEquals(RelayRole("king", "king", "ruler of the relay", 37, 1), banStore.getRole("king"))
// Only the id is required.
val idOnly = Nip86Request(method = Nip86Method.CREATE_ROLE, params = buildJsonArray { add(JsonPrimitive("bare")) })
assertNull(server.dispatch(admin, idOnly).error)
assertEquals(RelayRole("bare"), banStore.getRole("bare"))
assertNotNull(server.dispatch(admin, Nip86Request.createRole("hot", color = 361)).error)
assertNull(banStore.getRole("hot"))
assertNotNull(server.dispatch(admin, Nip86Request(method = Nip86Method.CREATE_ROLE)).error)
}
}
@Test
fun claimLifecycle() {
runBlocking {
val (server, banStore, _) = fixture()
assertNull(server.dispatch(admin, Nip86Request.createClaim("invite-1")).error)
assertNull(server.dispatch(admin, Nip86Request.createClaim("invite-2")).error)
assertTrue(banStore.isValidClaim("invite-1"))
val listed = server.dispatch(admin, Nip86Request.listClaims())
assertEquals(setOf("invite-1", "invite-2"), (listed.result as JsonArray).map { it.jsonPrimitive.content }.toSet())
assertNull(server.dispatch(admin, Nip86Request.deleteClaim("invite-1")).error)
assertFalse(banStore.isValidClaim("invite-1"))
assertNotNull(server.dispatch(admin, Nip86Request.createClaim(" ")).error)
}
}
@Test
fun supportedMethodsAreAllDispatchable() {
runBlocking {
val (server, _, _) = fixture()
// Every advertised method must reach a real handler, never "method not supported".
server.supportedMethods.forEach { method ->
val resp = server.dispatch(admin, Nip86Request(method = method))
assertFalse(resp.error?.startsWith("method not supported") == true, "advertised but not handled: " + method)
}
listOf(
Nip86Method.UNBAN_EVENT,
Nip86Method.UNALLOW_EVENT,
Nip86Method.LIST_ALLOWED_EVENTS,
Nip86Method.LIST_DISALLOWED_KINDS,
Nip86Method.CREATE_ROLE,
Nip86Method.ASSIGN_ROLE,
Nip86Method.LIST_CLAIMS,
Nip86Method.CREATE_CLAIM,
).forEach { assertTrue(it in server.supportedMethods, it) }
}
}