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:
+20
@@ -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<String>()
|
||||
LocalCache.observeNewEvents<AdminCommandEvent>(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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+34
-19
@@ -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", <verb>]` 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<AdminCommandEvent>(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<AdminCommandEvent>(KIND, Action.MUTE.code, createdAt) {
|
||||
) = build(room, target, Action.MUTE, createdAt)
|
||||
|
||||
private fun build(
|
||||
room: ATag,
|
||||
target: HexKey,
|
||||
action: Action,
|
||||
createdAt: Long,
|
||||
) = eventTemplate<AdminCommandEvent>(KIND, "", createdAt) {
|
||||
aTag(room)
|
||||
add(PTag(target).toTagArray())
|
||||
add(arrayOf(ACTION_TAG, action.code))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-3
@@ -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<String> = 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
|
||||
|
||||
|
||||
+52
-4
@@ -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())
|
||||
|
||||
+21
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user