diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt index fad68faca1..c33f421c56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -59,9 +60,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayRoleChips import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.relay_members_count import com.vitorpamplona.amethyst.commons.resources.relay_members_empty +import com.vitorpamplona.amethyst.commons.resources.relay_members_invite_code +import com.vitorpamplona.amethyst.commons.resources.relay_members_invite_code_hint import com.vitorpamplona.amethyst.commons.resources.relay_members_join_sent import com.vitorpamplona.amethyst.commons.resources.relay_members_leave_sent import com.vitorpamplona.amethyst.commons.resources.relay_members_loading @@ -72,9 +76,9 @@ import com.vitorpamplona.amethyst.commons.resources.relay_members_you_are_member import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -83,6 +87,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.lastOrNull import kotlinx.coroutines.launch @@ -97,24 +104,32 @@ fun RelayMembersScreen( val normalizedRelayUrl = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } if (normalizedRelayUrl == null) return - var members by remember { mutableStateOf>(emptyList()) } + var members by remember { mutableStateOf>(emptyList()) } + var roles by remember { mutableStateOf>(emptyMap()) } var isLoading by remember { mutableStateOf(true) } var isMember by remember { mutableStateOf(false) } var joinRequestSent by remember { mutableStateOf(false) } var leaveRequestSent by remember { mutableStateOf(false) } + var inviteCode by remember { mutableStateOf("") } val scope = rememberCoroutineScope() - LaunchedEffect(normalizedRelayUrl) { + // NIP-43 lists (13534) and roles (33534) MUST be signed by the relay's NIP-11 `self`. + // Filter by it once the doc resolves; until then, take whatever the relay serves. + val relayInfo by loadRelayInfo(normalizedRelayUrl) + val relaySelf = relayInfo.self + + LaunchedEffect(normalizedRelayUrl, relaySelf) { launch(Dispatchers.IO) { - val filter = - Filter( - kinds = listOf(RelayMembershipListEvent.KIND), - limit = 1, + val authors = relaySelf?.let { listOf(it) } + val filters = + listOf( + Filter(kinds = listOf(RelayMembershipListEvent.KIND), authors = authors, limit = 1), + Filter(kinds = listOf(RelayRoleEvent.KIND), authors = authors), ) val events = accountViewModel.account.client - .fetchAsFlow(normalizedRelayUrl, filter) + .fetchAsFlow(normalizedRelayUrl, filters) .lastOrNull() val membershipEvent = @@ -122,9 +137,19 @@ fun RelayMembersScreen( ?.mapNotNull { it as? RelayMembershipListEvent } ?.maxByOrNull { it.createdAt } - val memberList = membershipEvent?.members() ?: emptyList() + // Only trust role definitions from whoever signed the member list. + val roleSigner = relaySelf ?: membershipEvent?.pubKey + roles = + events + ?.mapNotNull { it as? RelayRoleEvent } + ?.filter { it.pubKey == roleSigner } + ?.groupBy { it.roleId() } + ?.mapValues { (_, versions) -> versions.maxBy { it.createdAt }.role() } + ?: emptyMap() + + val memberList = membershipEvent?.membersWithRoles() ?: emptyList() members = memberList - isMember = memberList.contains(accountViewModel.account.signer.pubKey) + isMember = memberList.any { it.pubKey == accountViewModel.account.signer.pubKey } isLoading = false } } @@ -162,9 +187,12 @@ fun RelayMembersScreen( isLoading = isLoading, joinRequestSent = joinRequestSent, leaveRequestSent = leaveRequestSent, + inviteCode = inviteCode, + onInviteCodeChange = { inviteCode = it }, onJoinRequest = { + val claim = inviteCode.trim() accountViewModel.launchSigner { - sendJoinRequest(normalizedRelayUrl, accountViewModel) + sendJoinRequest(normalizedRelayUrl, claim, accountViewModel) joinRequestSent = true } }, @@ -215,13 +243,21 @@ fun RelayMembersScreen( ) LazyColumn(modifier = Modifier.fillMaxSize()) { - items(members, key = { it }) { memberPubKey -> - val user = remember(memberPubKey) { accountViewModel.account.cache.getOrCreateUser(memberPubKey) } - UserCompose( - baseUser = user, - accountViewModel = accountViewModel, - nav = nav, - ) + items(members, key = { it.pubKey }) { member -> + val user = remember(member.pubKey) { accountViewModel.account.cache.getOrCreateUser(member.pubKey) } + Column { + UserCompose( + baseUser = user, + accountViewModel = accountViewModel, + nav = nav, + ) + // Role ids without a published 33534 definition still show, by id. + val memberRoles = remember(member, roles) { member.roles.map { roles[it] ?: RelayRole(it) } } + RelayRoleChips( + roles = memberRoles, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp), + ) + } } } } @@ -235,9 +271,32 @@ fun MembershipActions( isLoading: Boolean, joinRequestSent: Boolean, leaveRequestSent: Boolean, + inviteCode: String, + onInviteCodeChange: (String) -> Unit, onJoinRequest: () -> Unit, onLeaveRequest: () -> Unit, ) { + if (!isLoading && !isMember && !joinRequestSent) { + // NIP-43 join requests (kind 28934) must carry the invite code the relay issued. + Column( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 16.dp), + ) { + Text( + text = stringRes(Res.string.relay_members_invite_code_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = inviteCode, + onValueChange = onInviteCodeChange, + label = { Text(stringRes(Res.string.relay_members_invite_code)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + Row( modifier = Modifier @@ -293,7 +352,7 @@ fun MembershipActions( fontWeight = FontWeight.Bold, ) } else { - Button(onClick = onJoinRequest) { + Button(onClick = onJoinRequest, enabled = inviteCode.isNotBlank()) { Icon( symbol = MaterialSymbols.PersonAdd, contentDescription = null, @@ -316,6 +375,8 @@ private fun MembershipActionsNotMemberPreview() { isLoading = false, joinRequestSent = false, leaveRequestSent = false, + inviteCode = "", + onInviteCodeChange = {}, onJoinRequest = {}, onLeaveRequest = {}, ) @@ -331,6 +392,8 @@ private fun MembershipActionsIsMemberPreview() { isLoading = false, joinRequestSent = false, leaveRequestSent = false, + inviteCode = "", + onInviteCodeChange = {}, onJoinRequest = {}, onLeaveRequest = {}, ) @@ -346,6 +409,8 @@ private fun MembershipActionsJoinSentPreview() { isLoading = false, joinRequestSent = true, leaveRequestSent = false, + inviteCode = "", + onInviteCodeChange = {}, onJoinRequest = {}, onLeaveRequest = {}, ) @@ -361,6 +426,8 @@ private fun MembershipActionsLeaveSentPreview() { isLoading = false, joinRequestSent = false, leaveRequestSent = true, + inviteCode = "", + onInviteCodeChange = {}, onJoinRequest = {}, onLeaveRequest = {}, ) @@ -369,9 +436,10 @@ private fun MembershipActionsLeaveSentPreview() { suspend fun sendJoinRequest( relay: NormalizedRelayUrl, + claim: String, accountViewModel: AccountViewModel, ) { - val template = RelayJoinRequestEvent.build() + val template = RelayJoinRequestEvent.build(claim) val signedEvent = accountViewModel.account.signer.sign(template) accountViewModel.account.cache.justConsumeMyOwnEvent(signedEvent) accountViewModel.account.client.publish(signedEvent, setOf(relay)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt index 0498e1996f..0ad8237f2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt @@ -82,8 +82,10 @@ import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.relay_management_add import com.vitorpamplona.amethyst.commons.resources.relay_management_allow +import com.vitorpamplona.amethyst.commons.resources.relay_management_allow_event import com.vitorpamplona.amethyst.commons.resources.relay_management_allow_kind import com.vitorpamplona.amethyst.commons.resources.relay_management_allow_pubkey +import com.vitorpamplona.amethyst.commons.resources.relay_management_allowed_events import com.vitorpamplona.amethyst.commons.resources.relay_management_allowed_kinds import com.vitorpamplona.amethyst.commons.resources.relay_management_allowed_pubkeys import com.vitorpamplona.amethyst.commons.resources.relay_management_apply @@ -96,6 +98,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_management_block_ip import com.vitorpamplona.amethyst.commons.resources.relay_management_blocked_ips import com.vitorpamplona.amethyst.commons.resources.relay_management_cancel import com.vitorpamplona.amethyst.commons.resources.relay_management_confirm +import com.vitorpamplona.amethyst.commons.resources.relay_management_disallowed_kinds import com.vitorpamplona.amethyst.commons.resources.relay_management_dismiss import com.vitorpamplona.amethyst.commons.resources.relay_management_error import com.vitorpamplona.amethyst.commons.resources.relay_management_event_id_hex @@ -103,11 +106,13 @@ import com.vitorpamplona.amethyst.commons.resources.relay_management_ip_address import com.vitorpamplona.amethyst.commons.resources.relay_management_kind_number import com.vitorpamplona.amethyst.commons.resources.relay_management_loading import com.vitorpamplona.amethyst.commons.resources.relay_management_moderation_queue +import com.vitorpamplona.amethyst.commons.resources.relay_management_no_allowed_events import com.vitorpamplona.amethyst.commons.resources.relay_management_no_allowed_kinds import com.vitorpamplona.amethyst.commons.resources.relay_management_no_allowed_pubkeys import com.vitorpamplona.amethyst.commons.resources.relay_management_no_banned_events import com.vitorpamplona.amethyst.commons.resources.relay_management_no_banned_pubkeys import com.vitorpamplona.amethyst.commons.resources.relay_management_no_blocked_ips +import com.vitorpamplona.amethyst.commons.resources.relay_management_no_disallowed_kinds import com.vitorpamplona.amethyst.commons.resources.relay_management_no_methods import com.vitorpamplona.amethyst.commons.resources.relay_management_no_moderation_events import com.vitorpamplona.amethyst.commons.resources.relay_management_reason_optional @@ -288,12 +293,19 @@ private fun RelayManagementContent( add(ManagementTab.PUBKEYS) } if (supportedMethods.any { - it in listOf(Nip86Method.BAN_EVENT, Nip86Method.LIST_BANNED_EVENTS, Nip86Method.ALLOW_EVENT, Nip86Method.LIST_EVENTS_NEEDING_MODERATION) + it in + listOf( + Nip86Method.BAN_EVENT, + Nip86Method.LIST_BANNED_EVENTS, + Nip86Method.ALLOW_EVENT, + Nip86Method.LIST_ALLOWED_EVENTS, + Nip86Method.LIST_EVENTS_NEEDING_MODERATION, + ) } ) { add(ManagementTab.EVENTS) } - if (supportedMethods.any { it in listOf(Nip86Method.ALLOW_KIND, Nip86Method.DISALLOW_KIND, Nip86Method.LIST_ALLOWED_KINDS) }) { + if (supportedMethods.any { it in listOf(Nip86Method.ALLOW_KIND, Nip86Method.DISALLOW_KIND, Nip86Method.LIST_ALLOWED_KINDS, Nip86Method.LIST_DISALLOWED_KINDS) }) { add(ManagementTab.KINDS) } if (supportedMethods.any { it in listOf(Nip86Method.BLOCK_IP, Nip86Method.UNBLOCK_IP, Nip86Method.LIST_BLOCKED_IPS) }) { @@ -571,8 +583,10 @@ private fun EventsTab( supportedMethods: List, ) { val bannedEvents by viewModel.bannedEvents.collectAsState() + val allowedEvents by viewModel.allowedEvents.collectAsState() val eventsNeedingModeration by viewModel.eventsNeedingModeration.collectAsState() var showBanDialog by remember { mutableStateOf(false) } + var showAllowDialog by remember { mutableStateOf(false) } LazyColumn( contentPadding = PaddingValues(10.dp), @@ -596,6 +610,7 @@ private fun EventsTab( reason = entry.reason, canAllow = supportedMethods.contains(Nip86Method.ALLOW_EVENT), canBan = supportedMethods.contains(Nip86Method.BAN_EVENT), + // Approve: `allowevent` allow-lists the event (and lifts any ban). onAllow = { viewModel.allowEvent(entry.id) }, onBan = { viewModel.banEvent(entry.id) }, ) @@ -616,16 +631,52 @@ private fun EventsTab( if (bannedEvents.isEmpty()) { item { EmptyListMessage(stringRes(Res.string.relay_management_no_banned_events)) } } else { - items(bannedEvents, key = { it.id }) { entry -> + items(bannedEvents, key = { "banned" + it.id }) { entry -> HexEntryCard( hex = entry.id, reason = entry.reason, - showRemove = false, - onRemove = {}, + showRemove = supportedMethods.contains(Nip86Method.UNBAN_EVENT), + onRemove = { viewModel.unbanEvent(entry.id) }, ) } } } + + if (supportedMethods.contains(Nip86Method.LIST_ALLOWED_EVENTS)) { + item { Spacer(modifier = Modifier.height(8.dp)) } + item { + SectionHeaderWithAdd( + stringRes(Res.string.relay_management_allowed_events), + showAdd = supportedMethods.contains(Nip86Method.ALLOW_EVENT), + onAdd = { showAllowDialog = true }, + ) + } + + if (allowedEvents.isEmpty()) { + item { EmptyListMessage(stringRes(Res.string.relay_management_no_allowed_events)) } + } else { + items(allowedEvents, key = { "allowed" + it.id }) { entry -> + HexEntryCard( + hex = entry.id, + reason = entry.reason, + showRemove = supportedMethods.contains(Nip86Method.UNALLOW_EVENT), + onRemove = { viewModel.unallowEvent(entry.id) }, + ) + } + } + } + } + + if (showAllowDialog) { + HexInputDialog( + title = stringRes(Res.string.relay_management_allow_event), + label = stringRes(Res.string.relay_management_event_id_hex), + onConfirm = { hex, reason -> + viewModel.allowEvent(hex, reason.ifBlank { null }) + showAllowDialog = false + }, + onDismiss = { showAllowDialog = false }, + ) } if (showBanDialog) { @@ -648,6 +699,7 @@ private fun KindsTab( supportedMethods: List, ) { val allowedKinds by viewModel.allowedKinds.collectAsState() + val disallowedKinds by viewModel.disallowedKinds.collectAsState() var showAddDialog by remember { mutableStateOf(false) } LazyColumn( @@ -665,7 +717,7 @@ private fun KindsTab( if (allowedKinds.isEmpty()) { item { EmptyListMessage(stringRes(Res.string.relay_management_no_allowed_kinds)) } } else { - items(allowedKinds, key = { it }) { kind -> + items(allowedKinds, key = { "allowed$it" }) { kind -> KindEntryCard( kind = kind, showRemove = supportedMethods.contains(Nip86Method.DISALLOW_KIND), @@ -673,6 +725,30 @@ private fun KindsTab( ) } } + + // Read-only: NIP-86 has no "undisallow"; `allowkind` would also turn on the allow list. + if (supportedMethods.contains(Nip86Method.LIST_DISALLOWED_KINDS)) { + item { Spacer(modifier = Modifier.height(8.dp)) } + item { + SectionHeaderWithAdd( + stringRes(Res.string.relay_management_disallowed_kinds), + showAdd = false, + onAdd = {}, + ) + } + + if (disallowedKinds.isEmpty()) { + item { EmptyListMessage(stringRes(Res.string.relay_management_no_disallowed_kinds)) } + } else { + items(disallowedKinds, key = { "disallowed$it" }) { kind -> + KindEntryCard( + kind = kind, + showRemove = false, + onRemove = {}, + ) + } + } + } } if (showAddDialog) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt index 9b81f386fc..b67f9fa9c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt @@ -29,11 +29,13 @@ import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client +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.BlockedIp import com.vitorpamplona.quartz.nip86RelayManagement.rpc.EventNeedingModeration +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -69,12 +71,18 @@ class RelayManagementViewModel( private val _bannedEvents = MutableStateFlow>(emptyList()) val bannedEvents: StateFlow> = _bannedEvents + private val _allowedEvents = MutableStateFlow>(emptyList()) + val allowedEvents: StateFlow> = _allowedEvents + private val _eventsNeedingModeration = MutableStateFlow>(emptyList()) val eventsNeedingModeration: StateFlow> = _eventsNeedingModeration private val _allowedKinds = MutableStateFlow>(emptyList()) val allowedKinds: StateFlow> = _allowedKinds + private val _disallowedKinds = MutableStateFlow>(emptyList()) + val disallowedKinds: StateFlow> = _disallowedKinds + private val _blockedIps = MutableStateFlow>(emptyList()) val blockedIps: StateFlow> = _blockedIps @@ -147,6 +155,17 @@ class RelayManagementViewModel( } } + fun loadAllowedEvents() { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.listAllowedEvents()) + if (response.error != null) { + _error.value = response.error + } else { + _allowedEvents.value = client.parseAllowedEvents(response)?.distinctBy { it.id } ?: emptyList() + } + } + } + fun loadEventsNeedingModeration() { viewModelScope.launch { val response = retriever.execute(client, Nip86Request.listEventsNeedingModeration()) @@ -169,6 +188,17 @@ class RelayManagementViewModel( } } + fun loadDisallowedKinds() { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.listDisallowedKinds()) + if (response.error != null) { + _error.value = response.error + } else { + _disallowedKinds.value = client.parseDisallowedKinds(response)?.distinctBy { it } ?: emptyList() + } + } + } + fun loadBlockedIps() { viewModelScope.launch { val response = retriever.execute(client, Nip86Request.listBlockedIps()) @@ -189,7 +219,9 @@ class RelayManagementViewModel( if (response.error != null) { _error.value = response.error } else { + // NIP-86: banning also drops the pubkey from the allow list. loadBannedPubkeys() + loadIfSupported(Nip86Method.LIST_ALLOWED_PUBKEYS) { loadAllowedPubkeys() } } } } @@ -214,7 +246,9 @@ class RelayManagementViewModel( if (response.error != null) { _error.value = response.error } else { + // NIP-86: allowing also lifts any ban on the pubkey. loadAllowedPubkeys() + loadIfSupported(Nip86Method.LIST_BANNED_PUBKEYS) { loadBannedPubkeys() } } } } @@ -236,6 +270,17 @@ class RelayManagementViewModel( ) { viewModelScope.launch { val response = retriever.execute(client, Nip86Request.banEvent(eventId, reason)) + if (response.error != null) { + _error.value = response.error + } else { + reloadEventLists() + } + } + } + + fun unbanEvent(eventId: String) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.unbanEvent(eventId)) if (response.error != null) { _error.value = response.error } else { @@ -244,6 +289,10 @@ class RelayManagementViewModel( } } + /** + * Approves an event: NIP-86 `allowevent` puts it on the relay's event + * allow list and lifts any ban on it (it no longer means "unban"). + */ fun allowEvent( eventId: String, reason: String? = null, @@ -253,11 +302,35 @@ class RelayManagementViewModel( if (response.error != null) { _error.value = response.error } else { - loadEventsNeedingModeration() + reloadEventLists() } } } + fun unallowEvent(eventId: String) { + viewModelScope.launch { + val response = retriever.execute(client, Nip86Request.unallowEvent(eventId)) + if (response.error != null) { + _error.value = response.error + } else { + loadAllowedEvents() + } + } + } + + private fun reloadEventLists() { + loadIfSupported(Nip86Method.LIST_EVENTS_NEEDING_MODERATION) { loadEventsNeedingModeration() } + loadIfSupported(Nip86Method.LIST_BANNED_EVENTS) { loadBannedEvents() } + loadIfSupported(Nip86Method.LIST_ALLOWED_EVENTS) { loadAllowedEvents() } + } + + private inline fun loadIfSupported( + method: String, + load: () -> Unit, + ) { + if (_supportedMethods.value.contains(method)) load() + } + fun changeRelayName(newName: String) { viewModelScope.launch { val response = retriever.execute(client, Nip86Request.changeRelayName(newName)) @@ -292,6 +365,7 @@ class RelayManagementViewModel( _error.value = response.error } else { loadAllowedKinds() + loadIfSupported(Nip86Method.LIST_DISALLOWED_KINDS) { loadDisallowedKinds() } } } } @@ -303,6 +377,7 @@ class RelayManagementViewModel( _error.value = response.error } else { loadAllowedKinds() + loadIfSupported(Nip86Method.LIST_DISALLOWED_KINDS) { loadDisallowedKinds() } } } } @@ -337,12 +412,13 @@ class RelayManagementViewModel( } fun loadAllLists() { - val methods = _supportedMethods.value - if (methods.contains("listbannedpubkeys")) loadBannedPubkeys() - if (methods.contains("listallowedpubkeys")) loadAllowedPubkeys() - if (methods.contains("listbannedevents")) loadBannedEvents() - if (methods.contains("listeventsneedingmoderation")) loadEventsNeedingModeration() - if (methods.contains("listallowedkinds")) loadAllowedKinds() - if (methods.contains("listblockedips")) loadBlockedIps() + loadIfSupported(Nip86Method.LIST_BANNED_PUBKEYS) { loadBannedPubkeys() } + loadIfSupported(Nip86Method.LIST_ALLOWED_PUBKEYS) { loadAllowedPubkeys() } + loadIfSupported(Nip86Method.LIST_BANNED_EVENTS) { loadBannedEvents() } + loadIfSupported(Nip86Method.LIST_ALLOWED_EVENTS) { loadAllowedEvents() } + loadIfSupported(Nip86Method.LIST_EVENTS_NEEDING_MODERATION) { loadEventsNeedingModeration() } + loadIfSupported(Nip86Method.LIST_ALLOWED_KINDS) { loadAllowedKinds() } + loadIfSupported(Nip86Method.LIST_DISALLOWED_KINDS) { loadDisallowedKinds() } + loadIfSupported(Nip86Method.LIST_BLOCKED_IPS) { loadBlockedIps() } } } diff --git a/cli/README.md b/cli/README.md index 359e2c5787..ddb37079c3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -453,8 +453,12 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `amy admin RELAY supported-methods` | List the NIP-86 methods the relay implements. | | `amy admin RELAY ban-pubkey HEX [--reason R]` / `unban-pubkey HEX` / `list-banned-pubkeys` | Pubkey ban list. | | `amy admin RELAY allow-pubkey HEX [--reason R]` / `unallow-pubkey HEX` / `list-allowed-pubkeys` | Pubkey allow list. | -| `amy admin RELAY ban-event ID [--reason R]` / `allow-event ID` / `list-banned-events` / `list-needing-moderation` | Event moderation. | -| `amy admin RELAY allow-kind N` / `disallow-kind N` / `list-allowed-kinds` | Kind allow list. | +| `amy admin RELAY ban-event ID [--reason R]` / `unban-event ID` / `list-banned-events` | Event ban list. `ban-event` also drops the id from the allow list; `unban-event` does not allow-list it. | +| `amy admin RELAY allow-event ID [--reason R]` / `unallow-event ID` / `list-allowed-events` / `list-needing-moderation` | Event allow list (approve an event: it also lifts any ban) and the moderation queue. | +| `amy admin RELAY create-role ID [--label L] [--description D] [--color HUE] [--order N]` / `edit-role ID …` / `delete-role ID` | NIP-43 member roles (kind 33534); `--color` is a hue 0–360. | +| `amy admin RELAY assign-role HEX ROLE` / `unassign-role HEX ROLE` | Give / take a role. | +| `amy admin RELAY create-claim CODE` / `delete-claim CODE` / `list-claims` | NIP-43 invite codes for kind 28934 join requests. | +| `amy admin RELAY allow-kind N` / `disallow-kind N` / `list-allowed-kinds` / `list-disallowed-kinds` | Kind allow / deny lists. | | `amy admin RELAY block-ip IP [--reason R]` / `unblock-ip IP` / `list-blocked-ips` | IP block list. | | `amy admin RELAY change-name S` / `change-description S` / `change-icon URL` | Relay metadata. | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 41f46bbc9c..d275836755 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -116,7 +116,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `git` | `amy git` | ✅ (events + read) | NIP-34: `init` bootstraps a repo from the local `git` checkout (announce + state, like `ngit init`); repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), NIP-32 labels (1985), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `apply` applies a fetched patch to the local tree (`git am`); `browse`/`cat`/`log` read git objects over smart-HTTP v2 (quartz `GitHttpClient`, the same shallow-clone path the Android browser uses). Only git-packfile **push** (writing objects to clone/GRASP servers) and NIP-34 cover notes (1624, no quartz builder yet) are out of scope. Event tag shapes were verified byte-for-byte against the ngit reference implementation and the NIP-34 spec (`clone`/`web` as single multi-value tags, issue `p`-tag for maintainer routing, plain patch/PR `r` tags); the quartz readers stay tolerant of the legacy repeated form. See `quartz/…/nip34Git/GitNip34InteropTest`. | | `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. | | `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. | -| `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. | +| `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, NIP-43 roles + invite claims, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. | | `serve` | `amy serve` | ✅ | Embeds **geode** (the standalone Ktor relay on quartz's relay-server code) — in-memory by default, `--db FILE` for SQLite, account is admin so `amy admin` works against it. NIP-86 + NIP-77 included. | | `wallet` (NIP-60 Cashu) | `amy cashu` | ✅ | See the Cashu row above — full NIP-60/61 wallet + nutzaps. | | `mcp` / `fs` / `spell` | — | 🆕 (niche) | MCP server, FUSE mount, MuSig2/FROST; some pull new deps. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt index e97f71745c..345f3bbbee 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request import okhttp3.OkHttpClient @@ -41,10 +42,15 @@ import okhttp3.OkHttpClient * admin wss://relay ban-pubkey HEX [--reason R] / unban-pubkey HEX * admin wss://relay allow-pubkey HEX [--reason R] / list-allowed-pubkeys * admin wss://relay list-banned-pubkeys - * admin wss://relay ban-event ID [--reason R] / allow-event ID / list-banned-events + * admin wss://relay ban-event ID [--reason R] / unban-event ID / list-banned-events + * admin wss://relay allow-event ID [--reason R] / unallow-event ID / list-allowed-events * admin wss://relay list-needing-moderation + * admin wss://relay create-role ID [--label L] [--description D] [--color HUE] [--order N] + * admin wss://relay edit-role ID [...] / delete-role ID + * admin wss://relay assign-role HEX ROLE / unassign-role HEX ROLE + * admin wss://relay create-claim CODE / delete-claim CODE / list-claims * admin wss://relay change-name S / change-description S / change-icon URL - * admin wss://relay allow-kind N / disallow-kind N / list-allowed-kinds + * admin wss://relay allow-kind N / disallow-kind N / list-allowed-kinds / list-disallowed-kinds * admin wss://relay block-ip IP [--reason R] / unblock-ip IP / list-blocked-ips */ object AdminCommand { @@ -58,16 +64,30 @@ object AdminCommand { | admin RELAY allow-pubkey HEX [--reason R] allow-list a pubkey | admin RELAY unallow-pubkey HEX [--reason R] remove a pubkey from the allow-list | admin RELAY list-allowed-pubkeys list allowed pubkeys - | admin RELAY ban-event ID [--reason R] ban an event id - | admin RELAY allow-event ID [--reason R] allow an event id + | admin RELAY ban-event ID [--reason R] ban an event id (drops it from the allow-list) + | admin RELAY unban-event ID [--reason R] lift an event ban (does not allow-list it) | admin RELAY list-banned-events list banned events + | admin RELAY allow-event ID [--reason R] allow-list an event id (lifts any ban) + | admin RELAY unallow-event ID [--reason R] remove an event id from the allow-list + | admin RELAY list-allowed-events list allow-listed events | admin RELAY list-needing-moderation list events flagged for moderation + | admin RELAY create-role ID [--label L] [--description D] [--color HUE] [--order N] + | define a NIP-43 member role (hue 0-360) + | admin RELAY edit-role ID [--label L] [--description D] [--color HUE] [--order N] + | replace a role's definition + | admin RELAY delete-role ID delete a role + | admin RELAY assign-role HEX ROLE give a pubkey a role + | admin RELAY unassign-role HEX ROLE take a role from a pubkey + | admin RELAY list-claims list NIP-43 invite codes the relay accepts + | admin RELAY create-claim CODE create a NIP-43 invite code + | admin RELAY delete-claim CODE revoke a NIP-43 invite code | admin RELAY change-name NAME set the relay's name | admin RELAY change-description TEXT set the relay's description | admin RELAY change-icon URL set the relay's icon | admin RELAY allow-kind N allow an event kind | admin RELAY disallow-kind N disallow an event kind | admin RELAY list-allowed-kinds list allowed kinds + | admin RELAY list-disallowed-kinds list disallowed kinds | admin RELAY block-ip IP [--reason R] block an IP address | admin RELAY unblock-ip IP unblock an IP address | admin RELAY list-blocked-ips list blocked IPs @@ -86,7 +106,12 @@ object AdminCommand { val method = args.positionalOrNull(1) ?: return Output.error("bad_args", "missing method; e.g. supported-methods") val relay = RelayUrlNormalizer.normalizeOrNull(relayArg) ?: return Output.invalidRelayUrl(relayArg) val p2 = args.positionalOrNull(2) + val p3 = args.positionalOrNull(3) val reason = args.flag("reason") + val label = args.flag("label") + val description = args.flag("description") + val color = args.flag("color") + val order = args.flag("order") args.rejectUnknown() fun needArg(name: String): String? = @@ -95,6 +120,19 @@ object AdminCommand { null } + fun needSecondArg(name: String): String? = + p3 ?: run { + Output.error("bad_args", "$method requires a $name argument") + null + } + + val colorInt = + color?.let { + it.toIntOrNull()?.takeIf { hue -> RelayRole.isValidHue(hue) } + ?: return Output.error("bad_args", "--color must be a hue between 0 and 360") + } + val orderInt = order?.let { it.toIntOrNull() ?: return Output.error("bad_args", "--order must be an integer") } + val request: Nip86Request = when (method) { "supported-methods" -> Nip86Request.supportedMethods() @@ -105,15 +143,27 @@ object AdminCommand { "unallow-pubkey" -> Nip86Request.unallowPubkey(needArg("pubkey") ?: return 2, reason) "list-allowed-pubkeys" -> Nip86Request.listAllowedPubkeys() "ban-event" -> Nip86Request.banEvent(needArg("event-id") ?: return 2, reason) - "allow-event" -> Nip86Request.allowEvent(needArg("event-id") ?: return 2, reason) + "unban-event" -> Nip86Request.unbanEvent(needArg("event-id") ?: return 2, reason) "list-banned-events" -> Nip86Request.listBannedEvents() + "allow-event" -> Nip86Request.allowEvent(needArg("event-id") ?: return 2, reason) + "unallow-event" -> Nip86Request.unallowEvent(needArg("event-id") ?: return 2, reason) + "list-allowed-events" -> Nip86Request.listAllowedEvents() "list-needing-moderation" -> Nip86Request.listEventsNeedingModeration() + "create-role" -> Nip86Request.createRole(needArg("role-id") ?: return 2, label, description, colorInt, orderInt) + "edit-role" -> Nip86Request.editRole(needArg("role-id") ?: return 2, label, description, colorInt, orderInt) + "delete-role" -> Nip86Request.deleteRole(needArg("role-id") ?: return 2) + "assign-role" -> Nip86Request.assignRole(needArg("pubkey") ?: return 2, needSecondArg("role-id") ?: return 2) + "unassign-role" -> Nip86Request.unassignRole(needArg("pubkey") ?: return 2, needSecondArg("role-id") ?: return 2) + "list-claims" -> Nip86Request.listClaims() + "create-claim" -> Nip86Request.createClaim(needArg("claim") ?: return 2) + "delete-claim" -> Nip86Request.deleteClaim(needArg("claim") ?: return 2) "change-name" -> Nip86Request.changeRelayName(needArg("name") ?: return 2) "change-description" -> Nip86Request.changeRelayDescription(needArg("description") ?: return 2) "change-icon" -> Nip86Request.changeRelayIcon(needArg("icon-url") ?: return 2) "allow-kind" -> Nip86Request.allowKind((needArg("kind") ?: return 2).toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer")) "disallow-kind" -> Nip86Request.disallowKind((needArg("kind") ?: return 2).toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer")) "list-allowed-kinds" -> Nip86Request.listAllowedKinds() + "list-disallowed-kinds" -> Nip86Request.listDisallowedKinds() "block-ip" -> Nip86Request.blockIp(needArg("ip") ?: return 2, reason) "unblock-ip" -> Nip86Request.unblockIp(needArg("ip") ?: return 2) "list-blocked-ips" -> Nip86Request.listBlockedIps() diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 3782cc7eb2..5ad1e808a5 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2805,8 +2805,13 @@ No events needing moderation Banned Events No banned events + Allowed Events + No allowed events + Allow Event Allowed Kinds No allowed kinds + Disallowed Kinds + No disallowed kinds Blocked IPs No blocked IPs Add @@ -2839,6 +2844,8 @@ Join request sent Leave request sent You are a member + Invite code + This relay admits members with an invite code from its operator Relay membership list Member added to relay %1$d members added to relay diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt index 731198bcec..8ad7a55ca3 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt @@ -20,18 +20,23 @@ */ package com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -52,6 +57,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent +import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole /** NIP-43 kind 13534: the relay's current member list, shown as a count. */ @Composable @@ -123,6 +129,52 @@ fun RelayLeaveRequestCard() { ) } +/** + * The NIP-43 roles (kind 33534) a relay assigned to a member, as small labelled + * chips tinted with each role's hue. Roles without a label show their id; roles + * without a color use the theme's secondary container. Sorted by the roles' + * display `order`. + */ +@Composable +fun RelayRoleChips( + roles: List, + modifier: Modifier = Modifier, +) { + if (roles.isEmpty()) return + val sorted = remember(roles) { roles.sortedWith(compareBy({ it.order ?: Int.MAX_VALUE }, { it.label ?: it.id })) } + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + sorted.forEach { RelayRoleChip(it) } + } +} + +@Composable +fun RelayRoleChip(role: RelayRole) { + val hue = role.color + val background = + if (hue != null && RelayRole.isValidHue(hue)) { + Color.hsv(hue.toFloat(), 0.45f, 0.85f) + } else { + MaterialTheme.colorScheme.secondaryContainer + } + val content = if (hue != null && RelayRole.isValidHue(hue)) Color.Black else MaterialTheme.colorScheme.onSecondaryContainer + + Text( + text = role.label ?: role.id, + style = MaterialTheme.typography.labelSmall, + color = content, + maxLines = 1, + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(background) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) +} + @Composable private fun RelayMemberEventCard( icon: MaterialSymbol, @@ -175,6 +227,20 @@ private fun RelayMembershipListCardPreview() { } } +@Preview +@Composable +private fun RelayRoleChipsPreview() { + ThemeComparisonColumn { + RelayRoleChips( + listOf( + RelayRole("28b7e50f", label = "king", color = 37, order = 1), + RelayRole("mod", label = "moderator", color = 200, order = 2), + RelayRole("plain"), + ), + ) + } +} + @Preview @Composable private fun RelayAddMemberCardPreview() { 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 a4f4695cd4..0df568b07a 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.NwcInfoEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent @@ -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 06f5682286..a3986c0516 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.NwcInfoEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent @@ -854,6 +855,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) } } }