From 0d485b51bb59037334c90b028d3790b0bfdf263f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:57:44 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- cli/README.md | 2 +- .../commands/RelayGroupModerationCommands.kt | 66 ++++++++++++-- .../amethyst/cli/RelayGroupPinReadTest.kt | 85 +++++++++++++++++++ 3 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/RelayGroupPinReadTest.kt diff --git a/cli/README.md b/cli/README.md index a7e5518690..96f433da99 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt index 91cdbbbad1..569120d012 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt @@ -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() - .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, + ) : 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() + .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, diff --git a/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/RelayGroupPinReadTest.kt b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/RelayGroupPinReadTest.kt new file mode 100644 index 0000000000..9262d824b9 --- /dev/null +++ b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/RelayGroupPinReadTest.kt @@ -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(read).pins) + } + + @Test + fun aTimedOutReadAbortsWithTimeout() { + val read = RelayGroupModerationCommands.readPinList(FetchAllResult(emptyList(), emptyMap(), setOf(relay)), relay, relayKey) + assertEquals("timeout", assertIs(read).code) + } + + @Test + fun aClosedOrUnreachableReadAborts() { + val read = RelayGroupModerationCommands.readPinList(FetchAllResult(emptyList(), mapOf(relay to "cannot:refused"), emptySet()), relay, relayKey) + assertEquals("fetch_failed", assertIs(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(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(read).code) + } +}