fix(cli): relaygroup pin/unpin never overwrites pins from a failed read

The current kind-39005 is now fetched with authors = the relay's NIP-11
`self`, and only an EOSE-backed empty answer counts as "no pins". A timeout
aborts with `timeout` (exit 124), any other unanswered read with
`fetch_failed`, and a relay with no readable `self` with `no_relay_key`
(exit 1) - instead of publishing a full-replacement 9010 that wipes pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc
This commit is contained in:
Claude
2026-09-27 22:57:44 +00:00
parent 6ccf754915
commit 0d485b51bb
3 changed files with 145 additions and 8 deletions
+1 -1
View File
@@ -599,7 +599,7 @@ screen speaks.
| `amy relaygroup invite RELAY GID --code CODE` | Mint an invite code (9009, moderator). |
| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). |
| `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). |
| `amy relaygroup pin RELAY GID REF` / `unpin …` | Add/remove a pin (9010, moderator). REF is a note1/nevent1/hex id (`e`) or naddr1/`kind:pubkey:d` (`a`); the rest of the current 39005 list is kept. |
| `amy relaygroup pin RELAY GID REF` / `unpin …` | Add/remove a pin (9010, moderator). REF is a note1/nevent1/hex id (`e`) or naddr1/`kind:pubkey:d` (`a`); the rest of the current 39005 list (signed by the relay's NIP-11 `self`) is kept; if that list cannot be read the command aborts (`timeout` → 124, `fetch_failed`/`no_relay_key` → 1) rather than overwrite it. |
### Buzz workspaces (block/buzz — NIP-29 dialect)
@@ -24,7 +24,10 @@ import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.FetchAllResult
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupCreateInviteEvent
@@ -144,6 +147,10 @@ object RelayGroupModerationCommands {
* list, so this reads the group's current kind-39005 and re-submits it with REF added (at the
* end) or removed — every other pin, `e` or `a`, kept verbatim. REF is an event (`note1`,
* `nevent1`, 64-hex → `e`) or an addressable event (`naddr1`, `kind:pubkey:d` → `a`).
*
* The 39005 is only trusted when signed by the relay's NIP-11 `self` key, and a read that did not
* reach EOSE aborts instead of being taken as "no pins" — publishing a full replacement from a
* failed read would wipe every existing pin.
*/
suspend fun pin(
dataDir: DataDir,
@@ -162,14 +169,23 @@ object RelayGroupModerationCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val filter = Filter(kinds = listOf(GroupPinnedEvent.KIND), tags = mapOf("d" to listOf(groupId)), limit = 1)
// NIP-29: the 39005 is "signed by the relay keypair … as stated by the NIP-11 `self`".
// Without that key we cannot tell the real list from a forged one, so do not rewrite it.
val relayKey =
ctx.relayInfo(relay)?.self
?: return Output.error("no_relay_key", "could not read the NIP-11 self pubkey of ${relay.url}; refusing to rewrite the pin list")
val filter =
Filter(
kinds = listOf(GroupPinnedEvent.KIND),
authors = listOf(relayKey),
tags = mapOf("d" to listOf(groupId)),
limit = 1,
)
val current =
ctx
.drain(mapOf(relay to listOf(filter)), 6_000)
.map { it.second }
.filterIsInstance<GroupPinnedEvent>()
.maxByOrNull { it.createdAt }
?.pins() ?: emptyList()
when (val read = readPinList(ctx.drainResult(mapOf(relay to listOf(filter)), 6_000), relay, relayKey)) {
is PinListRead.Found -> read.pins
is PinListRead.Failed -> return Output.error(read.code, read.detail)
}
val updated =
if (pin) {
@@ -195,6 +211,42 @@ object RelayGroupModerationCommands {
}
}
/** The outcome of reading a group's current kind-39005 before a read-merge-write. */
internal sealed interface PinListRead {
class Found(
val pins: List<GroupPin>,
) : PinListRead
class Failed(
val code: String,
val detail: String,
) : PinListRead
}
/**
* The latest relay-signed 39005 in [result]; an empty list only when the relay answered (EOSE)
* and had none. A timeout maps to `timeout` (exit 124), any other unanswered read to
* `fetch_failed` (exit 1).
*/
internal fun readPinList(
result: FetchAllResult,
relay: NormalizedRelayUrl,
relayKey: HexKey,
): PinListRead {
val latest =
result.events
.map { it.second }
.filterIsInstance<GroupPinnedEvent>()
.filter { it.pubKey == relayKey }
.maxByOrNull { it.createdAt }
return when {
latest != null -> PinListRead.Found(latest.pins())
result.anyRelayServed -> PinListRead.Found(emptyList())
relay in result.stalled -> PinListRead.Failed("timeout", "${relay.url} did not answer the pin-list read; refusing to overwrite pins")
else -> PinListRead.Failed("fetch_failed", "could not read the pin list from ${relay.url} (${result.doneReasons[relay] ?: "no answer"}); refusing to overwrite pins")
}
}
/** `relaygroup invite RELAY GROUP_ID --code CODE` → 9009. */
suspend fun invite(
dataDir: DataDir,
@@ -0,0 +1,85 @@
/*
* 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.amethyst.cli
import com.vitorpamplona.amethyst.cli.commands.RelayGroupModerationCommands
import com.vitorpamplona.amethyst.cli.commands.RelayGroupModerationCommands.PinListRead
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.DONE_REASON_EOSE
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.FetchAllResult
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
/**
* `relaygroup pin|unpin` re-submits the whole 39005 list, so a failed read must abort instead of
* being read as "no pins" (which would publish an empty replacement and wipe them).
*/
class RelayGroupPinReadTest {
private val relay = RelayUrlNormalizer.normalize("wss://groups.example.com")
private val relayKey = "aa".repeat(32)
private val stranger = "bb".repeat(32)
private val pinnedId = "cc".repeat(32)
private fun pinned(
author: String,
createdAt: Long,
) = GroupPinnedEvent(
id = "dd".repeat(32),
pubKey = author,
createdAt = createdAt,
tags = arrayOf(arrayOf("d", "gid"), arrayOf("e", pinnedId)),
content = "",
sig = "ee".repeat(64),
)
@Test
fun eoseWithoutAListMeansNoPins() {
val read = RelayGroupModerationCommands.readPinList(FetchAllResult(emptyList(), mapOf(relay to DONE_REASON_EOSE), emptySet()), relay, relayKey)
assertEquals(emptyList(), assertIs<PinListRead.Found>(read).pins)
}
@Test
fun aTimedOutReadAbortsWithTimeout() {
val read = RelayGroupModerationCommands.readPinList(FetchAllResult(emptyList(), emptyMap(), setOf(relay)), relay, relayKey)
assertEquals("timeout", assertIs<PinListRead.Failed>(read).code)
}
@Test
fun aClosedOrUnreachableReadAborts() {
val read = RelayGroupModerationCommands.readPinList(FetchAllResult(emptyList(), mapOf(relay to "cannot:refused"), emptySet()), relay, relayKey)
assertEquals("fetch_failed", assertIs<PinListRead.Failed>(read).code)
}
@Test
fun onlyTheRelaySignedListCounts() {
val events = listOf(relay to pinned(stranger, 200), relay to pinned(relayKey, 100))
val read = RelayGroupModerationCommands.readPinList(FetchAllResult(events, mapOf(relay to DONE_REASON_EOSE), emptySet()), relay, relayKey)
assertEquals(listOf(pinnedId), assertIs<PinListRead.Found>(read).pins.map { it.ref })
}
@Test
fun aForgedListAloneIsNotAnAnswerWithoutEose() {
val read = RelayGroupModerationCommands.readPinList(FetchAllResult(listOf(relay to pinned(stranger, 200)), emptyMap(), setOf(relay)), relay, relayKey)
assertEquals("timeout", assertIs<PinListRead.Failed>(read).code)
}
}