mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+1
-1
@@ -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)
|
||||
|
||||
|
||||
+59
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user