Merge branch 'worktree-agent-ae1faf5e57eec1f75' into claude/serene-lamport-3eyza2

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