mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
Merge branch 'worktree-agent-ae1faf5e57eec1f75' into claude/serene-lamport-3eyza2
This commit is contained in:
+83
-15
@@ -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<List<HexKey>>(emptyList()) }
|
||||
var members by remember { mutableStateOf<List<RelayMember>>(emptyList()) }
|
||||
var roles by remember { mutableStateOf<Map<String, RelayRole>>(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) }
|
||||
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))
|
||||
|
||||
+82
-6
@@ -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<String>,
|
||||
) {
|
||||
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<String>,
|
||||
) {
|
||||
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) {
|
||||
|
||||
+84
-8
@@ -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<List<BannedEvent>>(emptyList())
|
||||
val bannedEvents: StateFlow<List<BannedEvent>> = _bannedEvents
|
||||
|
||||
private val _allowedEvents = MutableStateFlow<List<AllowedEvent>>(emptyList())
|
||||
val allowedEvents: StateFlow<List<AllowedEvent>> = _allowedEvents
|
||||
|
||||
private val _eventsNeedingModeration = MutableStateFlow<List<EventNeedingModeration>>(emptyList())
|
||||
val eventsNeedingModeration: StateFlow<List<EventNeedingModeration>> = _eventsNeedingModeration
|
||||
|
||||
private val _allowedKinds = MutableStateFlow<List<Int>>(emptyList())
|
||||
val allowedKinds: StateFlow<List<Int>> = _allowedKinds
|
||||
|
||||
private val _disallowedKinds = MutableStateFlow<List<Int>>(emptyList())
|
||||
val disallowedKinds: StateFlow<List<Int>> = _disallowedKinds
|
||||
|
||||
private val _blockedIps = MutableStateFlow<List<BlockedIp>>(emptyList())
|
||||
val blockedIps: StateFlow<List<BlockedIp>> = _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() }
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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. |
|
||||
|
||||
|
||||
+1
-1
@@ -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. |
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2805,8 +2805,13 @@
|
||||
<string name="relay_management_no_moderation_events">No events needing moderation</string>
|
||||
<string name="relay_management_banned_events">Banned Events</string>
|
||||
<string name="relay_management_no_banned_events">No banned events</string>
|
||||
<string name="relay_management_allowed_events">Allowed Events</string>
|
||||
<string name="relay_management_no_allowed_events">No allowed events</string>
|
||||
<string name="relay_management_allow_event">Allow Event</string>
|
||||
<string name="relay_management_allowed_kinds">Allowed Kinds</string>
|
||||
<string name="relay_management_no_allowed_kinds">No allowed kinds</string>
|
||||
<string name="relay_management_disallowed_kinds">Disallowed Kinds</string>
|
||||
<string name="relay_management_no_disallowed_kinds">No disallowed kinds</string>
|
||||
<string name="relay_management_blocked_ips">Blocked IPs</string>
|
||||
<string name="relay_management_no_blocked_ips">No blocked IPs</string>
|
||||
<string name="relay_management_add">Add</string>
|
||||
@@ -2839,6 +2844,8 @@
|
||||
<string name="relay_members_join_sent">Join request sent</string>
|
||||
<string name="relay_members_leave_sent">Leave request sent</string>
|
||||
<string name="relay_members_you_are_member">You are a member</string>
|
||||
<string name="relay_members_invite_code">Invite code</string>
|
||||
<string name="relay_members_invite_code_hint">This relay admits members with an invite code from its operator</string>
|
||||
<string name="relay_membership_list">Relay membership list</string>
|
||||
<string name="relay_member_added">Member added to relay</string>
|
||||
<string name="relay_members_added">%1$d members added to relay</string>
|
||||
|
||||
+66
@@ -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<RelayRole>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (roles.isEmpty()) return
|
||||
val sorted = remember(roles) { roles.sortedWith(compareBy<RelayRole>({ 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() {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.geode.config
|
||||
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -36,7 +37,9 @@ import java.nio.file.StandardCopyOption
|
||||
* - the live NIP-11 info doc (so `changerelayname/description/icon`
|
||||
* survive a restart), and
|
||||
* - the NIP-86 ban / allow / kind lists for pubkeys, events, and
|
||||
* kinds (NIP-86 admin RPC mutates these directly).
|
||||
* kinds (NIP-86 admin RPC mutates these directly), and
|
||||
* - the NIP-43 role definitions, role assignments and invite codes
|
||||
* managed through the NIP-86 role / claim methods.
|
||||
*
|
||||
* One JSON file per relay. Lives next to the SQLite event store by
|
||||
* convention, but the path is configurable independently via
|
||||
@@ -133,6 +136,33 @@ data class RuntimeConfigData(
|
||||
val bannedEvents: List<BannedEntry> = emptyList(),
|
||||
val allowedKinds: List<Int> = emptyList(),
|
||||
val disallowedKinds: List<Int> = emptyList(),
|
||||
val allowedEvents: List<BannedEntry> = emptyList(),
|
||||
val roles: List<RoleEntry> = emptyList(),
|
||||
val roleAssignments: List<RoleAssignmentEntry> = emptyList(),
|
||||
val claims: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/** A NIP-43 role definition (NIP-86 `createrole` params). */
|
||||
@Serializable
|
||||
data class RoleEntry(
|
||||
val id: String,
|
||||
val label: String? = null,
|
||||
val description: String? = null,
|
||||
val color: Int? = null,
|
||||
val order: Int? = null,
|
||||
) {
|
||||
fun toRole() = RelayRole(id, label, description, color, order)
|
||||
|
||||
companion object {
|
||||
fun of(role: RelayRole) = RoleEntry(role.id, role.label, role.description, role.color, role.order)
|
||||
}
|
||||
}
|
||||
|
||||
/** The NIP-43 role ids assigned to one pubkey. */
|
||||
@Serializable
|
||||
data class RoleAssignmentEntry(
|
||||
val pubkey: String,
|
||||
val roles: List<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -149,6 +179,10 @@ fun RuntimeConfigData.seedInto(banStore: BanStore) {
|
||||
bannedEvents = bannedEvents.map { it.key to it.reason },
|
||||
allowedKinds = allowedKinds,
|
||||
disallowedKinds = disallowedKinds,
|
||||
allowedEvents = allowedEvents.map { it.key to it.reason },
|
||||
roles = roles.map { it.toRole() },
|
||||
roleAssignments = roleAssignments.map { it.pubkey to it.roles },
|
||||
claims = claims,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -164,4 +198,8 @@ fun snapshotOf(
|
||||
bannedEvents = banStore.listBannedEvents().map { (k, r) -> BannedEntry(k, r) },
|
||||
allowedKinds = banStore.listAllowedKinds(),
|
||||
disallowedKinds = banStore.listDisallowedKinds(),
|
||||
allowedEvents = banStore.listAllowedEvents().map { (k, r) -> BannedEntry(k, r) },
|
||||
roles = banStore.listRoles().map { RoleEntry.of(it) },
|
||||
roleAssignments = banStore.listRoleAssignments().map { (pk, ids) -> RoleAssignmentEntry(pk, ids) },
|
||||
claims = banStore.listClaims(),
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
|
||||
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -39,6 +40,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
@@ -125,7 +127,7 @@ class Nip86EndToEndTest {
|
||||
fun supportedMethodsListsTheServersMethods() {
|
||||
rpc(Nip86Request.supportedMethods(), admin).use {
|
||||
assertEquals(200, it.code)
|
||||
val json = JsonMapper.fromJson<com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response>(it.body.string())
|
||||
val json = JsonMapper.fromJson<Nip86Response>(it.body.string())
|
||||
val arr = json.result as JsonArray
|
||||
val names = arr.map { e -> e.jsonPrimitive.content }
|
||||
assertTrue(names.contains("supportedmethods"))
|
||||
@@ -169,7 +171,7 @@ class Nip86EndToEndTest {
|
||||
// Admin bans them.
|
||||
rpc(Nip86Request.banPubkey(targetUser.pubKey, "spam"), admin).use {
|
||||
assertEquals(200, it.code)
|
||||
val resp = JsonMapper.fromJson<com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response>(it.body.string())
|
||||
val resp = JsonMapper.fromJson<Nip86Response>(it.body.string())
|
||||
assertEquals(true, (resp.result as JsonPrimitive).boolean)
|
||||
}
|
||||
|
||||
@@ -178,6 +180,33 @@ class Nip86EndToEndTest {
|
||||
assertEquals(false, after, "BanListPolicy must reject events from banned pubkeys")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allowEventLetsOneEventPastABanUntilUnallowed() =
|
||||
runBlocking {
|
||||
val relayUrl = server.url.normalizeRelayUrl()
|
||||
rpc(Nip86Request.banPubkey(targetUser.pubKey, "spam"), admin).use { assertEquals(200, it.code) }
|
||||
|
||||
// Admin approves one specific event from the banned author.
|
||||
val approved = targetUser.sign(TextNoteEvent.build("approved"))
|
||||
rpc(Nip86Request.allowEvent(approved.id, "reviewed"), admin).use { assertEquals(200, it.code) }
|
||||
assertEquals(true, nostrClient.publishAndConfirm(approved, setOf(relayUrl)), "allow-listed event bypasses the pubkey ban")
|
||||
|
||||
// Any other event from that author is still blocked.
|
||||
val other = targetUser.sign(TextNoteEvent.build("other"))
|
||||
assertEquals(false, nostrClient.publishAndConfirm(other, setOf(relayUrl)))
|
||||
|
||||
rpc(Nip86Request.listAllowedEvents(), admin).use {
|
||||
val resp = JsonMapper.fromJson<Nip86Response>(it.body.string())
|
||||
val ids = (resp.result as JsonArray).map { e -> (e as JsonObject)["id"]!!.jsonPrimitive.content }
|
||||
assertEquals(listOf(approved.id), ids)
|
||||
}
|
||||
|
||||
// unallowevent drops the exemption without banning the event.
|
||||
rpc(Nip86Request.unallowEvent(approved.id), admin).use { assertEquals(200, it.code) }
|
||||
assertTrue(!relay.banStore.isAllowedEvent(approved.id))
|
||||
assertTrue(!relay.banStore.isBannedEvent(approved.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun changeRelayNameFlowsToNip11Endpoint() {
|
||||
rpc(Nip86Request.changeRelayName("renamed-by-admin"), admin).use {
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.vitorpamplona.geode.RelayEngine
|
||||
import com.vitorpamplona.geode.RelayInfo
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.AfterTest
|
||||
@@ -124,6 +125,47 @@ class RuntimeConfigTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eventAllowListRolesAndClaimsSurviveRestart() {
|
||||
val pk = "b".repeat(64)
|
||||
val allowedId = "c".repeat(64)
|
||||
val r1 = relayPersisted()
|
||||
try {
|
||||
r1.banStore.allowEvent(allowedId, "approved")
|
||||
r1.banStore.createRole(RelayRole("mod", label = "Moderator", description = "keeps order", color = 120, order = 2))
|
||||
r1.banStore.assignRole(pk, "mod")
|
||||
r1.banStore.createClaim("invite-123")
|
||||
} finally {
|
||||
r1.close()
|
||||
}
|
||||
|
||||
val r2 = relayPersisted()
|
||||
try {
|
||||
assertEquals(listOf(allowedId to "approved"), r2.banStore.listAllowedEvents())
|
||||
assertTrue(r2.banStore.isAllowedEvent(allowedId))
|
||||
assertEquals(RelayRole("mod", "Moderator", "keeps order", 120, 2), r2.banStore.getRole("mod"))
|
||||
assertEquals(listOf("mod"), r2.banStore.rolesOf(pk))
|
||||
assertEquals(listOf("invite-123"), r2.banStore.listClaims())
|
||||
} finally {
|
||||
r2.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oldStateFileWithoutNewSectionsStillLoads() {
|
||||
// A snapshot written before the event allow list / roles / claims existed.
|
||||
stateFile.writeText("""{"info":{"name":"old"},"bannedEvents":[{"key":"${"d".repeat(64)}","reason":"x"}]}""")
|
||||
val r = relayPersisted()
|
||||
try {
|
||||
assertTrue(r.banStore.isBannedEvent("d".repeat(64)))
|
||||
assertEquals(emptyList(), r.banStore.listAllowedEvents())
|
||||
assertEquals(emptyList(), r.banStore.listRoles())
|
||||
assertEquals(emptyList(), r.banStore.listClaims())
|
||||
} finally {
|
||||
r.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun corruptStateFileIsTolerated() {
|
||||
stateFile.writeText("not valid json {")
|
||||
|
||||
@@ -150,6 +150,7 @@ import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEv
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.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"),
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+15
-3
@@ -24,9 +24,17 @@ import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip70ProtectedEvts.protect
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* NIP-43 kind 28934: a request to join a relay. Must carry a NIP-70 `-` tag and
|
||||
* a `claim` tag with the invite code; the relay answers with an `OK`. Invite
|
||||
* codes are minted by the relay (NIP-86 `createclaim`), not requested with the
|
||||
* deprecated kind 28935.
|
||||
*/
|
||||
@Immutable
|
||||
class RelayJoinRequestEvent(
|
||||
id: HexKey,
|
||||
@@ -42,12 +50,16 @@ class RelayJoinRequestEvent(
|
||||
const val KIND = 28934
|
||||
|
||||
fun build(
|
||||
claim: String? = null,
|
||||
claim: String,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<RelayJoinRequestEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, "", createdAt) {
|
||||
claim?.let { claim(it) }
|
||||
): EventTemplate<RelayJoinRequestEvent> {
|
||||
require(claim.isNotBlank()) { "NIP-43 join requests require an invite code (claim)" }
|
||||
return eventTemplate(KIND, "", createdAt) {
|
||||
protect()
|
||||
claim(claim)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember
|
||||
import com.vitorpamplona.quartz.nip70ProtectedEvts.protect
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@@ -39,6 +40,9 @@ class RelayMembershipListEvent(
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
fun members() = tags.members()
|
||||
|
||||
/** Members with the role ids (NIP-43 kind 33534 `d` tags) the relay assigned to each. */
|
||||
fun membersWithRoles() = tags.membersWithRoles()
|
||||
|
||||
companion object {
|
||||
const val KIND = 13534
|
||||
|
||||
@@ -51,5 +55,16 @@ class RelayMembershipListEvent(
|
||||
members(members)
|
||||
initializer()
|
||||
}
|
||||
|
||||
/** Like [build], but each member may carry its assigned role ids. */
|
||||
fun buildWithRoles(
|
||||
members: List<RelayMember>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<RelayMembershipListEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, "", createdAt) {
|
||||
protect()
|
||||
membersWithRoles(members)
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -23,5 +23,8 @@ package com.vitorpamplona.quartz.nip43RelayMembers.list
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.MemberTag
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember
|
||||
|
||||
fun TagArrayBuilder<RelayMembershipListEvent>.members(pubKeys: List<HexKey>) = addAll(MemberTag.assemble(pubKeys))
|
||||
|
||||
fun TagArrayBuilder<RelayMembershipListEvent>.membersWithRoles(members: List<RelayMember>) = addAll(members.map { MemberTag.assemble(it) })
|
||||
|
||||
+4
-1
@@ -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)
|
||||
|
||||
+35
@@ -20,10 +20,27 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip43RelayMembers.list.tags
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* A kind 13534 entry: the member's pubkey plus the NIP-43 role ids (kind
|
||||
* 33534 `d` tags) assigned to them, in tag order. [roles] is empty for a
|
||||
* member without roles, which is also what pre-roles relays publish.
|
||||
*/
|
||||
@Immutable
|
||||
data class RelayMember(
|
||||
val pubKey: HexKey,
|
||||
val roles: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* NIP-43 `["member", <pubkey>, <role-id>...]`. Role ids after the pubkey are
|
||||
* optional: [parse] ignores them (backward compatible) and [parseMember]
|
||||
* returns them.
|
||||
*/
|
||||
class MemberTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "member"
|
||||
@@ -35,8 +52,26 @@ class MemberTag {
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun parseMember(tag: Array<String>): RelayMember? {
|
||||
val pubKey = parse(tag) ?: return null
|
||||
if (tag.size <= 2) return RelayMember(pubKey)
|
||||
val roles = ArrayList<String>(tag.size - 2)
|
||||
for (i in 2 until tag.size) {
|
||||
val role = tag[i]
|
||||
if (role.isNotEmpty() && role !in roles) roles.add(role)
|
||||
}
|
||||
return RelayMember(pubKey, roles)
|
||||
}
|
||||
|
||||
fun assemble(pubKey: HexKey) = arrayOf(TAG_NAME, pubKey)
|
||||
|
||||
fun assemble(
|
||||
pubKey: HexKey,
|
||||
roles: List<String>,
|
||||
): Array<String> = arrayOf(TAG_NAME, pubKey) + roles
|
||||
|
||||
fun assemble(member: RelayMember) = assemble(member.pubKey, member.roles)
|
||||
|
||||
fun assemble(pubKeys: List<HexKey>) = pubKeys.map { assemble(it) }
|
||||
}
|
||||
}
|
||||
|
||||
+47
@@ -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
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip43RelayMembers.roles
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.nip70ProtectedEvts.protect
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* NIP-43 kind 33534: a role the relay defines and may assign to members.
|
||||
*
|
||||
* Signed by the relay's NIP-11 `self` pubkey and protected (NIP-70 `-` tag).
|
||||
* The `d` tag is the role id — the value a kind 13534 `member` tag lists after
|
||||
* the pubkey. `label`, `description`, `color` (a hue, 0..360) and `order`
|
||||
* (display-only sort key) are optional.
|
||||
*/
|
||||
@Immutable
|
||||
class RelayRoleEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
fun roleId() = dTag()
|
||||
|
||||
fun label() = tags.roleLabel()
|
||||
|
||||
fun description() = tags.roleDescription()
|
||||
|
||||
/** Hue in `0..360`, or null when absent or out of range. */
|
||||
fun color() = tags.roleColor()
|
||||
|
||||
fun order() = tags.roleOrder()
|
||||
|
||||
fun role() =
|
||||
RelayRole(
|
||||
id = roleId(),
|
||||
label = label(),
|
||||
description = description(),
|
||||
color = color(),
|
||||
order = order(),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val KIND = 33534
|
||||
|
||||
fun build(
|
||||
role: RelayRole,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<RelayRoleEvent>.() -> Unit = {},
|
||||
) = eventTemplate<RelayRoleEvent>(KIND, "", createdAt) {
|
||||
protect()
|
||||
dTag(role.id)
|
||||
role.label?.let { roleLabel(it) }
|
||||
role.description?.let { roleDescription(it) }
|
||||
role.color?.let {
|
||||
require(RelayRole.isValidHue(it)) { "role color must be a hue between 0 and 360, got $it" }
|
||||
roleColor(it)
|
||||
}
|
||||
role.order?.let { roleOrder(it) }
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip43RelayMembers.roles
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleColorTag
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleDescriptionTag
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleLabelTag
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.roles.tags.RoleOrderTag
|
||||
|
||||
fun TagArrayBuilder<RelayRoleEvent>.roleLabel(label: String) = addUnique(RoleLabelTag.assemble(label))
|
||||
|
||||
fun TagArrayBuilder<RelayRoleEvent>.roleDescription(description: String) = addUnique(RoleDescriptionTag.assemble(description))
|
||||
|
||||
fun TagArrayBuilder<RelayRoleEvent>.roleColor(hue: Int) = addUnique(RoleColorTag.assemble(hue))
|
||||
|
||||
fun TagArrayBuilder<RelayRoleEvent>.roleOrder(order: Int) = addUnique(RoleOrderTag.assemble(order))
|
||||
+36
@@ -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)
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip43RelayMembers.roles.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** NIP-43 kind 33534 `color` tag: a hue from 0 to 360. Non-numeric or out-of-range values parse as null. */
|
||||
class RoleColorTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "color"
|
||||
|
||||
fun parse(tag: Array<String>): Int? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
val hue = tag[1].trim().toIntOrNull() ?: return null
|
||||
ensure(RelayRole.isValidHue(hue)) { return null }
|
||||
return hue
|
||||
}
|
||||
|
||||
fun assemble(hue: Int) = arrayOf(TAG_NAME, hue.toString())
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip43RelayMembers.roles.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** NIP-43 kind 33534 `description` tag. */
|
||||
class RoleDescriptionTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "description"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(description: String) = arrayOf(TAG_NAME, description)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip43RelayMembers.roles.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** NIP-43 kind 33534 `label` tag: the role's display name. */
|
||||
class RoleLabelTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "label"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(label: String) = arrayOf(TAG_NAME, label)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip43RelayMembers.roles.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** NIP-43 kind 33534 `order` tag: a display-only integer sort key. */
|
||||
class RoleOrderTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "order"
|
||||
|
||||
fun parse(tag: Array<String>): Int? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
return tag[1].trim().toIntOrNull()
|
||||
}
|
||||
|
||||
fun assemble(order: Int) = arrayOf(TAG_NAME, order.toString())
|
||||
}
|
||||
}
|
||||
+65
-1
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedEvent
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
|
||||
@@ -84,16 +85,31 @@ class Nip86Client(
|
||||
return JsonMapper.fromJson<List<BannedEvent>>(result.toString())
|
||||
}
|
||||
|
||||
fun parseAllowedEvents(response: Nip86Response): List<AllowedEvent>? {
|
||||
val result = response.result ?: return null
|
||||
return JsonMapper.fromJson<List<AllowedEvent>>(result.toString())
|
||||
}
|
||||
|
||||
fun parseEventsNeedingModeration(response: Nip86Response): List<EventNeedingModeration>? {
|
||||
val result = response.result ?: return null
|
||||
return JsonMapper.fromJson<List<EventNeedingModeration>>(result.toString())
|
||||
}
|
||||
|
||||
fun parseAllowedKinds(response: Nip86Response): List<Int>? {
|
||||
fun parseAllowedKinds(response: Nip86Response): List<Int>? = parseKinds(response)
|
||||
|
||||
fun parseDisallowedKinds(response: Nip86Response): List<Int>? = parseKinds(response)
|
||||
|
||||
private fun parseKinds(response: Nip86Response): List<Int>? {
|
||||
val result = response.result ?: return null
|
||||
return (result as? JsonArray)?.map { it.jsonPrimitive.int }
|
||||
}
|
||||
|
||||
/** `listclaims` result: a plain array of NIP-43 invite codes. */
|
||||
fun parseClaims(response: Nip86Response): List<String>? {
|
||||
val result = response.result ?: return null
|
||||
return (result as? JsonArray)?.map { it.jsonPrimitive.content }
|
||||
}
|
||||
|
||||
fun parseBlockedIps(response: Nip86Response): List<BlockedIp>? {
|
||||
val result = response.result ?: return null
|
||||
return JsonMapper.fromJson<List<BlockedIp>>(result.toString())
|
||||
@@ -134,13 +150,59 @@ class Nip86Client(
|
||||
reason: String? = null,
|
||||
) = Nip86Request.allowEvent(eventId, reason)
|
||||
|
||||
fun unallowEventRequest(
|
||||
eventId: String,
|
||||
reason: String? = null,
|
||||
) = Nip86Request.unallowEvent(eventId, reason)
|
||||
|
||||
fun banEventRequest(
|
||||
eventId: String,
|
||||
reason: String? = null,
|
||||
) = Nip86Request.banEvent(eventId, reason)
|
||||
|
||||
fun unbanEventRequest(
|
||||
eventId: String,
|
||||
reason: String? = null,
|
||||
) = Nip86Request.unbanEvent(eventId, reason)
|
||||
|
||||
fun listBannedEventsRequest() = Nip86Request.listBannedEvents()
|
||||
|
||||
fun listAllowedEventsRequest() = Nip86Request.listAllowedEvents()
|
||||
|
||||
fun createRoleRequest(
|
||||
id: String,
|
||||
label: String? = null,
|
||||
description: String? = null,
|
||||
color: Int? = null,
|
||||
order: Int? = null,
|
||||
) = Nip86Request.createRole(id, label, description, color, order)
|
||||
|
||||
fun editRoleRequest(
|
||||
id: String,
|
||||
label: String? = null,
|
||||
description: String? = null,
|
||||
color: Int? = null,
|
||||
order: Int? = null,
|
||||
) = Nip86Request.editRole(id, label, description, color, order)
|
||||
|
||||
fun deleteRoleRequest(id: String) = Nip86Request.deleteRole(id)
|
||||
|
||||
fun assignRoleRequest(
|
||||
pubkey: String,
|
||||
roleId: String,
|
||||
) = Nip86Request.assignRole(pubkey, roleId)
|
||||
|
||||
fun unassignRoleRequest(
|
||||
pubkey: String,
|
||||
roleId: String,
|
||||
) = Nip86Request.unassignRole(pubkey, roleId)
|
||||
|
||||
fun listClaimsRequest() = Nip86Request.listClaims()
|
||||
|
||||
fun createClaimRequest(claim: String) = Nip86Request.createClaim(claim)
|
||||
|
||||
fun deleteClaimRequest(claim: String) = Nip86Request.deleteClaim(claim)
|
||||
|
||||
fun changeRelayNameRequest(newName: String) = Nip86Request.changeRelayName(newName)
|
||||
|
||||
fun changeRelayDescriptionRequest(newDescription: String) = Nip86Request.changeRelayDescription(newDescription)
|
||||
@@ -153,6 +215,8 @@ class Nip86Client(
|
||||
|
||||
fun listAllowedKindsRequest() = Nip86Request.listAllowedKinds()
|
||||
|
||||
fun listDisallowedKindsRequest() = Nip86Request.listDisallowedKinds()
|
||||
|
||||
fun blockIpRequest(
|
||||
ip: String,
|
||||
reason: String? = null,
|
||||
|
||||
+20
@@ -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"
|
||||
|
||||
+115
@@ -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,
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+12
@@ -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")
|
||||
}
|
||||
|
||||
+179
-18
@@ -21,17 +21,26 @@
|
||||
package com.vitorpamplona.quartz.nip86RelayManagement.server
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
/**
|
||||
* Lock-free runtime state for the NIP-86 management API. Holds the
|
||||
* ban/allow lists that [BanListPolicy] consults on every accept call,
|
||||
* plus an [onMutation] hook so the relay can persist the latest
|
||||
* snapshot whenever an admin RPC mutates state.
|
||||
* the NIP-43 role definitions / assignments and invite codes (claims)
|
||||
* that the role and claim RPCs manage, plus an [onMutation] hook so the
|
||||
* relay can persist the latest snapshot whenever an admin RPC mutates
|
||||
* state.
|
||||
*
|
||||
* Each entry carries an optional reason string so list-* RPCs can echo
|
||||
* back why an admin took the action — useful for audit trails.
|
||||
* Each list entry carries an optional reason string so list-* RPCs can
|
||||
* echo back why an admin took the action — useful for audit trails.
|
||||
*
|
||||
* Ban / allow lists follow NIP-86: `ban*` removes the entry from the
|
||||
* matching allow list and `allow*` removes it from the ban list, while
|
||||
* `unban*` / `unallow*` only clear their own list — they never add the
|
||||
* entry to the opposite one. So a pubkey or event id is never on both
|
||||
* lists at once.
|
||||
*
|
||||
* Persistence is intentionally NOT inside this class; supply
|
||||
* [onMutation] to flush to disk (or wherever) and use [seedFromSnapshot]
|
||||
@@ -47,18 +56,21 @@ class BanStore(
|
||||
private val onMutation: (() -> Unit)? = null,
|
||||
) {
|
||||
/**
|
||||
* Single immutable snapshot of all ban/allow state. Combined into
|
||||
* one object so kind allow/disallow lock-step (allow adds to
|
||||
* allowedKinds AND removes from disallowedKinds) is naturally
|
||||
* atomic — no possibility of an interleaved reader observing a
|
||||
* kind in both sets.
|
||||
* Single immutable snapshot of all state. Combined into one object so
|
||||
* the lock-step moves (ban removes from allow, allow removes from
|
||||
* ban, deleting a role unassigns it) are naturally atomic — no
|
||||
* interleaved reader can observe an entry on both lists.
|
||||
*/
|
||||
private data class State(
|
||||
val bannedPubkeys: Map<HexKey, String?> = emptyMap(),
|
||||
val allowedPubkeys: Map<HexKey, String?> = emptyMap(),
|
||||
val bannedEventIds: Map<HexKey, String?> = emptyMap(),
|
||||
val allowedEventIds: Map<HexKey, String?> = emptyMap(),
|
||||
val allowedKinds: Set<Int> = emptySet(),
|
||||
val disallowedKinds: Set<Int> = emptySet(),
|
||||
val roles: Map<String, RelayRole> = emptyMap(),
|
||||
val memberRoles: Map<HexKey, List<String>> = emptyMap(),
|
||||
val claims: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
private val state = AtomicReference(State())
|
||||
@@ -71,13 +83,33 @@ class BanStore(
|
||||
onMutation?.invoke()
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [mutate], but [transform] may return `null` to leave the state
|
||||
* untouched (e.g. editing a role that doesn't exist). Returns whether
|
||||
* the state changed; [onMutation] only fires when it did.
|
||||
*/
|
||||
private inline fun mutateIf(transform: (State) -> State?): Boolean {
|
||||
while (true) {
|
||||
val current = state.load()
|
||||
val next = transform(current) ?: return false
|
||||
if (state.compareAndSet(current, next)) break
|
||||
}
|
||||
onMutation?.invoke()
|
||||
return true
|
||||
}
|
||||
|
||||
// -- Pubkey ban list -----------------------------------------------------
|
||||
|
||||
/** Bans [pubkey] and, per NIP-86, drops it from the allow list. */
|
||||
fun banPubkey(
|
||||
pubkey: HexKey,
|
||||
reason: String? = null,
|
||||
) = mutate { it.copy(bannedPubkeys = it.bannedPubkeys + (pubkey.lowercase() to reason)) }
|
||||
) = mutate {
|
||||
val pk = pubkey.lowercase()
|
||||
it.copy(bannedPubkeys = it.bannedPubkeys + (pk to reason), allowedPubkeys = it.allowedPubkeys - pk)
|
||||
}
|
||||
|
||||
/** Lifts a ban. Does not add [pubkey] to the allow list. */
|
||||
fun unbanPubkey(pubkey: HexKey) = mutate { it.copy(bannedPubkeys = it.bannedPubkeys - pubkey.lowercase()) }
|
||||
|
||||
fun isBanned(pubkey: HexKey): Boolean = pubkey.lowercase() in state.load().bannedPubkeys
|
||||
@@ -90,11 +122,16 @@ class BanStore(
|
||||
|
||||
// -- Pubkey allow list ---------------------------------------------------
|
||||
|
||||
/** Allow-lists [pubkey] and, per NIP-86, drops it from the ban list. */
|
||||
fun allowPubkey(
|
||||
pubkey: HexKey,
|
||||
reason: String? = null,
|
||||
) = mutate { it.copy(allowedPubkeys = it.allowedPubkeys + (pubkey.lowercase() to reason)) }
|
||||
) = mutate {
|
||||
val pk = pubkey.lowercase()
|
||||
it.copy(allowedPubkeys = it.allowedPubkeys + (pk to reason), bannedPubkeys = it.bannedPubkeys - pk)
|
||||
}
|
||||
|
||||
/** Removes [pubkey] from the allow list. Does not ban it. */
|
||||
fun unallowPubkey(pubkey: HexKey) = mutate { it.copy(allowedPubkeys = it.allowedPubkeys - pubkey.lowercase()) }
|
||||
|
||||
fun isAllowedPubkey(pubkey: HexKey): Boolean = pubkey.lowercase() in state.load().allowedPubkeys
|
||||
@@ -107,24 +144,52 @@ class BanStore(
|
||||
|
||||
fun hasAllowList(): Boolean = state.load().allowedPubkeys.isNotEmpty()
|
||||
|
||||
// -- Event id ban list ---------------------------------------------------
|
||||
// -- Event id ban / allow lists -----------------------------------------
|
||||
|
||||
/** Bans [eventId] and, per NIP-86, drops it from the event allow list. */
|
||||
fun banEvent(
|
||||
eventId: HexKey,
|
||||
reason: String? = null,
|
||||
) = mutate { it.copy(bannedEventIds = it.bannedEventIds + (eventId.lowercase() to reason)) }
|
||||
) = mutate {
|
||||
val id = eventId.lowercase()
|
||||
it.copy(bannedEventIds = it.bannedEventIds + (id to reason), allowedEventIds = it.allowedEventIds - id)
|
||||
}
|
||||
|
||||
/** Removes an event id from the ban list. Mirrors NIP-86 `allowevent`. */
|
||||
fun allowEvent(eventId: HexKey) = mutate { it.copy(bannedEventIds = it.bannedEventIds - eventId.lowercase()) }
|
||||
/** NIP-86 `unbanevent`: removes [eventId] from the ban list without allow-listing it. */
|
||||
fun unbanEvent(eventId: HexKey) = mutate { it.copy(bannedEventIds = it.bannedEventIds - eventId.lowercase()) }
|
||||
|
||||
/**
|
||||
* NIP-86 `allowevent`: adds [eventId] to the event allow list and drops
|
||||
* it from the ban list. See [BanListPolicy] for what an allow-listed
|
||||
* event is exempt from.
|
||||
*/
|
||||
fun allowEvent(
|
||||
eventId: HexKey,
|
||||
reason: String? = null,
|
||||
) = mutate {
|
||||
val id = eventId.lowercase()
|
||||
it.copy(allowedEventIds = it.allowedEventIds + (id to reason), bannedEventIds = it.bannedEventIds - id)
|
||||
}
|
||||
|
||||
/** NIP-86 `unallowevent`: removes [eventId] from the allow list without banning it. */
|
||||
fun unallowEvent(eventId: HexKey) = mutate { it.copy(allowedEventIds = it.allowedEventIds - eventId.lowercase()) }
|
||||
|
||||
fun isBannedEvent(eventId: HexKey): Boolean = eventId.lowercase() in state.load().bannedEventIds
|
||||
|
||||
fun isAllowedEvent(eventId: HexKey): Boolean = eventId.lowercase() in state.load().allowedEventIds
|
||||
|
||||
fun listBannedEvents(): List<Pair<HexKey, String?>> =
|
||||
state
|
||||
.load()
|
||||
.bannedEventIds.entries
|
||||
.map { it.key to it.value }
|
||||
|
||||
fun listAllowedEvents(): List<Pair<HexKey, String?>> =
|
||||
state
|
||||
.load()
|
||||
.allowedEventIds.entries
|
||||
.map { it.key to it.value }
|
||||
|
||||
// -- Kind allow / deny --------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -160,12 +225,94 @@ class BanStore(
|
||||
return kind in s.allowedKinds
|
||||
}
|
||||
|
||||
// -- NIP-43 roles -------------------------------------------------------
|
||||
|
||||
/** NIP-86 `createrole`. Returns false (and changes nothing) if a role with that id exists. */
|
||||
fun createRole(role: RelayRole): Boolean =
|
||||
mutateIf {
|
||||
if (role.id in it.roles) null else it.copy(roles = it.roles + (role.id to role))
|
||||
}
|
||||
|
||||
/** NIP-86 `editrole`. Returns false (and changes nothing) if no role has that id. */
|
||||
fun editRole(role: RelayRole): Boolean =
|
||||
mutateIf {
|
||||
if (role.id !in it.roles) null else it.copy(roles = it.roles + (role.id to role))
|
||||
}
|
||||
|
||||
/** NIP-86 `deleterole`. Also unassigns the role from every member. Idempotent. */
|
||||
fun deleteRole(roleId: String) =
|
||||
mutate { s ->
|
||||
s.copy(
|
||||
roles = s.roles - roleId,
|
||||
memberRoles =
|
||||
s.memberRoles
|
||||
.mapValues { (_, roles) -> roles - roleId }
|
||||
.filterValues { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
|
||||
fun getRole(roleId: String): RelayRole? = state.load().roles[roleId]
|
||||
|
||||
/** Roles sorted by their display [RelayRole.order] (unordered last), then id. */
|
||||
fun listRoles(): List<RelayRole> =
|
||||
state
|
||||
.load()
|
||||
.roles.values
|
||||
.sortedWith(compareBy<RelayRole>({ it.order ?: Int.MAX_VALUE }, { it.id }))
|
||||
|
||||
/** NIP-86 `assignrole`. Returns false (and changes nothing) if the role doesn't exist. */
|
||||
fun assignRole(
|
||||
pubkey: HexKey,
|
||||
roleId: String,
|
||||
): Boolean =
|
||||
mutateIf { s ->
|
||||
if (roleId !in s.roles) return@mutateIf null
|
||||
val pk = pubkey.lowercase()
|
||||
val current = s.memberRoles[pk].orEmpty()
|
||||
if (roleId in current) s else s.copy(memberRoles = s.memberRoles + (pk to current + roleId))
|
||||
}
|
||||
|
||||
/** NIP-86 `unassignrole`. Idempotent. */
|
||||
fun unassignRole(
|
||||
pubkey: HexKey,
|
||||
roleId: String,
|
||||
) = mutate { s ->
|
||||
val pk = pubkey.lowercase()
|
||||
val remaining = s.memberRoles[pk].orEmpty() - roleId
|
||||
s.copy(memberRoles = if (remaining.isEmpty()) s.memberRoles - pk else s.memberRoles + (pk to remaining))
|
||||
}
|
||||
|
||||
fun rolesOf(pubkey: HexKey): List<String> = state.load().memberRoles[pubkey.lowercase()].orEmpty()
|
||||
|
||||
/** Every pubkey with at least one role, with its role ids in assignment order. */
|
||||
fun listRoleAssignments(): List<Pair<HexKey, List<String>>> =
|
||||
state
|
||||
.load()
|
||||
.memberRoles.entries
|
||||
.map { it.key to it.value }
|
||||
|
||||
// -- NIP-43 invite codes (claims) ---------------------------------------
|
||||
|
||||
/** NIP-86 `createclaim`. Idempotent. */
|
||||
fun createClaim(claim: String) = mutate { it.copy(claims = it.claims + claim) }
|
||||
|
||||
/** NIP-86 `deleteclaim`. Idempotent. */
|
||||
fun deleteClaim(claim: String) = mutate { it.copy(claims = it.claims - claim) }
|
||||
|
||||
/** True when [claim] is an invite code the relay currently accepts. */
|
||||
fun isValidClaim(claim: String): Boolean = claim in state.load().claims
|
||||
|
||||
fun listClaims(): List<String> = state.load().claims.toList()
|
||||
|
||||
/**
|
||||
* Bulk-load state without firing [onMutation]. Used at startup to
|
||||
* seed the in-memory state from a persisted snapshot — we don't
|
||||
* want every individual `put` to trigger another disk write. After
|
||||
* this call the store behaves exactly as if every entry had been
|
||||
* mutated through the public API.
|
||||
*
|
||||
* A snapshot that lists an id on both a ban and an allow list (only
|
||||
* possible from a hand-edited file) keeps the ban and drops the allow.
|
||||
*/
|
||||
fun seedFromSnapshot(
|
||||
bannedPubkeys: List<Pair<HexKey, String?>> = emptyList(),
|
||||
@@ -173,14 +320,28 @@ class BanStore(
|
||||
bannedEvents: List<Pair<HexKey, String?>> = emptyList(),
|
||||
allowedKinds: List<Int> = emptyList(),
|
||||
disallowedKinds: List<Int> = emptyList(),
|
||||
allowedEvents: List<Pair<HexKey, String?>> = emptyList(),
|
||||
roles: List<RelayRole> = emptyList(),
|
||||
roleAssignments: List<Pair<HexKey, List<String>>> = emptyList(),
|
||||
claims: List<String> = emptyList(),
|
||||
) {
|
||||
val banned = bannedPubkeys.associate { (k, r) -> k.lowercase() to r }
|
||||
val bannedEv = bannedEvents.associate { (k, r) -> k.lowercase() to r }
|
||||
val roleMap = roles.associateBy { it.id }
|
||||
state.store(
|
||||
State(
|
||||
bannedPubkeys = bannedPubkeys.associate { (k, r) -> k.lowercase() to r },
|
||||
allowedPubkeys = allowedPubkeys.associate { (k, r) -> k.lowercase() to r },
|
||||
bannedEventIds = bannedEvents.associate { (k, r) -> k.lowercase() to r },
|
||||
bannedPubkeys = banned,
|
||||
allowedPubkeys = allowedPubkeys.associate { (k, r) -> k.lowercase() to r } - banned.keys,
|
||||
bannedEventIds = bannedEv,
|
||||
allowedEventIds = allowedEvents.associate { (k, r) -> k.lowercase() to r } - bannedEv.keys,
|
||||
allowedKinds = allowedKinds.toSet(),
|
||||
disallowedKinds = disallowedKinds.toSet(),
|
||||
roles = roleMap,
|
||||
memberRoles =
|
||||
roleAssignments
|
||||
.associate { (pk, ids) -> pk.lowercase() to ids.filter { it in roleMap }.distinct() }
|
||||
.filterValues { it.isNotEmpty() },
|
||||
claims = claims.toSet(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+145
-2
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip86RelayManagement.server
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedEvent
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
|
||||
@@ -60,7 +62,16 @@ import kotlinx.serialization.json.int
|
||||
*
|
||||
* [supportedMethods] is the canonical list this server actually
|
||||
* implements; methods returned outside of it are no-ops and a NIP-86
|
||||
* client must not advertise them.
|
||||
* client must not advertise them. Every admin on the [allowList] holds
|
||||
* every permission, so the list is the same for every authorized caller
|
||||
* (NIP-86 lets it be tailored per caller; there is nothing to tailor).
|
||||
*
|
||||
* The NIP-43 role (`createrole`, `editrole`, `deleterole`, `assignrole`,
|
||||
* `unassignrole`) and invite-code (`listclaims`, `createclaim`,
|
||||
* `deleteclaim`) methods only maintain the [BanStore]'s records. A relay
|
||||
* that publishes kind 13534 / 33534 events or admits kind 28934 join
|
||||
* requests reads them from there ([BanStore.listRoles],
|
||||
* [BanStore.rolesOf], [BanStore.isValidClaim]).
|
||||
*/
|
||||
class Nip86Server(
|
||||
val banStore: BanStore,
|
||||
@@ -118,12 +129,24 @@ class Nip86Server(
|
||||
Nip86Method.ALLOW_PUBKEY,
|
||||
Nip86Method.UNALLOW_PUBKEY,
|
||||
Nip86Method.LIST_ALLOWED_PUBKEYS,
|
||||
Nip86Method.CREATE_ROLE,
|
||||
Nip86Method.EDIT_ROLE,
|
||||
Nip86Method.DELETE_ROLE,
|
||||
Nip86Method.ASSIGN_ROLE,
|
||||
Nip86Method.UNASSIGN_ROLE,
|
||||
Nip86Method.LIST_CLAIMS,
|
||||
Nip86Method.CREATE_CLAIM,
|
||||
Nip86Method.DELETE_CLAIM,
|
||||
Nip86Method.BAN_EVENT,
|
||||
Nip86Method.UNBAN_EVENT,
|
||||
Nip86Method.ALLOW_EVENT,
|
||||
Nip86Method.UNALLOW_EVENT,
|
||||
Nip86Method.LIST_BANNED_EVENTS,
|
||||
Nip86Method.LIST_ALLOWED_EVENTS,
|
||||
Nip86Method.ALLOW_KIND,
|
||||
Nip86Method.DISALLOW_KIND,
|
||||
Nip86Method.LIST_ALLOWED_KINDS,
|
||||
Nip86Method.LIST_DISALLOWED_KINDS,
|
||||
Nip86Method.CHANGE_RELAY_NAME,
|
||||
Nip86Method.CHANGE_RELAY_DESCRIPTION,
|
||||
Nip86Method.CHANGE_RELAY_ICON,
|
||||
@@ -190,14 +213,67 @@ class Nip86Server(
|
||||
}
|
||||
}
|
||||
|
||||
Nip86Method.UNBAN_EVENT -> {
|
||||
withHex(req, "event_id") { id -> banStore.unbanEvent(id) }
|
||||
}
|
||||
|
||||
Nip86Method.ALLOW_EVENT -> {
|
||||
withHex(req, "event_id") { id -> banStore.allowEvent(id) }
|
||||
withHexAndReason(req, "event_id") { id, reason -> banStore.allowEvent(id, reason) }
|
||||
}
|
||||
|
||||
Nip86Method.UNALLOW_EVENT -> {
|
||||
withHex(req, "event_id") { id -> banStore.unallowEvent(id) }
|
||||
}
|
||||
|
||||
Nip86Method.LIST_BANNED_EVENTS -> {
|
||||
ok(banStore.listBannedEvents().map { (id, r) -> BannedEvent(id, r) }.toJsonArray(BannedEvent.serializer()))
|
||||
}
|
||||
|
||||
Nip86Method.LIST_ALLOWED_EVENTS -> {
|
||||
ok(banStore.listAllowedEvents().map { (id, r) -> AllowedEvent(id, r) }.toJsonArray(AllowedEvent.serializer()))
|
||||
}
|
||||
|
||||
Nip86Method.CREATE_ROLE -> {
|
||||
withRole(req) { role ->
|
||||
if (banStore.createRole(role)) okTrue else Nip86Response(error = "role already exists: ${role.id}")
|
||||
}
|
||||
}
|
||||
|
||||
Nip86Method.EDIT_ROLE -> {
|
||||
withRole(req) { role ->
|
||||
if (banStore.editRole(role)) okTrue else Nip86Response(error = "unknown role: ${role.id}")
|
||||
}
|
||||
}
|
||||
|
||||
Nip86Method.DELETE_ROLE -> {
|
||||
withNonBlankString(req, "id") { id -> banStore.deleteRole(id) }
|
||||
}
|
||||
|
||||
Nip86Method.ASSIGN_ROLE -> {
|
||||
withPubkeyAndRole(req) { pk, roleId ->
|
||||
if (banStore.assignRole(pk, roleId)) okTrue else Nip86Response(error = "unknown role: $roleId")
|
||||
}
|
||||
}
|
||||
|
||||
Nip86Method.UNASSIGN_ROLE -> {
|
||||
withPubkeyAndRole(req) { pk, roleId ->
|
||||
banStore.unassignRole(pk, roleId)
|
||||
okTrue
|
||||
}
|
||||
}
|
||||
|
||||
Nip86Method.LIST_CLAIMS -> {
|
||||
ok(buildJsonArray { banStore.listClaims().forEach { add(JsonPrimitive(it)) } })
|
||||
}
|
||||
|
||||
Nip86Method.CREATE_CLAIM -> {
|
||||
withNonBlankString(req, "claim") { claim -> banStore.createClaim(claim) }
|
||||
}
|
||||
|
||||
Nip86Method.DELETE_CLAIM -> {
|
||||
withNonBlankString(req, "claim") { claim -> banStore.deleteClaim(claim) }
|
||||
}
|
||||
|
||||
Nip86Method.ALLOW_KIND -> {
|
||||
withInt(req, "kind") { k -> banStore.allowKind(k) }
|
||||
}
|
||||
@@ -214,6 +290,10 @@ class Nip86Server(
|
||||
ok(buildJsonArray { banStore.listAllowedKinds().forEach { add(JsonPrimitive(it)) } })
|
||||
}
|
||||
|
||||
Nip86Method.LIST_DISALLOWED_KINDS -> {
|
||||
ok(buildJsonArray { banStore.listDisallowedKinds().forEach { add(JsonPrimitive(it)) } })
|
||||
}
|
||||
|
||||
Nip86Method.CHANGE_RELAY_NAME -> {
|
||||
withString(req, "name") { name -> rewriteInfo { it.copy(name = name) } }
|
||||
}
|
||||
@@ -281,6 +361,45 @@ class Nip86Server(
|
||||
return okTrue
|
||||
}
|
||||
|
||||
private inline fun withNonBlankString(
|
||||
req: Nip86Request,
|
||||
label: String,
|
||||
action: (String) -> Unit,
|
||||
): Nip86Response {
|
||||
val v = req.params.firstString()?.takeIf { it.isNotBlank() } ?: return malformed("expected [$label]")
|
||||
action(v)
|
||||
return okTrue
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses NIP-86 `[id, label, description, color, order]`. Only `id` is
|
||||
* required; trailing params may be omitted or `null`. `color` (a hue,
|
||||
* 0..360) and `order` accept a JSON number or a numeric string, since
|
||||
* the kind 33534 tags carry them as strings.
|
||||
*/
|
||||
private inline fun withRole(
|
||||
req: Nip86Request,
|
||||
action: (RelayRole) -> Nip86Response,
|
||||
): Nip86Response {
|
||||
val id = req.params.firstString()?.takeIf { it.isNotBlank() } ?: return malformed("expected [id, label?, description?, color?, order?]")
|
||||
val label = req.params.optString(1) ?: return malformed("label must be a string")
|
||||
val description = req.params.optString(2) ?: return malformed("description must be a string")
|
||||
val color = req.params.optInt(3) ?: return malformed("color must be an integer hue")
|
||||
if (color.value != null && !RelayRole.isValidHue(color.value)) return malformed("color must be a hue between 0 and 360")
|
||||
val order = req.params.optInt(4) ?: return malformed("order must be an integer")
|
||||
return action(RelayRole(id, label.value?.ifEmpty { null }, description.value?.ifEmpty { null }, color.value, order.value))
|
||||
}
|
||||
|
||||
private inline fun withPubkeyAndRole(
|
||||
req: Nip86Request,
|
||||
action: (HexKey, String) -> Nip86Response,
|
||||
): Nip86Response {
|
||||
val (pk, roleId) = req.params.stringPair() ?: return malformed("expected [pubkey, role_id]")
|
||||
if (!Hex.isHex64(pk)) return malformed("pubkey must be 64-char hex")
|
||||
if (roleId.isNullOrBlank()) return malformed("expected [pubkey, role_id]")
|
||||
return action(pk.lowercase(), roleId)
|
||||
}
|
||||
|
||||
private fun rewriteInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
|
||||
infoHolder.set(transform(infoHolder.get()))
|
||||
}
|
||||
@@ -304,6 +423,30 @@ private fun JsonArray.stringPair(): Pair<String, String?>? {
|
||||
|
||||
private fun JsonArray.firstString(): String? = (getOrNull(0) as? JsonPrimitive)?.contentOrNull()
|
||||
|
||||
/** An optional positional param: [value] is null when the param is missing or JSON `null`. */
|
||||
private class OptionalParam<T>(
|
||||
val value: T?,
|
||||
)
|
||||
|
||||
/** Missing / `null` -> empty; a JSON string -> its content; anything else -> null (malformed). */
|
||||
private fun JsonArray.optString(index: Int): OptionalParam<String>? {
|
||||
val el = getOrNull(index) ?: return OptionalParam(null)
|
||||
if (el == JsonNull) return OptionalParam(null)
|
||||
val prim = el as? JsonPrimitive ?: return null
|
||||
if (!prim.isString) return null
|
||||
return OptionalParam(prim.content)
|
||||
}
|
||||
|
||||
/** Missing / `null` / "" -> empty; an integer number or numeric string -> its value; anything else -> null (malformed). */
|
||||
private fun JsonArray.optInt(index: Int): OptionalParam<Int>? {
|
||||
val el = getOrNull(index) ?: return OptionalParam(null)
|
||||
if (el == JsonNull) return OptionalParam(null)
|
||||
val prim = el as? JsonPrimitive ?: return null
|
||||
val text = prim.content.trim()
|
||||
if (prim.isString && text.isEmpty()) return OptionalParam(null)
|
||||
return text.toIntOrNull()?.let { OptionalParam(it) }
|
||||
}
|
||||
|
||||
private fun JsonArray.firstInt(): Int? =
|
||||
runCatching {
|
||||
(this[0] as? JsonPrimitive)?.int
|
||||
|
||||
@@ -250,6 +250,7 @@ import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEv
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.events.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)
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip43RelayMembers
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.inviteRequest.RelayInviteRequestEvent
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.MemberTag
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
|
||||
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent
|
||||
import com.vitorpamplona.quartz.nip70ProtectedEvts.isProtected
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class RelayRolesTest {
|
||||
private val signer = NostrSignerSync(KeyPair())
|
||||
private val alice = "c308e1f882c1f1dff2a43d4294239ddeec04e575f2d1aad1fa21ea7684e61fb5"
|
||||
private val bob = "ee1d336e13779e4d4c527b988429d96de16088f958cbf6c074676ac9cfd9c958"
|
||||
|
||||
@Test
|
||||
fun roleEventBuildsTheSpecExampleAndParsesBack() {
|
||||
val template = RelayRoleEvent.build(RelayRole("28b7e50f", "king", "ruler of the relay", 37, 1), createdAt = 1000)
|
||||
assertEquals(RelayRoleEvent.KIND, template.kind)
|
||||
assertContentEquals(arrayOf("-"), template.tags[0])
|
||||
assertContentEquals(arrayOf("d", "28b7e50f"), template.tags[1])
|
||||
assertContentEquals(arrayOf("label", "king"), template.tags[2])
|
||||
assertContentEquals(arrayOf("description", "ruler of the relay"), template.tags[3])
|
||||
assertContentEquals(arrayOf("color", "37"), template.tags[4])
|
||||
assertContentEquals(arrayOf("order", "1"), template.tags[5])
|
||||
|
||||
val signed = assertIs<RelayRoleEvent>(signer.sign(template))
|
||||
assertTrue(signed.isProtected())
|
||||
assertEquals("28b7e50f", signed.roleId())
|
||||
assertEquals(RelayRole("28b7e50f", "king", "ruler of the relay", 37, 1), signed.role())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roleEventOptionalTagsAndBadValues() {
|
||||
val minimal = assertIs<RelayRoleEvent>(signer.sign(RelayRoleEvent.build(RelayRole("r1"))))
|
||||
assertEquals(RelayRole("r1"), minimal.role())
|
||||
|
||||
// A foreign event with an out-of-range hue and a non-numeric order.
|
||||
val odd =
|
||||
assertIs<RelayRoleEvent>(
|
||||
signer.sign<Event>(
|
||||
1000,
|
||||
RelayRoleEvent.KIND,
|
||||
arrayOf(arrayOf("-"), arrayOf("d", "r2"), arrayOf("color", "400"), arrayOf("order", "first")),
|
||||
"",
|
||||
),
|
||||
)
|
||||
assertNull(odd.color())
|
||||
assertNull(odd.order())
|
||||
|
||||
assertFailsWith<IllegalArgumentException> { RelayRoleEvent.build(RelayRole("r3", color = 361)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun memberTagCarriesOptionalRoles() {
|
||||
val withRoles = arrayOf("member", bob, "28b7e50f", "", "abc", "28b7e50f")
|
||||
assertEquals(bob, MemberTag.parse(withRoles))
|
||||
assertEquals(RelayMember(bob, listOf("28b7e50f", "abc")), MemberTag.parseMember(withRoles))
|
||||
assertEquals(RelayMember(alice), MemberTag.parseMember(arrayOf("member", alice)))
|
||||
assertNull(MemberTag.parseMember(arrayOf("member", "short")))
|
||||
|
||||
assertContentEquals(arrayOf("member", bob, "r1", "r2"), MemberTag.assemble(bob, listOf("r1", "r2")))
|
||||
assertContentEquals(arrayOf("member", alice), MemberTag.assemble(RelayMember(alice)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun membershipListWithRolesKeepsPlainMembersBackwardCompatible() {
|
||||
val template =
|
||||
RelayMembershipListEvent.buildWithRoles(
|
||||
listOf(RelayMember(alice), RelayMember(bob, listOf("28b7e50f"))),
|
||||
)
|
||||
val signed = assertIs<RelayMembershipListEvent>(signer.sign(template))
|
||||
assertTrue(signed.isProtected())
|
||||
assertEquals(listOf(alice, bob), signed.members())
|
||||
assertEquals(listOf(RelayMember(alice), RelayMember(bob, listOf("28b7e50f"))), signed.membersWithRoles())
|
||||
|
||||
val legacy = assertIs<RelayMembershipListEvent>(signer.sign(RelayMembershipListEvent.build(listOf(alice))))
|
||||
assertEquals(listOf(RelayMember(alice)), legacy.membersWithRoles())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun joinRequestCarriesClaimAndProtectedTag() {
|
||||
val signed = assertIs<RelayJoinRequestEvent>(signer.sign(RelayJoinRequestEvent.build("invite-code")))
|
||||
assertTrue(signed.isProtected())
|
||||
assertEquals("invite-code", signed.claim())
|
||||
|
||||
assertFailsWith<IllegalArgumentException> { RelayJoinRequestEvent.build(" ") }
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Test
|
||||
fun deprecatedInviteRequestStillParses() {
|
||||
val signed =
|
||||
signer.sign<Event>(1000, RelayInviteRequestEvent.KIND, arrayOf(arrayOf("-"), arrayOf("claim", "abc")), "")
|
||||
assertIs<RelayInviteRequestEvent>(signed)
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip86RelayManagement
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class Nip86RequestResponseTest {
|
||||
private val client = Nip86Client(RelayUrlNormalizer.normalize("wss://relay.example.com"), NostrSignerInternal(KeyPair()))
|
||||
private val id = "c".repeat(64)
|
||||
private val pk = "a".repeat(64)
|
||||
|
||||
private fun json(req: Nip86Request) = client.serializeRequest(req)
|
||||
|
||||
@Test
|
||||
fun eventAllowAndBanMethods() {
|
||||
assertEquals("""{"method":"allowevent","params":["$id","ok"]}""", json(Nip86Request.allowEvent(id, "ok")))
|
||||
assertEquals("""{"method":"unallowevent","params":["$id"]}""", json(Nip86Request.unallowEvent(id)))
|
||||
assertEquals("""{"method":"unbanevent","params":["$id","oops"]}""", json(Nip86Request.unbanEvent(id, "oops")))
|
||||
assertEquals("""{"method":"listallowedevents","params":[]}""", json(Nip86Request.listAllowedEvents()))
|
||||
assertEquals("""{"method":"listdisallowedkinds","params":[]}""", json(Nip86Request.listDisallowedKinds()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roleMethods() {
|
||||
assertEquals(
|
||||
"""{"method":"createrole","params":["28b7e50f","king","ruler of the relay",37,1]}""",
|
||||
json(Nip86Request.createRole("28b7e50f", "king", "ruler of the relay", 37, 1)),
|
||||
)
|
||||
assertEquals("""{"method":"editrole","params":["r",null,null,null,null]}""", json(Nip86Request.editRole("r")))
|
||||
assertEquals("""{"method":"deleterole","params":["r"]}""", json(Nip86Request.deleteRole("r")))
|
||||
assertEquals("""{"method":"assignrole","params":["$pk","r"]}""", json(Nip86Request.assignRole(pk, "r")))
|
||||
assertEquals("""{"method":"unassignrole","params":["$pk","r"]}""", json(Nip86Request.unassignRole(pk, "r")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun claimMethods() {
|
||||
assertEquals("""{"method":"listclaims","params":[]}""", json(Nip86Request.listClaims()))
|
||||
assertEquals("""{"method":"createclaim","params":["abc"]}""", json(Nip86Request.createClaim("abc")))
|
||||
assertEquals("""{"method":"deleteclaim","params":["abc"]}""", json(Nip86Request.deleteClaim("abc")))
|
||||
assertEquals(Nip86Method.CREATE_CLAIM, JsonMapper.fromJson<Nip86Request>(json(Nip86Request.createClaim("abc"))).method)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesNewResponses() {
|
||||
val allowed = client.parseAllowedEvents(client.parseResponse("""{"result":[{"id":"$id","reason":"ok"},{"id":"$id"}]}"""))!!
|
||||
assertEquals(listOf(id, id), allowed.map { it.id })
|
||||
assertEquals(listOf("ok", null), allowed.map { it.reason })
|
||||
|
||||
assertEquals(listOf(4, 1059), client.parseDisallowedKinds(client.parseResponse("""{"result":[4,1059]}""")))
|
||||
assertEquals(listOf("a", "b"), client.parseClaims(client.parseResponse("""{"result":["a","b"]}""")))
|
||||
assertEquals(true, client.parseBooleanResult(client.parseResponse("""{"result":true}""")))
|
||||
}
|
||||
}
|
||||
+148
-1
@@ -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
|
||||
|
||||
+166
-2
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user