diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt index fa0a11848..01d3c3543 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -167,13 +168,29 @@ private fun AdminCommandsCollector( localPubkey: String, ) { LaunchedEffect(viewModel, roomATag, localPubkey) { + // EGG-07 / nostrnests gate: ignore admin commands older than + // 60 s. The relay filter narrows the firehose; the per-cmd + // check below catches anything that slips through (cached + // events, system clock skew, etc.). + val sinceSec = TimeUtils.now() - ADMIN_COMMAND_FRESHNESS_SEC val filter = Filter( kinds = listOf(AdminCommandEvent.KIND), tags = mapOf("a" to listOf(roomATag), "p" to listOf(localPubkey)), + since = sinceSec, ) + // Replay protection per EGG-07 #7: a single kick / mute must + // act exactly once even when re-delivered from multiple + // relays. Lifetime-of-collector dedup mirrors how nostrnests' + // useAdminCommands.ts uses a `processedRef` set. + val processed = mutableSetOf() LocalCache.observeNewEvents(filter).collect { cmd -> if (cmd.targetPubkey() != localPubkey) return@collect + // Defensive freshness re-check: relay might have served a + // cached older event despite the `since` hint, or the + // user's clock might have jumped forward. + if (TimeUtils.now() - cmd.createdAt > ADMIN_COMMAND_FRESHNESS_SEC) return@collect + if (!processed.add(cmd.id)) return@collect val signerIsAuthorised = cmd.pubKey == event.pubKey || event.participants().any { it.pubKey == cmd.pubKey && (it.isHost() || it.isModerator()) } @@ -187,6 +204,9 @@ private fun AdminCommandsCollector( } } +/** Spec window from EGG-07 #7 — 60-second freshness gate on kind-4312. */ +private const val ADMIN_COMMAND_FRESHNESS_SEC: Long = 60L + private const val PRESENCE_EVICT_INTERVAL_MS = 60_000L private const val PRESENCE_STALE_THRESHOLD_SEC = 6L * 60L private const val REACTIONS_TICK_MS = 1_000L diff --git a/nestsClient/specs/EGG-07.md b/nestsClient/specs/EGG-07.md index 3d8c853a3..de20b8262 100644 --- a/nestsClient/specs/EGG-07.md +++ b/nestsClient/specs/EGG-07.md @@ -170,3 +170,13 @@ moderation without speaking to the relay. Future EGGs MAY introduce additional `action` values (e.g. `"mute"`, `"warn"`). Implementers MUST treat unknown actions as no-ops. + +### Implemented extensions + +Amethyst additionally emits and honours `["action", "mute"]` as a +host-issued *force-mute*: the targeted speaker's client flips its own +mic-mute (the `["muted", "1"]` flag on its next kind:10312 heartbeat) +and stops broadcasting audio. nostrnests' web client does not yet +emit or recognise this verb, so cross-client force-mutes only land +when both sides run a client that implements it. Authority gates, +freshness window, and replay rules are identical to `kick`. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nests/admin/AdminCommandEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nests/admin/AdminCommandEvent.kt index 89cc44192..99cb84710 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nests/admin/AdminCommandEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nests/admin/AdminCommandEvent.kt @@ -36,13 +36,15 @@ import com.vitorpamplona.quartz.utils.TimeUtils * * Recipients filter on `kinds=[4312], #a=[room], #p=[me]` and only * honour commands whose signer is a participant marked HOST or - * MODERATOR on the active kind-30312 — relays don't enforce this, + * "admin" on the active kind-30312 — relays don't enforce this, * the client must. * - * The tag layout matches the action / role-mutation events - * nostrnests emits today; the action keyword goes in `content` - * rather than as a separate tag so we can extend it with new - * verbs without re-versioning the spec. + * Wire format matches the nostrnests reference (`useAdminCommands.ts`) + * and EGG-07: the verb lives in an `["action", "kick"]` tag with + * empty `content`. An older Amethyst build briefly emitted the verb + * in `content` instead — the reader still accepts that format so + * any in-flight events deploy cleanly, but emission only writes the + * tag form. */ @Immutable class AdminCommandEvent( @@ -60,12 +62,16 @@ class AdminCommandEvent( fun targetPubkey(): HexKey? = tags.firstOrNull { it.firstOrNull() == "p" }?.getOrNull(1) /** - * The verb (e.g. "kick"). Returned uppercase so callers can - * `when (event.action()) { Action.KICK -> ... }` cleanly; the - * raw content is preserved on the event so a future verb can - * be inspected as a string. + * The verb (e.g. "kick"). Reads the spec-correct + * `["action", ]` tag first; falls back to `content` for + * compatibility with the legacy Amethyst layout. */ - fun action(): Action? = Action.fromCode(content) + fun action(): Action? { + val tagAction = tags.firstOrNull { it.firstOrNull() == ACTION_TAG }?.getOrNull(1) + if (tagAction != null) return Action.fromCode(tagAction) + if (content.isNotBlank()) return Action.fromCode(content) + return null + } enum class Action( val code: String, @@ -91,32 +97,41 @@ class AdminCommandEvent( companion object { const val KIND = 4312 const val ALT = "Audio room admin command" + const val ACTION_TAG = "action" /** * Build a kick command tagged for [room] and [target]. The - * content carries the verb so the same kind can grow new - * actions (mute, ban, …) without a wire schema bump. + * verb travels in the `["action", "kick"]` tag (matching + * nostrnests / EGG-07) with empty `content`, so future + * actions (mute, …) extend the same envelope without a + * wire schema bump. */ fun kick( room: ATag, target: HexKey, createdAt: Long = TimeUtils.now(), - ) = eventTemplate(KIND, Action.KICK.code, createdAt) { - aTag(room) - add(PTag(target).toTagArray()) - } + ) = build(room, target, Action.KICK, createdAt) /** - * Build a force-mute command. Same envelope as [kick] — - * tagged for [room] + [target], action verb in content. + * Build a force-mute command. Amethyst extension — nostrnests + * doesn't emit or honor this verb today, so it's effectively + * a no-op when reaching a nostrnests listener. */ fun forceMute( room: ATag, target: HexKey, createdAt: Long = TimeUtils.now(), - ) = eventTemplate(KIND, Action.MUTE.code, createdAt) { + ) = build(room, target, Action.MUTE, createdAt) + + private fun build( + room: ATag, + target: HexKey, + action: Action, + createdAt: Long, + ) = eventTemplate(KIND, "", createdAt) { aTag(room) add(PTag(target).toTagArray()) + add(arrayOf(ACTION_TAG, action.code)) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt index a89b79a77..f07147a03 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTag.kt @@ -33,11 +33,24 @@ import com.vitorpamplona.quartz.utils.ensure enum class ROLE( val code: String, + /** + * Aliases recognised on inbound parse but never emitted. Used to + * keep reading kind-30312 events written by older Amethyst builds + * (which used `"moderator"` before we matched the nostrnests / + * EGG-07 `"admin"` wire string). + */ + val legacyCodes: List = emptyList(), ) { HOST("host"), - MODERATOR("moderator"), + MODERATOR("admin", legacyCodes = listOf("moderator")), SPEAKER("speaker"), PARTICIPANT("participant"), + ; + + /** Whether [code] (case-insensitive) matches this role's primary or legacy spellings. */ + fun matches(code: String): Boolean = + this.code.equals(code, ignoreCase = true) || + legacyCodes.any { it.equals(code, ignoreCase = true) } } @Immutable @@ -47,8 +60,8 @@ data class ParticipantTag( val role: String?, val proof: String?, ) : PubKeyReferenceTag { - /** Match the role string against [ROLE], case-insensitive. */ - fun effectiveRole(): ROLE? = role?.let { r -> ROLE.entries.firstOrNull { it.code.equals(r, ignoreCase = true) } } + /** Match the role string against [ROLE], case-insensitive — primary code or legacy alias. */ + fun effectiveRole(): ROLE? = role?.let { r -> ROLE.entries.firstOrNull { it.matches(r) } } fun isHost(): Boolean = effectiveRole() == ROLE.HOST diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nests/admin/AdminCommandEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nests/admin/AdminCommandEventTest.kt index 2367bfc94..610a2cf3f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nests/admin/AdminCommandEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nests/admin/AdminCommandEventTest.kt @@ -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()) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTagTest.kt index ba366226c..14f6ea6cf 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTagTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/tags/ParticipantTagTest.kt @@ -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) + } }