From 2465474d0d894b9a9eeac51eb7d955dc9c2bdffb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:05:19 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../geode/config/RuntimeConfig.kt | 40 +++- .../geode/admin/Nip86EndToEndTest.kt | 33 ++- .../geode/config/RuntimeConfigTest.kt | 42 ++++ .../vitorpamplona/quartz/kinds/KindNames.kt | 2 + .../inviteRequest/RelayInviteRequestEvent.kt | 8 + .../joinRequest/RelayJoinRequestEvent.kt | 20 +- .../list/RelayMembershipListEvent.kt | 15 ++ .../list/TagArrayBuilderExt.kt | 3 + .../nip43RelayMembers/list/TagArrayExt.kt | 5 +- .../nip43RelayMembers/list/tags/MemberTag.kt | 35 ++++ .../nip43RelayMembers/roles/RelayRole.kt | 47 +++++ .../nip43RelayMembers/roles/RelayRoleEvent.kt | 89 ++++++++ .../roles/TagArrayBuilderExt.kt | 35 ++++ .../nip43RelayMembers/roles/TagArrayExt.kt | 36 ++++ .../roles/tags/RoleColorTag.kt | 42 ++++ .../roles/tags/RoleDescriptionTag.kt | 40 ++++ .../roles/tags/RoleLabelTag.kt | 40 ++++ .../roles/tags/RoleOrderTag.kt | 39 ++++ .../nip86RelayManagement/Nip86Client.kt | 66 +++++- .../nip86RelayManagement/rpc/Nip86Method.kt | 20 ++ .../nip86RelayManagement/rpc/Nip86Request.kt | 115 ++++++++++ .../nip86RelayManagement/rpc/Nip86Response.kt | 6 + .../server/BanListPolicy.kt | 12 ++ .../nip86RelayManagement/server/BanStore.kt | 197 ++++++++++++++++-- .../server/Nip86Server.kt | 147 ++++++++++++- .../quartz/utils/EventFactory.kt | 2 + .../nip43RelayMembers/RelayRolesTest.kt | 128 ++++++++++++ .../Nip86RequestResponseTest.kt | 78 +++++++ .../server/BanStoreTest.kt | 149 ++++++++++++- .../server/Nip86ServerTest.kt | 168 ++++++++++++++- 30 files changed, 1627 insertions(+), 32 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/RelayRole.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/RelayRoleEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleColorTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleDescriptionTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleLabelTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleOrderTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/RelayRolesTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86RequestResponseTest.kt diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt index 5e0d532424..e861aa7158 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RuntimeConfig.kt @@ -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 = emptyList(), val allowedKinds: List = emptyList(), val disallowedKinds: List = emptyList(), + val allowedEvents: List = emptyList(), + val roles: List = emptyList(), + val roleAssignments: List = emptyList(), + val claims: List = 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, ) @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(), ) diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt index 5c119e1cd3..66ef69a46a 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt @@ -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(it.body.string()) + val json = JsonMapper.fromJson(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(it.body.string()) + val resp = JsonMapper.fromJson(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(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 { diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/config/RuntimeConfigTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/config/RuntimeConfigTest.kt index 10bab8a7dd..04b21cd576 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/config/RuntimeConfigTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/config/RuntimeConfigTest.kt @@ -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 {") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index b1ff7a9483..6bc8a37c3d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -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"), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/inviteRequest/RelayInviteRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/inviteRequest/RelayInviteRequestEvent.kt index fde64991fd..90fec26b71 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/inviteRequest/RelayInviteRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/inviteRequest/RelayInviteRequestEvent.kt @@ -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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/RelayJoinRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/RelayJoinRequestEvent.kt index 3c64941867..39ac57af0b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/RelayJoinRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/joinRequest/RelayJoinRequestEvent.kt @@ -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.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { - claim?.let { claim(it) } - initializer() + ): EventTemplate { + require(claim.isNotBlank()) { "NIP-43 join requests require an invite code (claim)" } + return eventTemplate(KIND, "", createdAt) { + protect() + claim(claim) + initializer() + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/RelayMembershipListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/RelayMembershipListEvent.kt index 9993cb05b7..6a8188cc5a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/RelayMembershipListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/RelayMembershipListEvent.kt @@ -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, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + protect() + membersWithRoles(members) + initializer() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayBuilderExt.kt index d622586deb..f6359a29dc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayBuilderExt.kt @@ -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.members(pubKeys: List) = addAll(MemberTag.assemble(pubKeys)) + +fun TagArrayBuilder.membersWithRoles(members: List) = addAll(members.map { MemberTag.assemble(it) }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayExt.kt index 39da8159b0..d84423c8ec 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/TagArrayExt.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/tags/MemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/tags/MemberTag.kt index c73004e935..fc93a663ae 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/tags/MemberTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/list/tags/MemberTag.kt @@ -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 = emptyList(), +) + +/** + * NIP-43 `["member", , ...]`. 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): RelayMember? { + val pubKey = parse(tag) ?: return null + if (tag.size <= 2) return RelayMember(pubKey) + val roles = ArrayList(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, + ): Array = arrayOf(TAG_NAME, pubKey) + roles + + fun assemble(member: RelayMember) = assemble(member.pubKey, member.roles) + fun assemble(pubKeys: List) = pubKeys.map { assemble(it) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/RelayRole.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/RelayRole.kt new file mode 100644 index 0000000000..59018cb2eb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/RelayRole.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.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 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/RelayRoleEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/RelayRoleEvent.kt new file mode 100644 index 0000000000..26c3193092 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/RelayRoleEvent.kt @@ -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>, + 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.() -> Unit = {}, + ) = eventTemplate(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() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..40f1bdd0b0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/TagArrayBuilderExt.kt @@ -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.roleLabel(label: String) = addUnique(RoleLabelTag.assemble(label)) + +fun TagArrayBuilder.roleDescription(description: String) = addUnique(RoleDescriptionTag.assemble(description)) + +fun TagArrayBuilder.roleColor(hue: Int) = addUnique(RoleColorTag.assemble(hue)) + +fun TagArrayBuilder.roleOrder(order: Int) = addUnique(RoleOrderTag.assemble(order)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/TagArrayExt.kt new file mode 100644 index 0000000000..38dceef188 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/TagArrayExt.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleColorTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleColorTag.kt new file mode 100644 index 0000000000..719e6b4ab4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleColorTag.kt @@ -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): 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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleDescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleDescriptionTag.kt new file mode 100644 index 0000000000..176b26773d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleDescriptionTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleLabelTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleLabelTag.kt new file mode 100644 index 0000000000..350b05d65c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleLabelTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleOrderTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleOrderTag.kt new file mode 100644 index 0000000000..00db5227bd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/roles/tags/RoleOrderTag.kt @@ -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): 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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86Client.kt index 3685bb11bb..00b9a24c00 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86Client.kt @@ -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>(result.toString()) } + fun parseAllowedEvents(response: Nip86Response): List? { + val result = response.result ?: return null + return JsonMapper.fromJson>(result.toString()) + } + fun parseEventsNeedingModeration(response: Nip86Response): List? { val result = response.result ?: return null return JsonMapper.fromJson>(result.toString()) } - fun parseAllowedKinds(response: Nip86Response): List? { + fun parseAllowedKinds(response: Nip86Response): List? = parseKinds(response) + + fun parseDisallowedKinds(response: Nip86Response): List? = parseKinds(response) + + private fun parseKinds(response: Nip86Response): List? { 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? { + val result = response.result ?: return null + return (result as? JsonArray)?.map { it.jsonPrimitive.content } + } + fun parseBlockedIps(response: Nip86Response): List? { val result = response.result ?: return null return JsonMapper.fromJson>(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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Method.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Method.kt index a52f48be72..41cb4ac81a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Method.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Method.kt @@ -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" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Request.kt index 1260c0ad5e..c181927780 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Request.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Request.kt @@ -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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Response.kt index 8f34cb275e..09642d7a43 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Response.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Response.kt @@ -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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt index dd693e80e1..aed433d44a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt @@ -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") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt index ba38eff154..b2ef70e00f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt @@ -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 = emptyMap(), val allowedPubkeys: Map = emptyMap(), val bannedEventIds: Map = emptyMap(), + val allowedEventIds: Map = emptyMap(), val allowedKinds: Set = emptySet(), val disallowedKinds: Set = emptySet(), + val roles: Map = emptyMap(), + val memberRoles: Map> = emptyMap(), + val claims: Set = 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> = state .load() .bannedEventIds.entries .map { it.key to it.value } + fun listAllowedEvents(): List> = + 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 = + state + .load() + .roles.values + .sortedWith(compareBy({ 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 = state.load().memberRoles[pubkey.lowercase()].orEmpty() + + /** Every pubkey with at least one role, with its role ids in assignment order. */ + fun listRoleAssignments(): List>> = + 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 = 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> = emptyList(), @@ -173,14 +320,28 @@ class BanStore( bannedEvents: List> = emptyList(), allowedKinds: List = emptyList(), disallowedKinds: List = emptyList(), + allowedEvents: List> = emptyList(), + roles: List = emptyList(), + roleAssignments: List>> = emptyList(), + claims: List = 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(), ), ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt index 9e8ce452ee..1a838ea773 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt @@ -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? { 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( + val value: T?, +) + +/** Missing / `null` -> empty; a JSON string -> its content; anything else -> null (malformed). */ +private fun JsonArray.optString(index: Int): OptionalParam? { + 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? { + 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 15d8d83388..2534fe4d28 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/RelayRolesTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/RelayRolesTest.kt new file mode 100644 index 0000000000..95f097be5c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip43RelayMembers/RelayRolesTest.kt @@ -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(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(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( + signer.sign( + 1000, + RelayRoleEvent.KIND, + arrayOf(arrayOf("-"), arrayOf("d", "r2"), arrayOf("color", "400"), arrayOf("order", "first")), + "", + ), + ) + assertNull(odd.color()) + assertNull(odd.order()) + + assertFailsWith { 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(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(signer.sign(RelayMembershipListEvent.build(listOf(alice)))) + assertEquals(listOf(RelayMember(alice)), legacy.membersWithRoles()) + } + + @Test + fun joinRequestCarriesClaimAndProtectedTag() { + val signed = assertIs(signer.sign(RelayJoinRequestEvent.build("invite-code"))) + assertTrue(signed.isProtected()) + assertEquals("invite-code", signed.claim()) + + assertFailsWith { RelayJoinRequestEvent.build(" ") } + } + + @Suppress("DEPRECATION") + @Test + fun deprecatedInviteRequestStillParses() { + val signed = + signer.sign(1000, RelayInviteRequestEvent.KIND, arrayOf(arrayOf("-"), arrayOf("claim", "abc")), "") + assertIs(signed) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86RequestResponseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86RequestResponseTest.kt new file mode 100644 index 0000000000..32b5168a99 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86RequestResponseTest.kt @@ -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(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}"""))) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt index 66bfdb0f29..3a1058f56f 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt @@ -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 diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt index 534eb84b4e..615009c503 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt @@ -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) } } }