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:
Claude
2026-07-08 02:27:18 +00:00
parent d85bac1f55
commit 633903b5c1
14 changed files with 235 additions and 52 deletions
@@ -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)
}
/**
@@ -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(