mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
fix: relay-group audit — timeline, roster, membership, list-safety
Fixes found in a full audit of the NIP-29 relay-groups feature across quartz/commons/amethyst/cli. Correctness (app): - Own group messages never appeared in the timeline until an app restart: the optimistic send is consumed with a null relay, so attachToRelayGroup bailed on the relay==null guard, and the host relay's echo (new==false) was skipped by the "only attach when newly consumed" gate. Attach now runs on every arrival, gated on the note being loaded, and the null-relay case attaches to the already-open channel(s) for that group id. Also avoids the wrong-relay phantom by only fabricating a channel from real provenance. - Roster subscription was frozen after an in-place join/leave (state keyed on the stable account, never re-derived); it now invalidates on every liveRelayGroupList change, so a fresh join's 39002 admission is fetched. - membershipOf demoted a 39001 admin with an empty/unknown role to MEMBER, hiding moderation; presence in the admins list now means at least MODERATOR. - Members roster showed permanent truncated-hex names (one-shot getUserIfExists cached null); uses checkGetOrCreateUser so UsernameDisplay fills in when kind:0 arrives. Protocol / data: - GroupTag had no value equality → joined-group Sets never deduped and the StateFlow re-emitted on every identical re-arrival. Equality is now the (id, relay) pair, excluding the cosmetic name. - create/edit emitted non-canonical ["public"]/["open"] status tags; NIP-29 flags are presence-only, so only private/closed are emitted when set. - Metadata/member/admin supersede guards use <= so an equal-createdAt duplicate isn't reprocessed (first-arrival wins); updatedMetadataAt is now private-set. Relay-group channels are now included in the prune loops. CLI: - join/leave/create updated the kind:10009 list from a network-only drain; a slow/empty fetch could publish a fresh list containing ONLY the new group, wiping the rest. Now reads the local store (source of truth) too. - edit re-asserted both visibility axes from flag presence, so --closed on a private group leaked it public. It now reads current 39000 and merges, with --public/--open counter-flags; only the specified axis changes. - create now tracks the new group in kind:10009 (parity with join/Android). UI polish: - Invite dialog no longer mints a 9009 for open groups and won't copy a code it never displayed. Browse "popular" list normalizes URLs before filtering. Tests: GroupTag identity, unknown-role-admin-moderates, equal-createdAt no-resupersede added; all quartz+commons NIP-29 suites and the amy relaygroup harness pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
@@ -578,8 +578,9 @@ private fun printUsage() {
|
||||
| relaygroup join RELAY GID [--code CODE] request to join (kind 9021)
|
||||
| relaygroup leave RELAY GID leave (kind 9022)
|
||||
| relaygroup message RELAY GID TEXT post a kind-9 chat to the group
|
||||
| relaygroup edit RELAY GID [--name N] edit metadata (kind 9002, admin)
|
||||
| [--about A] [--private] [--closed]
|
||||
| relaygroup edit RELAY GID [--name N] edit metadata (kind 9002, admin);
|
||||
| [--about A] [--private|--public] reads current visibility and only
|
||||
| [--closed|--open] changes the axis you specify
|
||||
| relaygroup invite RELAY GID --code CODE mint an invite code (kind 9009)
|
||||
| relaygroup put-user RELAY GID PUBKEY add/promote a user (kind 9000)
|
||||
| [--role admin|moderator]
|
||||
|
||||
@@ -94,6 +94,9 @@ object RelayGroupCommands {
|
||||
val status = groupStatus(isPrivate, isClosed)
|
||||
val edit = EditMetadataEvent.build(groupId, name = name, about = about, status = status)
|
||||
val editAck = ctx.publish(ctx.signer.sign(edit), target)
|
||||
// Track it in our own kind:10009 so `relaygroup list` shows it, matching
|
||||
// the Android create flow (Account.createRelayGroup → follow).
|
||||
val listed = updateGroupList(ctx, relay, groupId, add = true)
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
@@ -103,6 +106,7 @@ object RelayGroupCommands {
|
||||
"private" to isPrivate,
|
||||
"closed" to isClosed,
|
||||
"published" to (createAck.values.any { it } && editAck.values.any { it }),
|
||||
"listed" to listed,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
@@ -192,13 +196,20 @@ private suspend fun updateGroupList(
|
||||
val outbox = ctx.outboxRelays()
|
||||
if (outbox.isEmpty()) return false
|
||||
|
||||
// Load the current list from BOTH the local store (amy's source of truth —
|
||||
// every list we've published/synced is here) and a fresh relay drain, then
|
||||
// take the newest. Relying on the drain alone is unsafe: a slow or empty
|
||||
// fetch would look like "no list", and the `create` branch below would then
|
||||
// replace the user's entire kind:10009 with just this one group.
|
||||
val filter = Filter(kinds = listOf(SimpleGroupListEvent.KIND), authors = listOf(ctx.identity.pubKeyHex), limit = 1)
|
||||
val current =
|
||||
val stored = ctx.latestReplaceable(ctx.identity.pubKeyHex, SimpleGroupListEvent.KIND) as? SimpleGroupListEvent
|
||||
val drained =
|
||||
ctx
|
||||
.drain(outbox.associateWith { listOf(filter) }, 5_000)
|
||||
.map { it.second }
|
||||
.filterIsInstance<SimpleGroupListEvent>()
|
||||
.maxByOrNull { it.createdAt }
|
||||
val current = listOfNotNull(stored, drained).maxByOrNull { it.createdAt }
|
||||
|
||||
val tag = GroupTag(groupId, relay.url, null)
|
||||
val updated =
|
||||
@@ -212,14 +223,19 @@ private suspend fun updateGroupList(
|
||||
return ctx.publish(updated, outbox).values.any { it }
|
||||
}
|
||||
|
||||
/** The NIP-29 status flag set for the given visibility toggles. */
|
||||
/**
|
||||
* The NIP-29 status flag set for the given visibility. NIP-29 flags are
|
||||
* presence-only: a group is public/open by the ABSENCE of the private/closed
|
||||
* tags, so we emit only the restrictive flags that are actually on — never a
|
||||
* `["public"]`/`["open"]` tag (which are non-canonical and can confuse relays).
|
||||
*/
|
||||
internal fun groupStatus(
|
||||
isPrivate: Boolean,
|
||||
isClosed: Boolean,
|
||||
): Set<GroupMetadataEvent.GroupStatus> =
|
||||
buildSet {
|
||||
add(if (isPrivate) GroupMetadataEvent.GroupStatus.PRIVATE else GroupMetadataEvent.GroupStatus.PUBLIC)
|
||||
add(if (isClosed) GroupMetadataEvent.GroupStatus.CLOSED else GroupMetadataEvent.GroupStatus.OPEN)
|
||||
if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE)
|
||||
if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+73
-10
@@ -24,6 +24,8 @@ 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.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
|
||||
@@ -35,22 +37,83 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
|
||||
* host relay via [publishScoped] or a direct publish.
|
||||
*/
|
||||
object RelayGroupModerationCommands {
|
||||
/** `relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private] [--closed]` → 9002. */
|
||||
/**
|
||||
* `relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private|--public] [--closed|--open]` → 9002.
|
||||
*
|
||||
* A kind-9002 edit re-asserts the group's status flags, so sending only one
|
||||
* axis would silently reset the other (e.g. `--closed` on a private group
|
||||
* would drop `private` and leak it public). To avoid that we read the group's
|
||||
* current 39000 metadata and merge: each axis keeps its current value unless
|
||||
* the caller explicitly changes it with the flag or its counter-flag.
|
||||
*/
|
||||
suspend fun edit(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int =
|
||||
publishScoped(dataDir, rest, "relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private] [--closed]") { _, groupId, args ->
|
||||
// Only touch visibility when the user actually passed a flag — otherwise
|
||||
// leave name/about edits without asserting an (unknown) status.
|
||||
val status =
|
||||
if (args.bool("private") || args.bool("closed")) {
|
||||
groupStatus(args.bool("private"), args.bool("closed"))
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private|--public] [--closed|--open]"
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
|
||||
val filter =
|
||||
Filter(kinds = listOf(GroupMetadataEvent.KIND), tags = mapOf("d" to listOf(groupId)), limit = 1)
|
||||
val meta =
|
||||
ctx
|
||||
.drain(mapOf(relay to listOf(filter)), 6_000)
|
||||
.map { it.second }
|
||||
.filterIsInstance<GroupMetadataEvent>()
|
||||
.maxByOrNull { it.createdAt }
|
||||
if (meta == null) {
|
||||
System.err.println(
|
||||
"warning: could not read current metadata for $groupId on ${relay.url}; " +
|
||||
"visibility will be set from the flags given only",
|
||||
)
|
||||
}
|
||||
|
||||
val isPrivate =
|
||||
if (args.bool("private")) {
|
||||
true
|
||||
} else if (args.bool("public")) {
|
||||
false
|
||||
} else {
|
||||
emptySet()
|
||||
(meta?.isPrivate() ?: false)
|
||||
}
|
||||
EditMetadataEvent.build(groupId, name = args.flag("name"), about = args.flag("about"), status = status)
|
||||
val isClosed =
|
||||
if (args.bool("closed")) {
|
||||
true
|
||||
} else if (args.bool("open")) {
|
||||
false
|
||||
} else {
|
||||
(meta?.isClosed() ?: false)
|
||||
}
|
||||
|
||||
val signed =
|
||||
ctx.signer.sign(
|
||||
EditMetadataEvent.build(
|
||||
groupId,
|
||||
name = args.flag("name"),
|
||||
about = args.flag("about"),
|
||||
status = groupStatus(isPrivate, isClosed),
|
||||
),
|
||||
)
|
||||
val ack = ctx.publish(signed, setOf(relay))
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"group_id" to groupId,
|
||||
"relay" to relay.url,
|
||||
"private" to isPrivate,
|
||||
"closed" to isClosed,
|
||||
"published" to ack.values.any { it },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `relaygroup invite RELAY GROUP_ID --code CODE` → 9009. */
|
||||
suspend fun invite(
|
||||
|
||||
Reference in New Issue
Block a user