fix(nests): align kind-4312 + kind-30312 wire format with nostrnests/EGG-07

After verifying the nostrnests reference (NestsUI-v2 @ main):
- ProfileCard.tsx writes kicks as ['action','kick'] tags with empty content
- useAdminCommands.ts reads action via tags.find(t => t==='action') and
  applies a 60-s relay since plus a processedRef Set to dedup re-deliveries
- p-tag role marker for moderators is 'admin', not 'moderator'

Amethyst was diverging on every one of those, which means our outbound
admin commands were invisible to nostrnests, theirs to us, and any
nostrnests admin (role='admin') failed our isModerator() / canSpeak()
gates entirely — kicks and force-mutes signed by them were silently
dropped.

Changes:

quartz/AdminCommandEvent.kt
  - Emit ['action', '<verb>'] tag with empty content
  - Reader prefers the tag, falls back to content for any in-flight
    Amethyst-built kick from before this commit
  - kick() and forceMute() share a common build() helper

quartz/ParticipantTag.kt
  - ROLE.MODERATOR.code = 'admin' (matches nostrnests + EGG-07)
  - Adds legacyCodes = ['moderator'] so older Amethyst-emitted
    kind-30312 events still parse as MODERATOR
  - effectiveRole() walks both code + legacyCodes

amethyst/AdminCommandsCollector
  - Filter carries since = now - 60 (EGG-07 #7)
  - Defensive per-event freshness re-check for cached events / clock skew
  - mutableSetOf<String>() processed-id dedup for the lifetime of the
    collector, mirroring useAdminCommands.ts's processedRef

EGG-07.md
  - Documents the Amethyst-only ['action','mute'] extension under a new
    'Implemented extensions' section. nostrnests doesn't emit or honour
    it today; cross-client force-mutes only work between Amethyst peers
  - 'warn' stays in the future-actions list — nostrnests has no plans
    for it either

Tests:
  - ParticipantTagTest: new asserts that 'admin' / 'Admin' / 'ADMIN'
    parse as ROLE.MODERATOR; pins the wire string to 'admin' and the
    legacy alias to 'moderator'
  - AdminCommandEventTest: kick/forceMute templates carry ['action', _]
    tag with empty content; legacy content-form still parses; tag wins
    over content when both are present
This commit is contained in:
Claude
2026-04-28 12:59:48 +00:00
parent d41a24f945
commit 138ee12a6a
6 changed files with 153 additions and 26 deletions
@@ -32,10 +32,16 @@ class AdminCommandEventTest {
private val roomATag = ATag(kind = 30312, pubKeyHex = host, dTag = "rt-1", relay = null)
@Test
fun kickTemplateCarriesActionContentAddressAndTarget() {
fun kickTemplateUsesActionTagWithEmptyContent() {
// Spec / nostrnests wire format: ["action", "kick"] tag,
// empty content. An older Amethyst layout put the verb in
// content; the reader still accepts that for compat but
// emission is tag-only.
val template = AdminCommandEvent.kick(roomATag, target, createdAt = 100L)
assertEquals(AdminCommandEvent.KIND, template.kind)
assertEquals(AdminCommandEvent.Action.KICK.code, template.content)
assertEquals("", template.content)
val actionTag = template.tags.firstOrNull { it.firstOrNull() == AdminCommandEvent.ACTION_TAG }
assertEquals(AdminCommandEvent.Action.KICK.code, actionTag?.getOrNull(1))
// Single `a` tag pointing at the room.
val aTag = template.tags.firstOrNull { it.firstOrNull() == "a" }
assertEquals(roomATag.toTag(), aTag?.getOrNull(1))
@@ -44,6 +50,14 @@ class AdminCommandEventTest {
assertEquals(target, pTag?.getOrNull(1))
}
@Test
fun forceMuteTemplateUsesActionTag() {
val template = AdminCommandEvent.forceMute(roomATag, target, createdAt = 100L)
assertEquals("", template.content)
val actionTag = template.tags.firstOrNull { it.firstOrNull() == AdminCommandEvent.ACTION_TAG }
assertEquals(AdminCommandEvent.Action.MUTE.code, actionTag?.getOrNull(1))
}
@Test
fun parseRoundTripExposesRoomTargetAction() {
val template = AdminCommandEvent.kick(roomATag, target, createdAt = 100L)
@@ -62,14 +76,48 @@ class AdminCommandEventTest {
}
@Test
fun unknownActionReturnsNull() {
fun legacyContentVerbStillParsedForBackwardsCompat() {
// An Amethyst build briefly emitted the verb in content. After
// we switched to the tag form (matching nostrnests / EGG-07)
// the reader still accepts the old layout so any in-flight
// event during the rollout window is honored.
val event =
AdminCommandEvent(
id = "0".repeat(64),
pubKey = host,
createdAt = 100L,
tags = arrayOf(arrayOf("a", "30312:host:rt"), arrayOf("p", target)),
content = "haunt",
content = "kick",
sig = "0".repeat(128),
)
assertEquals(AdminCommandEvent.Action.KICK, event.action())
}
@Test
fun actionTagWinsOverContentWhenBothPresent() {
// Defensive: if both forms are present (e.g. mixed-source
// event), the tag — the spec-correct location — is preferred.
val event =
AdminCommandEvent(
id = "0".repeat(64),
pubKey = host,
createdAt = 100L,
tags = arrayOf(arrayOf("a", "30312:host:rt"), arrayOf("p", target), arrayOf("action", "mute")),
content = "kick",
sig = "0".repeat(128),
)
assertEquals(AdminCommandEvent.Action.MUTE, event.action())
}
@Test
fun unknownActionReturnsNull() {
val event =
AdminCommandEvent(
id = "0".repeat(64),
pubKey = host,
createdAt = 100L,
tags = arrayOf(arrayOf("a", "30312:host:rt"), arrayOf("p", target), arrayOf("action", "haunt")),
content = "",
sig = "0".repeat(128),
)
assertNull(event.action())
@@ -70,4 +70,25 @@ class ParticipantTagTest {
assertEquals(ROLE.SPEAKER, tag("speaker").effectiveRole())
assertEquals(ROLE.PARTICIPANT, tag("participant").effectiveRole())
}
@Test
fun nostrnestsAdminRoleIsAcceptedAsModerator() {
// nostrnests' useAdminCommands.ts emits role="admin" on the
// p-tag; we must recognise it for promote/demote/kick interop.
assertEquals(ROLE.MODERATOR, tag("admin").effectiveRole())
assertTrue(tag("admin").isModerator())
assertTrue(tag("admin").canSpeak())
// Case-insensitive too, like everything else.
assertEquals(ROLE.MODERATOR, tag("Admin").effectiveRole())
assertEquals(ROLE.MODERATOR, tag("ADMIN").effectiveRole())
}
@Test
fun moderatorCodeEmitsAdminWireString() {
// After matching nostrnests / EGG-07 the wire string is "admin";
// any code that builds a kind-30312 p-tag from ROLE.MODERATOR.code
// must produce "admin", not the legacy "moderator".
assertEquals("admin", ROLE.MODERATOR.code)
assertEquals(listOf("moderator"), ROLE.MODERATOR.legacyCodes)
}
}