diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index a5c9242612..6f6e099c51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -270,7 +270,7 @@ class AuthCoordinator( relayUrl: NormalizedRelayUrl, ): EventTemplate { if (!BuzzRelayDialect.isBuzz(relayUrl)) return template - val authTag = account.buzzAttestation.authTag() ?: return template + val authTag = account.buzzAttestation.authTag(template.createdAt) ?: return template return EventTemplate(template.createdAt, template.kind, template.tags + arrayOf(authTag), template.content) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 42db67fdba..105cbca892 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -61,7 +61,9 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.isBuzzEditableBy import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.channel.observeChannel @@ -283,13 +285,25 @@ fun ChatMessageActionSheet( ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss) // Editing my own chat message. Two surfaces publish an edit today, gated by type: - // - Buzz: kind-40002 stream message → a kind-40003 edit. + // - Buzz: kind-9 message in a Buzz-dialect relay group (or a legacy kind-40002 one) + // → a kind-40003 edit, by its author or the author's agent owner. // - Concord: kind-9 channel message or kind-1111 thread reply (carries a // ConcordChannel gatherer) → a kind-3302 edit wrapped on the channel plane. // Both restrict to my own messages; a note is only ever one of the two, so at // most one tile shows and both route through the same edit callback. val isMine = note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex - val canEditBuzz = onWantsToEditChatMessage != null && note.event is StreamMessageV2Event && isMine + // Buzz lets the owner of an agent edit the agent's messages too, and credits a + // relay-signed message to the member it names, so its own rule decides. + val canEditBuzz = + onWantsToEditChatMessage != null && + ( + note.event is StreamMessageV2Event || + ( + note.event is ChatEvent && + note.inGatherers?.any { it is RelayGroupChannel && BuzzRelayDialect.isBuzz(it.groupId.relayUrl) } == true + ) + ) && + note.isBuzzEditableBy(accountViewModel.userProfile().pubkeyHex) val canEditConcord = onWantsToEditChatMessage != null && (note.event is ChatEvent || note.event is CommentEvent) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index bb2cf0cb60..c34785a2c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -126,7 +126,7 @@ fun ChatroomMessageCompose( // reply quotes inside a DM, where the target is simply older than the loaded window (see // LoadingReplyNote). Null keeps the default blank for every other caller. onBlank: (@Composable () -> Unit)? = null, - // Edit my own chat message on surfaces that support it (Buzz kind-40002 → 40003, + // Edit my own chat message on surfaces that support it (Buzz kind-9 or legacy 40002 → 40003, // Concord kind-9 → 1010). Null for chat surfaces without message editing, which hides // the action. onWantsToEditChatMessage: ((Note) -> Unit)? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index 12efb2524c..0b619808bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -28,10 +30,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.buzz.ui.BuzzAgentLabel +import com.vitorpamplona.amethyst.commons.buzz.ui.rememberBuzzContextualName import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserDisplayNickname import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav @@ -44,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp import com.vitorpamplona.amethyst.commons.ui.theme.Size5Modifier import com.vitorpamplona.amethyst.commons.ui.theme.isLight import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.buzz.identityNames.IdentityNamePolicy @Composable fun DrawAuthorInfo( @@ -54,8 +62,15 @@ fun DrawAuthorInfo( // A geohash chat resolves the message's `n` nickname here (throwaway keys have no profile); // null everywhere else, so authors render from their profile as usual. val nameOverride = LocalChatDisplayNameResolver.current?.invoke(baseNote) + // In a Buzz channel two members may share a name; Buzz tells them apart there. + val buzzChannel = + remember(baseNote) { + baseNote.inGatherers + ?.firstNotNullOfOrNull { it as? RelayGroupChannel } + ?.takeIf { BuzzRelayDialect.isBuzz(it.groupId.relayUrl) } + } baseNote.author?.let { - WatchAndDisplayUser(it, nameOverride, accountViewModel, nav) + WatchAndDisplayUser(it, nameOverride, buzzChannel, accountViewModel, nav) } } @@ -80,14 +95,20 @@ fun authorNameColorFor( private fun WatchAndDisplayUser( author: User, nameOverride: String?, + buzzChannel: RelayGroupChannel?, accountViewModel: AccountViewModel, nav: INav, ) { val userState by observeUserInfo(author, accountViewModel) val nickname by observeUserDisplayNickname(author, accountViewModel) val petName = nickname?.petName - // A geohash message's `n` nickname wins over the (usually empty) profile of a throwaway key. - val displayName = nameOverride ?: petName ?: userState?.info?.bestName() + val profileName = userState?.info?.bestName() + // Buzz's contextual name ("Alice’s Honey", "Honey · 7xk2") when the channel has a namesake. + val contextual = buzzChannel?.let { rememberBuzzContextualName(it, author, accountViewModel) }?.name + val qualified = contextual != null && profileName != null && contextual != IdentityNamePolicy.trim(profileName) + // A geohash message's `n` nickname wins over the (usually empty) profile of a throwaway key, and a + // nickname the account set wins over everything else. + val displayName = nameOverride ?: petName ?: (if (qualified) contextual else profileName) val isLightTheme = MaterialTheme.colorScheme.isLight val nameColor = @@ -114,13 +135,22 @@ private fun WatchAndDisplayUser( ObserveAndRenderUserCards(author, Size20dp, Modifier.align(Alignment.BottomCenter), accountViewModel) }, name = { - CreateTextWithEmoji( - text = displayName ?: author.pubkeyDisplayHex(), - tags = (if (nameOverride == null && petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList, - color = nameColor, - maxLines = 1, - fontWeight = FontWeight.Bold, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + CreateTextWithEmoji( + text = displayName ?: author.pubkeyDisplayHex(), + tags = (if (nameOverride == null && petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList, + color = nameColor, + maxLines = 1, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f, fill = false), + ) + // An agent says so, and whose it is (its owner comes from the NIP-OA tag on its kind 0), + // unless its contextual name already does. + BuzzAgentLabel(author, userState?.nipOaOwner, accountViewModel, showOwner = !qualified) + } }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt index c8285ca31f..df4bfc6ab9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt @@ -73,6 +73,7 @@ import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent +import com.vitorpamplona.quartz.buzz.stream.BuzzEditTagOverlay import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent @@ -101,7 +102,15 @@ fun RenderBuzzEditedNote( RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav) return } - val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + // Render with the tags the edit leaves the message with (Buzz's applyEditTagOverlay): the edit's + // attachments and custom emoji, the original's channel/thread tags. + val tags = + remember(note.event, editNote.event) { + note.event + ?.tags + ?.let { BuzzEditTagOverlay.apply(it, editNote.event?.tags) } + ?.toImmutableListOfLists() ?: EmptyTagList + } Column { TranslatableRichTextViewer( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt index a5ded4051f..26c5cbba79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -41,6 +41,8 @@ import com.vitorpamplona.amethyst.commons.ui.uploads.uploadToDefaultServer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_STREAM +import com.vitorpamplona.quartz.buzz.workspace.canonicalBuzzChannelName +import com.vitorpamplona.quartz.buzz.workspace.isValidBuzzChannelName import com.vitorpamplona.quartz.buzz.workspace.newBuzzChannelId import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -127,7 +129,13 @@ class RelayGroupMetadataViewModel : ViewModel() { var touched by mutableStateOf(false) private set - val canPost by derivedStateOf { !isWorking && name.value.text.isNotBlank() } + /** + * A Buzz relay strips leading `#`s and whitespace from a channel name and refuses one that ends + * up empty, so on Buzz a name like `"# #"` can't be submitted at all. + */ + val canPost by derivedStateOf { + !isWorking && name.value.text.isNotBlank() && (!isBuzzRelay || isValidBuzzChannelName(name.value.text)) + } fun hasImage(): Boolean = pickedMedia != null || picture.value.text.isNotBlank() @@ -239,7 +247,9 @@ class RelayGroupMetadataViewModel : ViewModel() { } private suspend fun publish() { - val name = name.value.text.trim() + // Send Buzz the name it would store anyway (no leading `#`s), so our optimistic copy and + // the relay's 39000 agree. + val name = if (isBuzzRelay) canonicalBuzzChannelName(name.value.text) else name.value.text.trim() val about = about.value.text .trim() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index a70e5755f4..15d335c1f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -40,7 +40,9 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -53,6 +55,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHuddleLivenessState import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -69,6 +72,7 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_channel_archive import com.vitorpamplona.amethyst.commons.resources.buzz_channel_delete import com.vitorpamplona.amethyst.commons.resources.buzz_channel_delete_confirm import com.vitorpamplona.amethyst.commons.resources.buzz_channel_unarchive +import com.vitorpamplona.amethyst.commons.resources.buzz_huddle_live import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.join import com.vitorpamplona.amethyst.commons.resources.leave @@ -97,6 +101,10 @@ import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.delay +import kotlin.coroutines.cancellation.CancellationException @Composable fun RelayGroupTopBar( @@ -154,6 +162,7 @@ fun RelayGroupTopBar( var showJoinCode by remember { mutableStateOf(false) } var confirmDelete by remember { mutableStateOf(false) } val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) } + val huddleLive by observeBuzzHuddleLive(channel, enabled = isBuzzRelay && membership.isMember(), accountViewModel) TopBarExtensibleWithBackButton( title = { @@ -207,6 +216,20 @@ fun RelayGroupTopBar( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + if (huddleLive) { + Icon( + symbol = MaterialSymbols.Headphones, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(12.dp), + ) + Text( + text = stringRes(Res.string.buzz_huddle_live), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + ) + } } } }, @@ -556,6 +579,38 @@ private fun RoleBadge(membership: RelayGroupMembership) { } } +/** + * Whether a Buzz huddle is live in [channel] right now. While [enabled] (a Buzz relay we are a + * member of - the relay answers liveness only for an authorized `#h`), re-asks the relay every + * [HUDDLE_LIVENESS_REFRESH_MS] as Buzz's desktop does; a session that stops being reported ages out + * of [BuzzHuddleLivenessState] and the indicator drops. + */ +@Composable +private fun observeBuzzHuddleLive( + channel: RelayGroupChannel, + enabled: Boolean, + accountViewModel: AccountViewModel, +): State { + val seen by BuzzHuddleLivenessState.flow.collectAsStateWithLifecycle() + var now by remember { mutableLongStateOf(TimeUtils.now()) } + LaunchedEffect(channel.groupId, enabled) { + if (!enabled) return@LaunchedEffect + while (true) { + try { + accountViewModel.account.relayGroups.refreshBuzzHuddleLiveness(channel) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("RelayGroupTopBar", "Huddle liveness refresh failed", e) + } + now = TimeUtils.now() + delay(HUDDLE_LIVENESS_REFRESH_MS) + } + } + return remember(channel.groupId, enabled) { derivedStateOf { enabled && BuzzHuddleLivenessState.liveSessions(channel.groupId.id, now, seen).isNotEmpty() } } +} + +private const val HUDDLE_LIVENESS_REFRESH_MS = 10_000L + /** * Whether this Buzz channel has a canvas (kind 40100) in cache, recomposing when one lands. * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 68ae70fdf0..7acbf11897 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel +import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit import com.vitorpamplona.amethyst.commons.model.location.LocationResult import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel @@ -76,12 +77,11 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor +import com.vitorpamplona.quartz.buzz.stream.BuzzChatMessage +import com.vitorpamplona.quartz.buzz.stream.BuzzEditTagOverlay import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent -import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.stream.mentions -import com.vitorpamplona.quartz.buzz.threading.buzzThread -import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply -import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot +import com.vitorpamplona.quartz.buzz.threading.buzzThreadRootForReplyTo import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent @@ -114,6 +114,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.notify import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -177,11 +178,16 @@ open class ChannelNewMessageViewModel : // thread comment that opens as a minichat. Only meaningful while replyTo is set. val replyMode = mutableStateOf(ReplyMode.INLINE) - // When set, the composer is editing an existing Buzz stream message (kind 40002): + // When set, the composer is editing an existing Buzz message (kind 9, or a legacy 40002): // the next send publishes a kind-40003 edit targeting this note instead of a new // message. Only ever set for own messages on a Buzz-dialect relay (see editBuzzMessage). val editingBuzzMessage = mutableStateOf(null) + // The custom emoji the message being edited currently renders with. Buzz replaces a message's + // emoji set with the edit's whenever the edit carries any, so an edit re-sends the ones still + // used in the text alongside any newly picked ones. + private var editingBuzzEmojis: List = emptyList() + // Explicit @-mentions resolved by the last createTemplate, used by sendPostSync for the Buzz // auto-invite. Kept off the draft path on purpose (see createTemplate / sendPostSync). private var pendingBuzzInviteMentions: List = emptyList() @@ -295,21 +301,30 @@ open class ChannelNewMessageViewModel : } /** - * Enters Buzz edit mode: pre-fills the composer with [note]'s current text and marks - * the next send as a kind-40003 edit of it. Editing and replying are mutually - * exclusive, so any pending reply is cleared. The caller gates this to the user's own - * kind-40002 messages on a Buzz relay. + * Enters Buzz edit mode: pre-fills the composer with [note]'s current text (its newest edit, + * if it has one) and marks the next send as a kind-40003 edit of it. Editing and replying are + * mutually exclusive, so any pending reply is cleared. The caller gates this to the user's + * own kind-9 (or legacy 40002) messages on a Buzz relay. */ fun editBuzzMessage(note: Note) { replyTo.value = null replyMode.value = ReplyMode.INLINE editingBuzzMessage.value = note - message.setTextAndPlaceCursorAtEnd(note.event?.content ?: "") + val latestEdit = note.latestBuzzEdit()?.event + message.setTextAndPlaceCursorAtEnd(latestEdit?.content ?: note.event?.content ?: "") + // Start from what the message shows now: an edit fully replaces its attachments on Buzz, + // so the ones still referenced in the text must go out again with the edit. + val currentTags = BuzzEditTagOverlay.apply(note.event?.tags ?: emptyArray(), latestEdit?.tags) + iMetaAttachments.reset() + iMetaAttachments.addAll(currentTags.imetas()) + editingBuzzEmojis = currentTags.emojis() draftTag.newVersion() } fun clearBuzzEdit() { editingBuzzMessage.value = null + editingBuzzEmojis = emptyList() + iMetaAttachments.reset() message.setTextAndPlaceCursorAtEnd("") draftTag.newVersion() } @@ -592,7 +607,7 @@ open class ChannelNewMessageViewModel : val pk = user.pubkeyHex if (pk != me && channel.membershipOf(pk) == RelayGroupMembership.NONE) { try { - accountViewModel.account.relayGroups.putRelayGroupUser(channel, pk, emptyList()) + accountViewModel.account.relayGroups.addRelayGroupUser(channel, pk) } catch (e: Exception) { if (e is CancellationException) throw e Log.w("BuzzAutoInvite", "Failed to add mentioned member ${pk.take(8)}", e) @@ -640,7 +655,7 @@ open class ChannelNewMessageViewModel : // Buzz relays reject unknown kinds outright, and kind 1111 is not in Buzz's // registry — a minichat comment sent to a workspace channel would be refused // by the relay AFTER the composer already cleared. Buzz replies always go - // through the 40002 branch with its thread markers instead. + // through the Buzz kind-9 branch with its thread markers instead. val minichatAllowed = !(channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) val minichatParent = replyTo.value?.takeIf { minichatAllowed && replyMode.value == ReplyMode.MINICHAT }?.event if (minichatParent != null) { @@ -804,55 +819,59 @@ open class ChannelNewMessageViewModel : channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) && editingBuzzMessage.value != null -> { - // Buzz edit (kind 40003): replaces the text of an existing kind-40002 message. + // Buzz edit (kind 40003): replaces the text of an existing Buzz message. // build() sets the group's `h` tag plus the `e` tag pointing at the edited // message; content is the replacement text. Kept minimal to mirror Buzz's own // `build_edit` (buzz-sdk builders.rs) — the relay validates edits and the author // match. LocalCache overlays the newest edit last-write-wins, so the edited row // re-renders with this content. + // Buzz's clients render an edit with the original's tags overlaid by the edit's + // (BuzzEditTagOverlay): attachments come ONLY from the edit, and custom emoji from + // the edit whenever it has any. So the edit carries every attachment and emoji the + // new text still uses, like Buzz's own `build_message_edit`, or they would vanish. val target = editingBuzzMessage.value!! + val editEmojis = + (emojis + editingBuzzEmojis.filter { tagger.message.contains(":${it.code}:") }) + .distinctBy { it.code } StreamMessageEditEvent.build(channel.groupId.id, target.idHex, tagger.message) { // Carry `p` mentions for anyone cited in the edited text, so a mention added // (or kept) by an edit still notifies the member and resolves its `nostr:` ref. mentions(tagger.pTags?.map { it.pubkeyHex }.orEmpty()) + imetas(usedAttachments) + emojis(editEmojis) } } channel is RelayGroupChannel && BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) -> { - // Buzz workspace message: the native kind is 40002 (stream message v2) - // scoped with the group's `h` tag; Buzz threads replies with NIP-10 - // marked e-tags (["e", root, "", "root"] + ["e", parent, "", "reply"], - // collapsing to a single "reply" when the parent IS the root — mirrors - // thread_tags in buzz-sdk builders.rs) plus a `p` notify to the parent - // author. The dialect check comes from BuzzRelayDialect (marked off - // verified Buzz events), not a channel subtype: channel instances are - // captured by screens for their whole life, so the dialect must be - // able to flip mid-session without swapping objects. + // Buzz workspace message: a kind-9 chat in Buzz's tag shape (`build_message` in + // buzz-sdk builders.rs), which is what Buzz's own desktop, mobile and CLI clients + // write. Kind 40002 is only read now, for older messages. Replies thread with NIP-10 + // marked e-tags (["e", root, "", "root"] + ["e", parent, "", "reply"], collapsing to + // a single "reply" when the parent IS the root) plus a `p` notify to the parent + // author. The dialect check comes from BuzzRelayDialect (marked off verified Buzz + // events), not a channel subtype: channel instances are captured by screens for + // their whole life, so the dialect must be able to flip mid-session without + // swapping objects. // Reply routing (Buzz has no kind-1111): an INLINE reply is flagged `broadcast` so it stays // a flat timeline sibling; a MINICHAT reply omits it so the thread markers pull it into the // message's minichat (mirrors block/buzz's broadcast-vs-thread split). A non-reply is neither. - val broadcastReply = replyTo.value != null && replyMode.value == ReplyMode.INLINE - StreamMessageV2Event.build(channel.groupId.id, tagger.message, broadcast = broadcastReply) { - replyTo.value?.let { parent -> - // The parent's root marker (when it is itself a nested reply), - // else the parent's OWN reply target (a direct reply's collapsed - // form carries the root as its "reply" marker — Buzz's relay - // validates ancestry and rejects a mis-derived root), else the - // parent starts the thread. - val parentTags = parent.event?.tags - val root = - parentTags?.buzzThreadRoot() - ?: parentTags?.buzzThreadReply() - ?: parent.idHex - buzzThread(root, parent.idHex) - } - + val parent = replyTo.value + // The parent's resolved root when it is itself a reply (a direct reply's collapsed form + // carries the root as its "reply" marker), else the parent starts the thread. A parent + // with only a `root` marker counts as top-level: Buzz's relay validates ancestry that + // way and rejects a mis-derived root. + val threadRoot = parent?.let { it.event?.tags?.buzzThreadRootForReplyTo(it.idHex) ?: it.idHex } + BuzzChatMessage.build( + channelId = channel.groupId.id, + content = tagger.message, + threadRoot = threadRoot, + replyTo = parent?.idHex, // `p` mentions for everyone cited in the body (plus the reply target, which the // tagger seeds into pTags) so a named member is notified and the Buzz relay can - // resolve the `nostr:` reference — mirrors the mention p-tags every other chat - // kind above emits. Deduplicated by mentions(), so the reply author isn't doubled. - mentions(tagger.pTags?.map { it.pubkeyHex }.orEmpty()) - + // resolve the `nostr:` reference. Deduplicated, so the reply author isn't doubled. + mentions = tagger.pTags?.map { it.pubkeyHex }.orEmpty(), + broadcast = parent != null && replyMode.value == ReplyMode.INLINE, + ) { hashtags(findHashtags(tagger.message)) references(findURLs(tagger.message)) quotes(findNostrUris(tagger.message)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index bcd53937ec..872e6aabab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.buzz.ui.BuzzAgentLabel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.User @@ -152,6 +153,15 @@ fun DrawAdditionalInfo( DrawPlayName(displayName) } + + // A Buzz agent says so, and whose it is: the owner from the NIP-OA tag on its kind 0, + // which opens the owner's profile. + BuzzAgentLabel( + author = baseUser, + owner = user.nipOaOwner, + accountViewModel = accountViewModel, + onOwnerClick = { nav.nav(Route.Profile(it)) }, + ) } if (displayName != user.info.name && !user.info.name.isNullOrBlank()) { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt index 6aa5e58758..aad26aa3b3 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt @@ -23,16 +23,22 @@ package com.vitorpamplona.amethyst.model import android.os.Looper import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates +import com.vitorpamplona.amethyst.commons.model.buzzEffectiveAuthor import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.isBuzzEditableBy import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation +import com.vitorpamplona.quartz.buzz.stream.BuzzChatMessage import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -214,6 +220,84 @@ class BuzzWorkspaceChannelTest { assertEquals("the fix", target.latestBuzzEdit()?.event?.content) } + @Test + fun anEditOfAKind9MessageOverlaysIt() = + runBlocking { + val channelId = newChannelId() + // Buzz channel messages are kind 9 now; a 40003 targets them the same way. + LocalCache.checkDeletionAndConsume(streamMessage(channelId, "mark the dialect"), buzzRelay, false) + val original = signer.sign(BuzzChatMessage.build(channelId, "teh fix")) + LocalCache.checkDeletionAndConsume(original, buzzRelay, false) + val edit = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "the fix", createdAt = original.createdAt + 1)) + LocalCache.checkDeletionAndConsume(edit, buzzRelay, false) + + assertEquals( + "the fix", + LocalCache + .getNoteIfExists(original.id)!! + .latestBuzzEdit() + ?.event + ?.content, + ) + } + + @Test + fun theVerifiedOwnerOfAnAgentMayEditItsMessages() = + runBlocking { + val channelId = newChannelId() + val agentKeys = KeyPair() + val ownerKeys = KeyPair() + val agent = NostrSignerInternal(agentKeys) + val owner = NostrSignerInternal(ownerKeys) + + val original = agent.sign(StreamMessageV2Event.build(channelId, "agent output")) + LocalCache.checkDeletionAndConsume(original, buzzRelay, false) + val ownerEdit = owner.sign(StreamMessageEditEvent.build(channelId, original.id, "corrected by owner", createdAt = original.createdAt + 10)) + LocalCache.checkDeletionAndConsume(ownerEdit, buzzRelay, false) + val target = LocalCache.getNoteIfExists(original.id)!! + + // Until the agent's profile names the owner, the owner is just another pubkey. + assertNull(target.latestBuzzEdit()) + assertFalse(target.isBuzzEditableBy(owner.pubKey)) + + // The agent's kind 0 carries the owner's NIP-OA attestation for the agent key. + val attestation = OwnerAttestation.sign(agent.pubKey, "", ownerKeys.privKey!!) + val profile = agent.sign(MetadataEvent.newUser("agent", createdAt = original.createdAt) { add(attestation.toTag()) }) + LocalCache.checkDeletionAndConsume(profile, buzzRelay, false) + + assertTrue(target.isBuzzEditableBy(owner.pubKey)) + assertEquals("corrected by owner", target.latestBuzzEdit()?.event?.content) + } + + @Test + fun aRelaySignedMessageIsEditableByTheMemberItNames() = + runBlocking { + val channelId = newChannelId() + val relayKey = NostrSignerInternal(KeyPair()) + val member = NostrSignerInternal(KeyPair()) + val stranger = NostrSignerInternal(KeyPair()) + + // The relay-signed 39000 is what tells us the relay's key. + LocalCache.checkDeletionAndConsume(relayKey.sign(GroupMetadataEvent.build(channelId, name = "ops")), buzzRelay, false) + LocalCache.checkDeletionAndConsume(streamMessage(channelId, "mark the dialect"), buzzRelay, false) + + // A workflow posted on the member's behalf: relay-signed, attributed through `actor`. + val posted = relayKey.sign(BuzzChatMessage.build(channelId, "deploy done") { add(arrayOf("actor", member.pubKey)) }) + LocalCache.checkDeletionAndConsume(posted, buzzRelay, false) + val target = LocalCache.getNoteIfExists(posted.id)!! + assertEquals(member.pubKey, target.buzzEffectiveAuthor()) + + LocalCache.checkDeletionAndConsume(stranger.sign(StreamMessageEditEvent.build(channelId, posted.id, "nope", createdAt = posted.createdAt + 1)), buzzRelay, false) + assertNull(target.latestBuzzEdit()) + LocalCache.checkDeletionAndConsume(member.sign(StreamMessageEditEvent.build(channelId, posted.id, "deploy done (v2)", createdAt = posted.createdAt + 2)), buzzRelay, false) + assertEquals("deploy done (v2)", target.latestBuzzEdit()?.event?.content) + + // A user-signed message can't hand its edits to someone else with an `actor` tag. + val claimed = stranger.sign(BuzzChatMessage.build(channelId, "mine") { add(arrayOf("actor", member.pubKey)) }) + LocalCache.checkDeletionAndConsume(claimed, buzzRelay, false) + assertEquals(stranger.pubKey, LocalCache.getNoteIfExists(claimed.id)!!.buzzEffectiveAuthor()) + } + @Test fun deletingAnEditUnlinksItFromTheMessage() = runBlocking { diff --git a/cli/README.md b/cli/README.md index c08bea8c4d..7ec8111ffe 100644 --- a/cli/README.md +++ b/cli/README.md @@ -597,7 +597,7 @@ screen speaks. | `amy relaygroup message RELAY GID TEXT` | Post a kind:9 chat message into the group. | | `amy relaygroup edit RELAY GID [--name X] [--about A] [--picture URL] [--banner URL] [--parent GID\|--root] [--private\|--public] [--closed\|--open]` | Edit metadata (9002, admin only). Reads the current 39000 and changes only what you pass: picture, banner, subgroup links, other flags and unknown tags are carried over. | | `amy relaygroup invite RELAY GID --code CODE` | Mint an invite code (9009, moderator). | -| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). | +| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator] [--buzz-role owner\|admin\|member\|guest\|bot]` | Add or promote a user (9000, moderator). On Buzz, only `--buzz-role` sets a role; without it an existing member keeps theirs. | | `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). | | `amy relaygroup pin RELAY GID REF` / `unpin …` | Add/remove a pin (9010, moderator). REF is a note1/nevent1/hex id (`e`) or naddr1/`kind:pubkey:d` (`a`); the rest of the current 39005 list (signed by the relay's NIP-11 `self`) is kept; if that list cannot be read the command aborts (`timeout` → 124, `fetch_failed`/`no_relay_key` → 1) rather than overwrite it. | @@ -605,13 +605,13 @@ screen speaks. [`block/buzz`](https://github.com/block/buzz) workspaces are NIP-29 groups on a Buzz relay, so create/join/leave still use `amy relaygroup`. These verbs cover the Buzz-native -surface: the kind:40002 stream message, the owner-attestation primitive (NIP-OA), and the +surface: the Buzz channel message (kind:9), the owner-attestation primitive (NIP-OA), and the agent console (turn-metric aggregation + personas), all driving the same `quartz` models and `commons` aggregator the app uses. | Command | What it does | | --- | --- | -| `amy buzz post RELAY GID ` | Post a kind:40002 stream message (Buzz-native) into a workspace. | +| `amy buzz post RELAY GID ` | Post a channel message into a workspace: kind:9 in Buzz's tag shape, what Buzz's own clients write. | | `amy buzz read RELAY GID [--limit N] [--timeout SECS]` | Read the recent human-visible timeline (kinds 9 / 40002 / 40099). | | `amy buzz attest AGENT [--kind K] [--after UNIX] [--before UNIX]` | Sign a NIP-OA attestation authorizing AGENT (offline; needs a local key). Prints the `auth` tag to hand to the agent operator. | | `amy buzz console [--relays R,R] [--timeout SECS]` | Fetch my kind:44200 turn metrics (`#p`=me), decrypt, and aggregate fleet + per-agent cost/tokens. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 87caabad5f..ac5ab57c5c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -830,7 +830,7 @@ private fun printUsage() { | relaygroup remove-user RELAY GID PUBKEY kick a user (kind 9001) | |Buzz (block/buzz agent workspaces — NIP-29 dialect): - | buzz post RELAY GID post a kind-40002 stream message + | buzz post RELAY GID post a channel message (kind 9) | buzz read RELAY GID [--limit N] read recent workspace messages | buzz attest AGENT [--kind K] issue a NIP-OA attestation (offline) | buzz console [--relays R,R] aggregate my kind-44200 agent turn metrics diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt index f296b76fba..99afd73d7e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt @@ -30,10 +30,12 @@ import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent import com.vitorpamplona.quartz.buzz.dm.DmHideEvent import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent +import com.vitorpamplona.quartz.buzz.invite.BuzzInviteClaim import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation +import com.vitorpamplona.quartz.buzz.stream.BuzzChatMessage import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -63,7 +65,7 @@ import okhttp3.coroutines.executeAsync * `amy buzz …` — first-class access to the `block/buzz` workspace protocol, driving the * same `quartz` models + `commons` aggregator the app uses. Buzz workspaces are NIP-29 * groups, so join/leave/create still go through `amy relaygroup`; this verb group covers - * the Buzz-native pieces: stream messages (40002), the owner-attestation primitive (OA), + * the Buzz-native pieces: channel messages (kind 9 in Buzz's shape), the owner-attestation primitive (OA), * and the agent console (turn-metric aggregation + personas). */ object BuzzCommands { @@ -345,12 +347,24 @@ object BuzzCommands { }.toString() val authEvent = ctx.signer.sign(HTTPAuthorizationEvent.build(claimUrl, "POST", claimReq.encodeToByteArray())) val (claimCode, claimBody) = httpPost(http, claimUrl, claimReq, authEvent.toAuthToken()) - if (claimCode != 200) return Output.error("claim_failed", "invite claim failed ($claimCode): $claimBody") + if (claimCode != 200) { + return when (BuzzInviteClaim.errorOf(claimBody)) { + BuzzInviteClaim.ERROR_EXHAUSTED -> Output.error("exhausted", "this invite has no uses left; ask for a new one") + BuzzInviteClaim.ERROR_EXPIRED -> Output.error("expired", "this invite has expired") + BuzzInviteClaim.ERROR_INVALID -> Output.error("invalid", "this invite is not valid for this workspace (revoked, mistyped, or never minted here)") + BuzzInviteClaim.ERROR_JOIN_POLICY_REQUIRED -> + Output.error("policy_required", "this workspace requires accepting its terms + age attestation; re-run with --accept-policy to consent") + else -> Output.error("claim_failed", "invite claim failed ($claimCode): $claimBody") + } + } val result = jsonParser.parseToJsonElement(claimBody).jsonObject + val status = result["status"]?.jsonPrimitive?.content Output.emit( mapOf( - "status" to result["status"]?.jsonPrimitive?.content, + "status" to status, + // Distinguish a no-op claim (already a member; no invite use consumed) from a join. + "already_member" to (status == BuzzInviteClaim.STATUS_ALREADY_MEMBER), "community_id" to (result["community_id"]?.jsonPrimitive?.content ?: invite.communityId), "role" to (result["role"]?.jsonPrimitive?.content ?: invite.role), "host" to invite.host, @@ -389,7 +403,7 @@ object BuzzCommands { http.newCall(builder.build()).executeAsync().use { it.code to it.body.string() } } - /** `buzz post RELAY GID ` → publishes a kind-40002 stream message with an `h` tag. */ + /** `buzz post RELAY GID ` → publishes a kind-9 channel message in Buzz's shape (`build_message`). */ private suspend fun post( dataDir: DataDir, rest: Array, @@ -398,7 +412,7 @@ object BuzzCommands { val text = Args(rest).positionalOrNull(2) ?: return Output.error("bad_args", usage) if (text.isBlank()) return Output.error("bad_args", "message text must not be blank") return publishScoped(dataDir, rest, usage) { _, groupId, _ -> - StreamMessageV2Event.build(groupId, text) + BuzzChatMessage.build(groupId, text) } } @@ -524,6 +538,9 @@ object BuzzCommands { "total_tokens" to metrics.totals.totalTokens, "input_tokens" to metrics.totals.inputTokens, "output_tokens" to metrics.totals.outputTokens, + // null = never reported (NIP-AM: an omitted cache component is unknown, not zero) + "cache_read_tokens" to metrics.totals.cacheReadTokens, + "cache_write_tokens" to metrics.totals.cacheWriteTokens, "turns" to metrics.totalTurns, "sessions" to metrics.totalSessions, "agents" to metrics.agents.size, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt index 569120d012..1397150e40 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt @@ -24,6 +24,7 @@ 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.buzz.workspace.BUZZ_ROLES import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.FetchAllResult import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -258,13 +259,19 @@ object RelayGroupModerationCommands { } } - /** `relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator]` → 9000. */ + /** + * `relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator] [--buzz-role ROLE]` → 9000. + * + * Buzz ignores the roles in the `p` tag and reads only a top-level `role` tag + * (owner|admin|member|guest|bot); `--buzz-role` sets it. Without it a Buzz relay makes no + * role change: an existing member keeps their role and a newcomer joins as `member`. + */ suspend fun putUser( dataDir: DataDir, rest: Array, ): Int { val args = Args(rest) - val usage = "relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator]" + val usage = "relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator] [--buzz-role owner|admin|member|guest|bot]" val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage) val user = args.positionalOrNull(2) ?: return Output.error("bad_args", usage) @@ -275,12 +282,14 @@ object RelayGroupModerationCommands { ?.split(',') ?.map { it.trim() } ?.filter { it.isNotEmpty() } ?: emptyList() + val buzzRole = args.flag("buzz-role")?.trim()?.lowercase() + if (buzzRole != null && buzzRole !in BUZZ_ROLES) return Output.error("bad_args", usage) args.rejectUnknown() Context.open(dataDir).use { ctx -> ctx.prepare() val pubkey = ctx.requireUserHex(user) - val signed = ctx.signer.sign(GroupPutUserEvent.build(groupId, listOf(pubkey to roles))) + val signed = ctx.signer.sign(GroupPutUserEvent.build(groupId, listOf(pubkey to roles), buzzRole = buzzRole)) val ack = ctx.publish(signed, setOf(relay)) RawEventSupport.publishGuard(ack, signed.id)?.let { return it } Output.emit( @@ -290,6 +299,7 @@ object RelayGroupModerationCommands { "relay" to relay.url, "pubkey" to pubkey, "roles" to roles, + "buzz_role" to buzzRole, "published" to ack.values.any { it.accepted }, ), ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt index 97c8f612e6..175484b50b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt @@ -186,8 +186,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.FileHeader import com.vitorpamplona.amethyst.commons.util.logTime import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode import com.vitorpamplona.quartz.buzz.threading.buzzThread -import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply -import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot +import com.vitorpamplona.quartz.buzz.threading.buzzThreadRootForReplyTo import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent @@ -2219,7 +2218,7 @@ class Account( // [com.vitorpamplona.amethyst.commons.model.chats.isMinichatReply]). // // Attached media rides as URLs appended to the content. - val root = rootEvent.tags.buzzThreadRoot() ?: rootEvent.tags.buzzThreadReply() ?: rootEvent.id + val root = rootEvent.tags.buzzThreadRootForReplyTo(rootEvent.id) signer.sign( ChatEvent.build(finalText) { hTag(group.groupId.id) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt index 120dc22ff8..b9f51ea649 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountRelayGroupActions.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.model import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHuddleLivenessState import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -30,6 +31,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembe import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent import com.vitorpamplona.quartz.buzz.dm.DmHideEvent import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent +import com.vitorpamplona.quartz.buzz.huddles.HuddleLivenessEvent import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent @@ -41,13 +43,17 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent import com.vitorpamplona.quartz.buzz.workflow.workflowChannel import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_BOT +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_GUEST import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_OWNER import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -75,9 +81,11 @@ import com.vitorpamplona.quartz.nip29RelayGroups.tags.EventPin import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent -import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * NIP-29 relay-group and Buzz-workspace orchestration for an [Account]: @@ -158,6 +166,21 @@ class AccountRelayGroupActions( ?.substringBefore('"') ?.takeIf { it.isNotBlank() } + /** + * Asks [channel]'s Buzz relay which huddle sessions in it are live right now and records them + * in [BuzzHuddleLivenessState]. The relay synthesizes kind-48104 answers only for a REQ whose + * every filter is exactly `kinds:[48104]` with an authorized `#h`, so this is its own REQ, never + * part of the channel's timeline subscription. Buzz's desktop repeats it every 10 seconds. + */ + suspend fun refreshBuzzHuddleLiveness(channel: RelayGroupChannel) { + val filter = HuddleLivenessEvent.filter(listOf(channel.groupId.id)) + val now = TimeUtils.now() + account.client.fetchAll(channel.groupId.relayUrl, filter, idleTimeoutMs = 8_000).forEach { event -> + if (event !is HuddleLivenessEvent || event.channelId() != channel.groupId.id) return@forEach + event.sessionId()?.let { BuzzHuddleLivenessState.record(channel.groupId.id, it, now) } + } + } + /** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */ suspend fun hideBuzzDm(channel: RelayGroupChannel) { val template = DmHideEvent.build(channel.groupId.id) @@ -231,6 +254,7 @@ class AccountRelayGroupActions( * the YAML and runs it; self-hosted on geode the definition is a named catalog entry the picker * offers and `amy` triggers by id. Returns the new workflow id, or null when the account can't write. */ + @OptIn(ExperimentalUuidApi::class) suspend fun publishBuzzWorkflowDef( relay: NormalizedRelayUrl, channelId: String, @@ -238,7 +262,8 @@ class AccountRelayGroupActions( yaml: String, ): String? { if (!account.isWriteable()) return null - val workflowId = RandomInstance.randomChars(16) + // The relay parses the `d` tag as a UUID and refuses anything else. + val workflowId = Uuid.random().toString() val signed = account.signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null })) account.cache.justConsumeMyOwnEvent(signed) account.client.publish(signed, setOf(relay)) @@ -493,8 +518,28 @@ class AccountRelayGroupActions( } /** - * Add [pubkey] to the group (or change its roles) with a kind 9000 put-user - * event (moderator only). Pass an empty [roles] list for a plain member. + * Add [pubkey] to the group as a plain member with a kind 9000 put-user event, without + * touching the role of someone who is already in it. + * + * On Buzz this sends **no** `role` tag: the relay reads a missing role as "no role change", + * keeping an existing member's role and defaulting only a newcomer to `member`. Sending + * `role=member` instead would demote an admin who happened to be re-added, or be refused + * outright (`only owners/admins may change an active member's role`) when the actor is not + * elevated. Ground truth: `handle_put_user` in `buzz-relay/src/handlers/side_effects.rs` and + * `decide_put_user` in `channel_authz.rs`. + */ + suspend fun addRelayGroupUser( + channel: RelayGroupChannel, + pubkey: HexKey, + ) { + val template = GroupPutUserEvent.build(channel.groupId.id, listOf(pubkey to emptyList())) + account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + + /** + * Set [pubkey]'s roles in the group with a kind 9000 put-user event (moderator only). An + * empty [roles] list makes them a plain member - on Buzz that is an explicit demotion to + * `member`. To add someone without changing a role, use [addRelayGroupUser]. */ suspend fun putRelayGroupUser( channel: RelayGroupChannel, @@ -506,10 +551,7 @@ class AccountRelayGroupActions( // the whole put-user, which is why an unmapped role must become `member` rather than travel. val buzzRole = if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) { - when { - roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> BUZZ_ROLE_ADMIN - else -> BUZZ_ROLE_MEMBER - } + buzzRoleFor(roles) } else { null } @@ -606,3 +648,18 @@ class AccountRelayGroupActions( account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } } + +/** + * Maps NIP-29 role names onto Buzz's closed role set (owner/admin/member/guest/bot), picking the + * most privileged one present. Anything else, including NIP-29's `moderator`, becomes `member`. + */ +internal fun buzzRoleFor(roles: List): String { + fun has(role: String) = roles.any { it.equals(role, true) } + return when { + has(BUZZ_ROLE_OWNER) -> BUZZ_ROLE_OWNER + has(RelayGroupMembership.ROLE_ADMIN) || has(BUZZ_ROLE_ADMIN) -> BUZZ_ROLE_ADMIN + has(BUZZ_ROLE_BOT) -> BUZZ_ROLE_BOT + has(BUZZ_ROLE_GUEST) -> BUZZ_ROLE_GUEST + else -> BUZZ_ROLE_MEMBER + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/NoteEditOverlays.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/NoteEditOverlays.kt index b3ebcb6b5e..644f9e5f9f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/NoteEditOverlays.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/NoteEditOverlays.kt @@ -20,11 +20,15 @@ */ package com.vitorpamplona.amethyst.commons.model +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore +import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils /* @@ -33,9 +37,11 @@ import com.vitorpamplona.quartz.utils.TimeUtils * in-memory folds — no cache scan, no LocalCache state involved, which is why they live on the * note rather than the cache. * - * All four kinds apply ONLY edits authored by the edited note's own author: the send side gates - * editing to your own messages, and neither the relay (Buzz) nor an encrypted-plane peer (Concord, - * Marmot) is trusted to enforce that, so a foreign-authored edit never rewrites your message. + * Every kind applies ONLY edits the message's author was entitled to make, checked here rather + * than trusted to the relay (Buzz) or an encrypted-plane peer (Concord, Marmot), so a + * foreign-authored edit never rewrites your message. For Concord, Marmot and kind-1010 that is the + * note's own author. Buzz also lets the owner of an agent edit the agent's messages, and credits a + * relay-signed message to the member it names ([buzzEffectiveAuthor], [buzzEditorsOf]). */ /** @@ -52,11 +58,65 @@ fun Note.textNoteModifications(): List { }.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) } -/** The kind-40003 Buzz edit overlaying this message, or null — author-only, newest by created_at. */ -fun Note.latestBuzzEdit(): Note? { - val authorHex = author?.pubkeyHex ?: return null - return edits - .filter { it.event is StreamMessageEditEvent && it.author?.pubkeyHex == authorHex } +/** + * Who a Buzz message counts as written by. Normally its signer; but when the channel's relay signed + * it on a member's behalf (a workflow posting, a legacy relay-attributed message) the member named in + * its `actor` tag, else its first `p` tag. The relay's key is the author of the channel's + * relay-signed 39000 metadata, so a user-signed message can never claim someone else this way. + * Mirrors `effective_message_author` in Buzz's `buzz-relay/src/handlers/ingest.rs` and + * `resolveEventAuthorPubkey` in its desktop client (which also wants the `h` tag before reading `p`). + */ +fun Note.buzzEffectiveAuthor(): HexKey? { + val event = event ?: return author?.pubkeyHex + val relayKey = inGatherers?.firstNotNullOfOrNull { (it as? RelayGroupChannel)?.event?.pubKey } + if (relayKey != null && event.pubKey == relayKey) { + event.tags.firstOrNull { it.size > 1 && it[0] == "actor" && Hex.isHex64(it[1]) }?.let { return it[1] } + if (event.tags.any { it.size > 1 && it[0] == "h" }) { + event.tags.firstOrNull { it.size > 1 && it[0] == "p" && Hex.isHex64(it[1]) }?.let { return it[1] } + } + } + return event.pubKey +} + +/** + * Whether [signer] may edit this Buzz message: its [buzzEffectiveAuthor], or the owner that author + * declares through a verified NIP-OA `auth` tag on its kind-0 profile (Buzz lets the owning human + * edit its agent's messages). Mirrors `validate_edit_ownership` on the relay and + * `isAuthorizedMessageEdit` in Buzz's desktop client. + */ +fun Note.isBuzzEditableBy( + signer: HexKey, + users: (HexKey) -> User? = LocalCache::getUserIfExists, +): Boolean { + val author = buzzEffectiveAuthor() ?: return false + return signer == author || signer == buzzOwnerOf(author, users) +} + +private fun buzzOwnerOf( + author: HexKey, + users: (HexKey) -> User?, +): HexKey? = + users(author) + ?.metadataOrNull() + ?.flow + ?.value + ?.nipOaOwner + +/** + * The kind-40003 Buzz edit overlaying this message, or null: the newest ([createdAt], then id) of + * the edits its author or the author's agent owner made ([isBuzzEditableBy]). + */ +fun Note.latestBuzzEdit(users: (HexKey) -> User? = LocalCache::getUserIfExists): Note? { + val candidates = edits.filter { it.event is StreamMessageEditEvent } + if (candidates.isEmpty()) return null + val author = buzzEffectiveAuthor() ?: return null + // Resolve the owner only when some edit is not the author's own; it costs a signature check. + val owner by lazy { buzzOwnerOf(author, users) } + return candidates + .filter { + val signer = it.author?.pubkeyHex + signer != null && (signer == author || signer == owner) + } // idHex tie-break so a same-second pair resolves identically on every client. .maxWithOrNull(compareBy({ it.createdAt() ?: 0L }, { it.idHex })) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentFleetMetrics.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentFleetMetrics.kt index fb5c1e8b79..e5f28674dd 100644 Binary files a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentFleetMetrics.kt and b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentFleetMetrics.kt differ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzCanvasWriter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzCanvasWriter.kt new file mode 100644 index 0000000000..4d03ea2531 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzCanvasWriter.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.buzz + +import com.vitorpamplona.amethyst.commons.model.Account +import com.vitorpamplona.quartz.buzz.stream.CanvasEvent +import com.vitorpamplona.quartz.buzz.stream.tags.ExpectedRevisionTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Publishes a Buzz canvas (kind 40100) edit with the writer discipline Buzz's own clients use + * (`set_canvas` in `desktop/src-tauri/src/commands/canvas.rs`): + * + * - the write asserts the head the editor was opened on with `["expected-revision", ]`, + * or `none` when there was no canvas yet, so the relay refuses it (`conflict: …`) instead of + * silently overwriting someone else's newer revision; + * - it is stamped `created_at = max(now, head.created_at + 1)` ([CanvasEvent.writeCreatedAt]) so + * it sorts strictly ahead of that head, and is refused outright when the head sits too far in + * the future to ratchet past. + * + * Unlike the fire-and-forget broadcaster, this waits for the relay's OK: a rejected write must + * not be folded into [BuzzWorkspaceStates] as the new head, and the conflict has to reach the UI. + */ +object BuzzCanvasWriter { + sealed interface Outcome { + /** The relay stored the revision; it is now the local head too. */ + data class Saved( + val event: CanvasEvent, + ) : Outcome + + /** The canvas changed since the editor loaded it (the relay's `conflict:` rejection). */ + data class Conflict( + val message: String, + ) : Outcome + + /** The loaded head is timestamped too far in the future to write after; nothing was sent. */ + data object HeadTooFarInFuture : Outcome + + /** Any other rejection, or no answer at all. */ + data class Failed( + val message: String, + ) : Outcome + } + + /** + * Signs and publishes [markdown] as the new canvas of [channelId] on [relay], asserting the + * head the editor started from ([headId] / [headCreatedAt], both null when there was none). + */ + suspend fun save( + account: Account, + relay: NormalizedRelayUrl, + channelId: String, + markdown: String, + headId: HexKey?, + headCreatedAt: Long?, + now: Long = TimeUtils.now(), + ): Outcome { + val createdAt = CanvasEvent.writeCreatedAt(headCreatedAt, now) ?: return Outcome.HeadTooFarInFuture + val template = CanvasEvent.build(channelId, markdown, createdAt, expectedRevision = headId ?: ExpectedRevisionTag.NONE) + val signed = account.signer.sign(template) + + var results = account.client.publishAndCollectResults(signed, setOf(relay)) + // A cold socket answers the first write with `auth-required` while our AUTH lands; the + // relay does not replay it, so send once more on the now-authenticated connection. + if (results.values.none { it.accepted } && results.values.any { it.message.contains("auth-required", ignoreCase = true) }) { + results = account.client.publishAndCollectResults(signed, setOf(relay)) + } + + val outcome = classify(signed, results.values) + if (outcome is Outcome.Saved) account.cache.justConsumeMyOwnEvent(signed) + return outcome + } + + /** Maps the relay's OK answers to an [Outcome]: any acceptance wins, then a `conflict:`, then the first reason. */ + fun classify( + event: CanvasEvent, + results: Collection, + ): Outcome { + if (results.any { it.accepted }) return Outcome.Saved(event) + results.firstOrNull { CanvasEvent.isConflict(it.message) }?.let { return Outcome.Conflict(it.message) } + return Outcome.Failed(results.firstOrNull()?.message ?: PublishResult.NO_RESPONSE) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestations.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestations.kt index 5ec6667c26..55f585e1ea 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestations.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHeldAttestations.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.model.buzz import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -50,10 +51,12 @@ class BuzzHeldAttestations( val flow: StateFlow = held /** - * The `auth` tag to attach to this account's NIP-42 AUTH event, or null when no verified - * attestation is held. + * The `auth` tag to attach to an AUTH event signed at [authCreatedAt], or null when no verified + * attestation is held or its `created_at<` / `created_at>` bounds exclude that moment. Buzz + * checks those bounds against the AUTH event's timestamp and refuses membership outside them, + * so an expired credential stays held (the UI can say it expired) but is not presented. */ - fun authTag(): Array? = held.value?.toTag() + fun authTag(authCreatedAt: Long = TimeUtils.now()): Array? = held.value?.takeIf { it.isValidAt(authCreatedAt) }?.toTag() /** * Stores [attestation] as authorizing this account, if it verifies for [agentPubKey]. Returns diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHuddleLivenessState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHuddleLivenessState.kt new file mode 100644 index 0000000000..f3e687ad1e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHuddleLivenessState.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.buzz + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Which Buzz huddle sessions are live right now, per channel, fed by the relay-synthesized + * kind-48104 liveness events (`HuddleLivenessEvent`). The relay builds those on demand from its + * live huddle table, answers only for sessions that are live, and never stores them, so a + * session's liveness is "seen in a recent answer": Buzz's desktop re-asks every 10 seconds and + * treats a session that stops appearing as ended. [LIVE_WINDOW_SECS] leaves room for two missed + * polls before a huddle drops off. + * + * Shape: `channelId -> sessionId -> last seen (seconds)`. Lock-guarded because `LocalCache` + * consume runs on several relay reader threads. Process-wide singleton like [BuzzPresenceState]. + */ +object BuzzHuddleLivenessState { + const val LIVE_WINDOW_SECS = 30L + + private val lock = KmpLock() + private val mutableSeen = MutableStateFlow>>(persistentMapOf()) + + /** `channelId -> sessionId -> last seen`; collect it and filter with [liveSessions]. */ + val flow: StateFlow>> = mutableSeen + + /** Records that the relay reported [sessionId] in [channelId] live as of [seenAtSecs]. */ + fun record( + channelId: String, + sessionId: String, + seenAtSecs: Long, + ) = lock.withLock { + val sessions = mutableSeen.value[channelId] ?: persistentMapOf() + val prev = sessions[sessionId] + if (prev != null && seenAtSecs <= prev) return@withLock + mutableSeen.value = mutableSeen.value.putting(channelId, sessions.putting(sessionId, seenAtSecs)) + } + + /** The sessions in [channelId] reported live within [LIVE_WINDOW_SECS] of [nowSecs]. */ + fun liveSessions( + channelId: String, + nowSecs: Long, + snapshot: Map> = mutableSeen.value, + ): Set = + snapshot[channelId] + ?.filterValues { nowSecs - it <= LIVE_WINDOW_SECS } + ?.keys + .orEmpty() + + /** Test-only: clears all state so unit tests don't leak into each other. */ + fun clearForTesting() = + lock.withLock { + mutableSeen.value = persistentMapOf() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzIdentityNames.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzIdentityNames.kt new file mode 100644 index 0000000000..7ba41bdd9a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzIdentityNames.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.buzz + +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent +import com.vitorpamplona.quartz.buzz.identityNames.IdentityNamePolicy +import com.vitorpamplona.quartz.buzz.identityNames.NamingIdentity +import com.vitorpamplona.quartz.buzz.identityNames.ResolvedIdentityName +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Buzz's contextual identity names for one relay group: each member's label, lengthened only when + * someone else in the channel shares the name ("Alice’s Honey", "Honey (agent)", "Honey · 7xk2"). See + * [IdentityNamePolicy] for the rule. Names are compared against the channel's members, so a label only + * grows when a real collision exists there; a non-member author is resolved against the members plus + * itself. + * + * Each channel's result is cached until its roster changes or any profile or agent profile arrives + * ([invalidate], which bumps [version]); the UI can collect [version] to redraw. + */ +object BuzzIdentityNames { + private val mutableVersion = MutableStateFlow(0L) + + /** Bumped whenever a name, agent profile or owner may have changed. */ + val version: StateFlow = mutableVersion + + /** A kind-0 or kind-10100 landed; names computed so far may be stale. */ + fun invalidate() { + mutableVersion.value = mutableVersion.value + 1 + } + + private class Entry( + val members: Set, + val version: Long, + val viewer: HexKey, + val resolved: Map, + val outside: MutableMap = HashMap(), + ) + + private val lock = KmpLock() + private val cache = HashMap() + + /** + * The contextual label for [pubkey] in [channel] as [viewer] sees it, or null when there is no + * name to disambiguate (an invalid key). The label equals the plain name unless it collides. + */ + fun labelFor( + channel: RelayGroupChannel, + pubkey: HexKey, + viewer: HexKey, + users: (HexKey) -> User? = LocalCache::getUserIfExists, + hasAgentProfile: (HexKey) -> Boolean = ::hasCachedAgentProfile, + ): ResolvedIdentityName? { + val members = channel.allMemberKeys() + val version = mutableVersion.value + val entry = + lock.withLock { + cache[channel.groupId]?.takeIf { it.members === members && it.version == version && it.viewer == viewer } + } ?: Entry(members, version, viewer, resolve(members, viewer, users, hasAgentProfile)).also { lock.withLock { cache[channel.groupId] = it } } + + val key = pubkey.lowercase() + if (key in entry.resolved) return entry.resolved[key] + return lock.withLock { entry.outside[key] } + ?: resolve(members + key, viewer, users, hasAgentProfile)[key].also { found -> lock.withLock { entry.outside[key] = found } } + } + + /** The naming fact for [pubkey]: its profile name, and whether (and whose) agent it is. */ + fun factFor( + pubkey: HexKey, + users: (HexKey) -> User?, + hasAgentProfile: (HexKey) -> Boolean = ::hasCachedAgentProfile, + ): NamingIdentity { + val user = users(pubkey) + val info = user?.metadataOrNull()?.flow?.value + val owner = info?.nipOaOwner + return NamingIdentity( + pubkey = pubkey, + name = info?.info?.bestName()?.takeIf { IdentityNamePolicy.trim(it).isNotEmpty() } ?: user?.pubkeyDisplayHex() ?: pubkey.take(8), + isAgent = owner != null || hasAgentProfile(pubkey), + ownerPubkey = owner, + ) + } + + /** Whether the cache holds a kind-10100 agent profile by [pubkey]. */ + fun hasCachedAgentProfile(pubkey: HexKey): Boolean = LocalCache.getAddressableNoteIfExists(AgentProfileEvent.createAddress(pubkey))?.event is AgentProfileEvent + + private fun resolve( + candidates: Set, + viewer: HexKey, + users: (HexKey) -> User?, + hasAgentProfile: (HexKey) -> Boolean, + ): Map { + val facts = candidates.filter { isKey(it) }.map { factFor(it, users, hasAgentProfile) } + // Owners outside the channel only lend their names ("Alice’s Honey"); they don't compete. + // Listed first so a member's own fact stays the preferred one. + val ownerFacts = + facts + .mapNotNull { it.ownerPubkey } + .filter { it !in candidates } + .distinct() + .mapNotNull { owner -> + users(owner) + ?.metadataOrNull() + ?.flow + ?.value + ?.info + ?.bestName() + ?.takeIf { IdentityNamePolicy.trim(it).isNotEmpty() } + ?.let { NamingIdentity(owner, it) } + } + return IdentityNamePolicy.resolve(ownerFacts + facts, viewer = viewer.takeIf { isKey(it) }, candidates = facts.map { it.pubkey }) + } + + private fun isKey(value: String) = value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } + + /** Test-only. */ + fun clearForTesting() = + lock.withLock { + cache.clear() + mutableVersion.value = 0 + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt index 9876820844..73d68e7830 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzWorkspaceStates.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.buzz import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.buzz.stream.CanvasEvent import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -46,7 +47,10 @@ import kotlin.concurrent.Volatile class BuzzWorkspaceState { private val lock = KmpLock() - /** The newest canvas (kind 40100) note for this channel, or null when none seen. */ + /** + * The live canvas (kind 40100) note for this channel, or null when none seen — picked the way + * the relay reads its head: `created_at DESC, id ASC` (see [CanvasEvent.isNewerHead]). + */ @Volatile var canvasNote: Note? = null private set @@ -58,7 +62,10 @@ class BuzzWorkspaceState { fun updateCanvas(note: Note) = lock.withLock { - if ((note.createdAt() ?: 0L) > (canvasNote?.createdAt() ?: 0L)) { + val current = canvasNote + // A same-second tie goes to the smallest id, not the last arrival, so every client + // (and the relay's own CAS check) agrees on which revision is the head. + if (CanvasEvent.isNewerHead(note.createdAt() ?: 0L, note.idHex, current?.createdAt(), current?.idHex)) { canvasNote = note canvasVersion.value = canvasVersion.value + 1 } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index c53f0d9858..eab486d0d5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -36,6 +36,8 @@ import com.vitorpamplona.amethyst.commons.model.UserContext import com.vitorpamplona.amethyst.commons.model.backups.LocallySignedEvents import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHuddleLivenessState +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzIdentityNames import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState @@ -64,7 +66,10 @@ import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent +import com.vitorpamplona.quartz.buzz.arArtifacts.ArtifactEvent +import com.vitorpamplona.quartz.buzz.arArtifacts.ArtifactRemovalEvent import com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent +import com.vitorpamplona.quartz.buzz.cwChannelWindow.ThreadWindowBoundsEvent import com.vitorpamplona.quartz.buzz.cwChannelWindow.WindowBoundsEvent import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent @@ -77,6 +82,7 @@ import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleGuidelinesEvent +import com.vitorpamplona.quartz.buzz.huddles.HuddleLivenessEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleReactionEvent @@ -98,6 +104,7 @@ import com.vitorpamplona.quartz.buzz.moderation.ModerationResolveReportEvent import com.vitorpamplona.quartz.buzz.moderation.ModerationTimeoutEvent import com.vitorpamplona.quartz.buzz.moderation.ModerationUntimeoutEvent import com.vitorpamplona.quartz.buzz.moderation.ProductFeedbackEvent +import com.vitorpamplona.quartz.buzz.mpProjects.ProjectEvent import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent import com.vitorpamplona.quartz.buzz.pairing.PairingEvent @@ -119,6 +126,7 @@ import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent +import com.vitorpamplona.quartz.buzz.teamCatalog.TeamCatalogEvent import com.vitorpamplona.quartz.buzz.teams.TeamEvent import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent @@ -1098,6 +1106,7 @@ open class EventCache : val newUserMetadata = event.contactMetaData() if (newUserMetadata != null && (wasVerified || justVerify(event))) { user.updateUserInfo(newUserMetadata, event) + BuzzIdentityNames.invalidate() if (relay != null) { user.addRelayBeingUsed(relay, event.createdAt) } @@ -2329,7 +2338,7 @@ open class EventCache : /** * Marks the serving relay as Buzz, but only off a VERIFIED event: the mark changes - * what the composer sends (40002 vs kind 9) and how new channels on the relay are + * what the composer sends (Buzz's kind-9 shape vs NIP-29's) and how new channels on the relay are * treated, so an unverifiable frame from a buggy/hostile relay must not flip it. * The note-has-event check is the same verification gate the attach path uses. */ @@ -3757,6 +3766,11 @@ open class EventCache : is AuditEntryEvent, is ChannelSummaryEvent, is PresenceSnapshotEvent, + // NIP-AR artifact revisions and relay-signed removal markers: queryable state, never + // chat rows - the spec keeps them out of chat, reply and unread counts. The current + // head per `d` is derived with ArtifactHeadResolver (by `prev`, not by time). + is ArtifactEvent, + is ArtifactRemovalEvent, -> consumeBuzzRegularEvent(event, relay, wasVerified) // Buzz ephemeral signals: transient by definition (20000-29999) — do not @@ -3776,8 +3790,19 @@ open class EventCache : BuzzPresenceState.record(event.subjectPubKey(), event.status(), event.createdAt) false } + // An agent profile makes its author an agent, which can change how names in a + // channel are told apart. + is AgentProfileEvent -> consumeBaseReplaceable(event, relay, wasVerified).also { BuzzIdentityNames.invalidate() } is ObserverFrameEvent -> false is HuddleReactionEvent -> false + // Relay-synthesized "this huddle session is live" (48104): only produced on demand + // for a dedicated REQ and never stored, so record it and keep no note. + is HuddleLivenessEvent -> { + val channel = event.channelId() + val session = event.sessionId() + if (channel != null && session != null) BuzzHuddleLivenessState.record(channel, session, TimeUtils.now()) + false + } // Pairing (24134) is deliberately dialect-neutral: it flows during device // pairing before any workspace relationship is established. is PairingEvent -> false @@ -3900,13 +3925,15 @@ open class EventCache : // Buzz addressable/replaceable state. is PersonaEvent, is TeamEvent, + is TeamCatalogEvent, + is ProjectEvent, is ManagedAgentEvent, - is AgentProfileEvent, is EngramEvent, is WorkflowDefEvent, is EventReminderEvent, is PushLeaseEvent, is WindowBoundsEvent, + is ThreadWindowBoundsEvent, is ArchivedIdentitiesListEvent, is RelayDiscoveryEvent, is RelayMonitorEvent, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReply.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReply.kt index 1e97f7e0a5..51976d5299 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReply.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReply.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.model.chats import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event +import com.vitorpamplona.quartz.buzz.stream.isBroadcast import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -31,10 +32,11 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent * its parent message, NOT as a flat sibling in the main timeline. Three dialects express the same idea: * * - **NIP-28/NIP-29 (public chats, Concord)**: a kind-1111 [CommentEvent]. - * - **Buzz workspaces, current**: a kind-9 [ChatEvent] carrying a NIP-10 `reply`-marked `e` tag. This - * is what every live Buzz client writes — `_buildReplyTags` in its Flutter client emits - * `["e", id, "", "reply"]` for a direct reply and `["e", root, "", "root"]` + - * `["e", parent, "", "reply"]` for a nested one, and all three of their clients send chat as kind 9. + * - **Buzz workspaces, current**: a kind-9 [ChatEvent] carrying a NIP-10 `reply`-marked `e` tag and + * NOT flagged `broadcast`. This is what every live Buzz client (and Amethyst) writes — + * `build_message` emits `["e", id, "", "reply"]` for a direct reply and `["e", root, "", "root"]` + + * `["e", parent, "", "reply"]` for a nested one, plus `["broadcast", "1"]` when the reply should + * also show in the channel. * - **Buzz workspaces, legacy**: a kind-40002 [StreamMessageV2Event] with the same markers and NOT * flagged `broadcast`. Nothing in Buzz writes 40002 any more (their own NOSTR.md grades it * "Buzz-only — no standard NIP-29 client renders these"), but events exist in the wild from the @@ -49,8 +51,8 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent * as a quote bubble in the timeline — so matching on the `reply` marker (never on the bare tag) leaves * that dialect untouched. * - * A `broadcast=1` reply is an inline timeline sibling ("also send to channel"), matching block/buzz's - * `isThreadReply`. Kind 9 has no broadcast tag, so a marked kind-9 is always thread-only. + * A `broadcast=1` reply is an inline timeline sibling ("also send to channel") on either kind, matching + * block/buzz's `isBroadcastReply`, which the relay reads the same way whatever the kind. * * The timeline filter drops these (they belong in the minichat), the minichat count counts them, and * the minichat feed shows them — so all three agree on one definition. @@ -58,7 +60,7 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent fun isMinichatReply(event: Event?): Boolean = when (event) { is CommentEvent -> true - is ChatEvent -> event.tags.buzzThreadReply() != null + is ChatEvent -> !event.tags.isBroadcast() && event.tags.buzzThreadReply() != null is StreamMessageV2Event -> !event.isBroadcast() && event.tags.buzzThreadReply() != null else -> false } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserMetadataCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserMetadataCache.kt index ffa1e25374..59cea6acab 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserMetadataCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserMetadataCache.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.commons.model.nip01Core import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag @@ -37,7 +39,17 @@ class UserInfo( val tags: ImmutableListOfLists, val identities: List, val createdAt: Long, -) + val pubKey: HexKey? = null, +) { + /** + * The owner this profile declares through a valid NIP-OA `auth` tag, making the user an agent + * of that owner (who may then edit the agent's Buzz messages), or null. Verified once per + * profile version. + */ + val nipOaOwner: HexKey? by lazy { + pubKey?.let { OwnerAttestation.verifiedOwnerOf(it, MetadataEvent.KIND, createdAt, tags.lists) } + } +} @Stable class UserMetadataCache { @@ -53,6 +65,7 @@ class UserMetadataCache { tags = metaEvent.tags.toImmutableListOfLists(), identities = metaEvent.identityClaims(), createdAt = metaEvent.createdAt, + pubKey = metaEvent.pubKey, ) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 0b7bd453ee..49389c1958 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzIdentityNames import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock @@ -139,6 +140,8 @@ class RelayGroupChannel( private fun recomputeAllMembers() { allMembers = if (admins.isEmpty()) members else members + admins.mapTo(HashSet()) { it.pubKey } + // Who is in the channel decides which names collide there. + if (BuzzRelayDialect.isBuzz(groupId.relayUrl)) BuzzIdentityNames.invalidate() } /** @@ -316,6 +319,9 @@ class RelayGroupChannel( /** Number of known members (admins are members too). */ fun memberCount(): Int = allMembers.size + /** Every known member and admin. The set is replaced, never mutated, when the roster changes. */ + fun allMemberKeys(): Set = allMembers + /** * The subset of this group's members/admins that [follows] contains — "people you follow who * are in here". Reads the cached [allMembers] set, so it's cheap to call per recomposition. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt index 6f8ebac80e..fc04d0352a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusCache.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.UserDependencies +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip40Expiration.isExpired import kotlinx.collections.immutable.ImmutableList @@ -42,7 +43,7 @@ class UserStatusCache : UserDependencies { fun addStatus(note: AddressableNote) { // if it's already there, quick exit - if (statuses.value.contains(note) || note.event?.content.isNullOrBlank()) return + if (statuses.value.contains(note) || note.isEmptyStatus()) return // don't add expired statuses if (note.event?.isExpired() == true) return @@ -52,6 +53,15 @@ class UserStatusCache : UserDependencies { } } + /** + * Nothing to show: no event, or blank text with no plain status emoji. An emoji-only status + * (Buzz writes `["emoji", "🌴"]` with blank content) is still a status. + */ + private fun Note.isEmptyStatus(): Boolean { + val event = event ?: return true + return if (event is UserStatusEvent) event.isCleared() else event.content.isBlank() + } + fun removeStatus(deleteNote: AddressableNote) { // if it's not already there, quick exit if (!statuses.value.contains(deleteNote)) return diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt index bab6f362a6..ec83f0f2d2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.forEachChunk import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast +import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer @@ -57,6 +58,10 @@ val UserMetadataForKeyKinds = // kind:17375 — that's the user's private wallet (NIP-44 encrypted // to them); only the kind:10019 nutzap announcement is public. NutzapInfoEvent.KIND, + // Buzz agent profile (kind:10100): marks the author as an agent and carries its metadata + // and channel-add policy. Co-loaded with kind:0 so an agent is recognizable the moment it + // renders. (Its owner comes from the NIP-OA `auth` tag on the kind:0 itself.) + AgentProfileEvent.KIND, ) fun filterUserMetadataForKey( diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/BuzzRoleForTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/BuzzRoleForTest.kt new file mode 100644 index 0000000000..3588256fef --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/BuzzRoleForTest.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model + +import kotlin.test.Test +import kotlin.test.assertEquals + +class BuzzRoleForTest { + @Test + fun picksTheMostPrivilegedBuzzRole() { + assertEquals("owner", buzzRoleFor(listOf("admin", "owner"))) + assertEquals("admin", buzzRoleFor(listOf("admin"))) + assertEquals("admin", buzzRoleFor(listOf("Admin"))) + assertEquals("bot", buzzRoleFor(listOf("bot"))) + assertEquals("guest", buzzRoleFor(listOf("guest"))) + } + + @Test + fun unknownOrEmptyRolesBecomeMember() { + // Buzz has no moderator; an unparseable role would fail the whole put-user. + assertEquals("member", buzzRoleFor(listOf("moderator"))) + assertEquals("member", buzzRoleFor(emptyList())) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentFleetAggregatorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentFleetAggregatorTest.kt index 6765377150..44bc72504c 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentFleetAggregatorTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentFleetAggregatorTest.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.buzz.amTurnMetrics.TokenCounts import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class AgentFleetAggregatorTest { @@ -196,4 +197,67 @@ class AgentFleetAggregatorTest { assertEquals(50L, metrics.totals.totalTokens) assertFalse(metrics.hasUnreliableEstimates) } + + private fun cache( + read: Long? = null, + write: Long? = null, + ) = TokenCounts(inputTokens = 100, cacheReadTokens = read, cacheWriteTokens = write) + + /** NIP-AM: an omitted cache component is unknown — the total must not become a fabricated 0. */ + @Test + fun unreportedCacheStaysUnknown() { + val metrics = + AgentFleetAggregator.aggregate( + listOf( + turn(agentA, "s1", 1, turn = counts(input = 100)), + turn(agentA, "s1", 2, turn = counts(input = 100)), + ), + ) + assertNull(metrics.totals.cacheReadTokens) + assertNull(metrics.totals.cacheWriteTokens) + assertEquals(200L, metrics.totals.inputTokens) + } + + @Test + fun explicitZeroCacheIsKnownZero() { + val metrics = AgentFleetAggregator.aggregate(listOf(turn(agentA, "s1", 1, turn = cache(read = 0, write = 0)))) + assertEquals(0L, metrics.totals.cacheReadTokens) + assertEquals(0L, metrics.totals.cacheWriteTokens) + } + + @Test + fun cacheUsesMaxCumulativeThenReportedDeltas() { + val metrics = + AgentFleetAggregator.aggregate( + listOf( + // Session s1 reports cumulatively; s2 only per turn, and only for reads. + turn(agentA, "s1", 1, cumulative = cache(read = 40, write = 5)), + turn(agentA, "s1", 2, cumulative = cache(read = 90, write = 5)), + turn(agentA, "s2", 3, turn = cache(read = 7)), + turn(agentA, "s2", 4, turn = cache(read = 3)), + // Another agent that never reports cache at all. + turn(agentB, "s3", 5, turn = counts(input = 1)), + ), + ) + val a = metrics.agents.single { it.agentPubKey == agentA } + assertEquals(100L, a.totals.cacheReadTokens) + assertEquals(5L, a.totals.cacheWriteTokens) + + val b = metrics.agents.single { it.agentPubKey == agentB } + assertNull(b.totals.cacheReadTokens) + + // Unknown sessions add nothing to the fleet total, but don't erase the known ones. + assertEquals(100L, metrics.totals.cacheReadTokens) + assertEquals(5L, metrics.totals.cacheWriteTokens) + } + + @Test + fun tokenTotalsAddUnknownAsIdentity() { + val known = TokenTotals(cacheReadTokens = 3) + val unknown = TokenTotals() + assertEquals(3L, (known + unknown).cacheReadTokens) + assertEquals(3L, (unknown + known).cacheReadTokens) + assertNull((unknown + unknown).cacheReadTokens) + assertEquals(6L, (known + known).cacheReadTokens) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzCanvasHeadTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzCanvasHeadTest.kt new file mode 100644 index 0000000000..31032f5134 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzCanvasHeadTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.buzz + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.buzz.stream.CanvasEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame + +class BuzzCanvasHeadTest { + private val channel = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + + private fun canvas( + id: String, + createdAt: Long, + ): Note = + Note(id).apply { + event = CanvasEvent(id, "a".repeat(64), createdAt, arrayOf(arrayOf("h", channel)), "rev $id", "sig") + } + + @AfterTest + fun tearDown() = BuzzWorkspaceStates.clearForTesting() + + @Test + fun newerCreatedAtWins() { + val state = BuzzWorkspaceState() + val old = canvas("1".repeat(64), 100) + val new = canvas("2".repeat(64), 101) + state.updateCanvas(new) + state.updateCanvas(old) + assertSame(new, state.canvasNote) + } + + /** The relay reads the head as `created_at DESC, id ASC`: a same-second tie goes to the smaller id. */ + @Test + fun sameSecondTieGoesToTheSmallestIdNotTheLastArrival() { + val small = canvas("1".repeat(64), 100) + val big = canvas("f".repeat(64), 100) + + val bigFirst = BuzzWorkspaceState() + bigFirst.updateCanvas(big) + bigFirst.updateCanvas(small) + assertSame(small, bigFirst.canvasNote) + + val smallFirst = BuzzWorkspaceState() + smallFirst.updateCanvas(small) + smallFirst.updateCanvas(big) + assertSame(small, smallFirst.canvasNote) + assertEquals(1, smallFirst.canvasUpdates.value) + } + + @Test + fun reconsumingTheHeadDoesNotBump() { + val state = BuzzWorkspaceState() + val head = canvas("1".repeat(64), 100) + state.updateCanvas(head) + state.updateCanvas(head) + assertEquals(1, state.canvasUpdates.value) + } + + @Test + fun writerClassifiesRelayAnswers() { + val event = CanvasEvent("1".repeat(64), "a".repeat(64), 100, arrayOf(arrayOf("h", channel)), "x", "sig") + + assertIs(BuzzCanvasWriter.classify(event, listOf(PublishResult(true, "")))) + + val conflict = BuzzCanvasWriter.classify(event, listOf(PublishResult(false, "conflict: canvas changed since it was loaded"))) + assertIs(conflict) + assertEquals("conflict: canvas changed since it was loaded", conflict.message) + + val invalid = BuzzCanvasWriter.classify(event, listOf(PublishResult(false, "invalid: canvas created_at too far in the future"))) + assertIs(invalid) + + val silent = BuzzCanvasWriter.classify(event, emptyList()) + assertIs(silent) + assertEquals(PublishResult.NO_RESPONSE, silent.message) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHuddleLivenessStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHuddleLivenessStateTest.kt new file mode 100644 index 0000000000..0cdd43bbd8 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzHuddleLivenessStateTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.buzz + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BuzzHuddleLivenessStateTest { + @AfterTest + fun reset() = BuzzHuddleLivenessState.clearForTesting() + + @Test + fun aReportedSessionIsLiveUntilItStopsBeingReported() { + BuzzHuddleLivenessState.record("chan", "s1", seenAtSecs = 1_000) + + assertEquals(setOf("s1"), BuzzHuddleLivenessState.liveSessions("chan", nowSecs = 1_000)) + assertEquals(setOf("s1"), BuzzHuddleLivenessState.liveSessions("chan", nowSecs = 1_000 + BuzzHuddleLivenessState.LIVE_WINDOW_SECS)) + // Two missed 10s polls later it is gone: the relay only answers for live sessions. + assertTrue(BuzzHuddleLivenessState.liveSessions("chan", nowSecs = 1_001 + BuzzHuddleLivenessState.LIVE_WINDOW_SECS).isEmpty()) + } + + @Test + fun sessionsAreScopedToTheirChannelAndAnOlderReportIsIgnored() { + BuzzHuddleLivenessState.record("chan", "s1", seenAtSecs = 1_000) + BuzzHuddleLivenessState.record("chan", "s1", seenAtSecs = 900) + + assertTrue(BuzzHuddleLivenessState.liveSessions("other", nowSecs = 1_000).isEmpty()) + assertEquals(1_000L, BuzzHuddleLivenessState.flow.value["chan"]?.get("s1")) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzIdentityNamesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzIdentityNamesTest.kt new file mode 100644 index 0000000000..2be7ec3f7d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/BuzzIdentityNamesTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.buzz + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.UserContext +import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BuzzIdentityNamesTest { + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.example.team/")!! + private val context = UserContext { AddressableNote(it) } + private val users = HashMap() + + @AfterTest + fun reset() = BuzzIdentityNames.clearForTesting() + + private fun user( + pubkey: HexKey, + name: String, + vararg tags: Array, + ) = User(pubkey, context).also { + it.metadata().flow.value = UserInfo(UserMetadata().apply { this.name = name }, arrayOf(*tags).toImmutableListOfLists(), emptyList(), 1, pubkey) + users[pubkey] = it + } + + private fun channelWith(vararg members: HexKey) = + RelayGroupChannel(GroupId("6a39da2f-33c0-44f6-a050-c4da0138644a", relay)).also { channel -> + channel.updateMembers(GroupMembersEvent("00", "ff".repeat(32), 10, members.map { arrayOf("p", it) }.toTypedArray(), "", "00")) + } + + private fun label( + channel: RelayGroupChannel, + pubkey: HexKey, + viewer: HexKey, + ) = BuzzIdentityNames.labelFor(channel, pubkey, viewer, users = { users[it] }, hasAgentProfile = { false })?.name + + @Test + fun anAgentSharingAMembersNameIsNamedAfterItsOwner() { + val alice = KeyPair() + val aliceKey = alice.pubKey.toHexKey() + val me = "1".repeat(64) + val human = "c".repeat(64) + val agent = "a".repeat(64) + user(me, "Me") + user(aliceKey, "Alice") + user(human, "Honey") + user(agent, "Honey", OwnerAttestation.sign(agent, "", alice.privKey!!).toTag()) + + val channel = channelWith(me, human, agent) + // The person keeps the plain name; the agent borrows its owner's. + assertEquals("Honey", label(channel, human, me)) + assertEquals("Alice’s Honey", label(channel, agent, me)) + } + + @Test + fun namesOnlyGrowOnARealCollision() { + val me = "1".repeat(64) + val a = "a".repeat(64) + val b = "b".repeat(64) + user(me, "Me") + user(a, "Sam") + user(b, "Sam") + + // Alone in the channel, Sam is just Sam. + assertEquals("Sam", label(channelWith(me, a), a, me)) + // With a namesake, both get a key suffix. + val both = channelWith(me, a, b) + assertTrue(label(both, a, me)!!.startsWith("Sam · ")) + assertTrue(label(both, a, me) != label(both, b, me)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReplyTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReplyTest.kt index 27e0a33c10..1bb8443dee 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReplyTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/MinichatReplyTest.kt @@ -96,4 +96,10 @@ class MinichatReplyTest { fun `root marker alone is not a reply`() { assertFalse(isMinichatReply(chat(arrayOf("e", rootId, "", "root")))) } + + /** "Also send to channel": Buzz flags the reply `broadcast`, on kind 9 exactly as on 40002. */ + @Test + fun `buzz broadcast reply stays in the channel timeline`() { + assertFalse(isMinichatReply(chat(arrayOf("e", parentId, "", "reply"), arrayOf("broadcast", "1")))) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserInfoAgentOwnerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserInfoAgentOwnerTest.kt new file mode 100644 index 0000000000..ab5868a955 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserInfoAgentOwnerTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip01Core + +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class UserInfoAgentOwnerTest { + private val owner = KeyPair() + private val agent = KeyPair().pubKey.toHexKey() + + private fun info( + vararg tags: Array, + pubKey: String? = agent, + ) = UserInfo(UserMetadata(), arrayOf(*tags).toImmutableListOfLists(), emptyList(), createdAt = 1_000, pubKey = pubKey) + + @Test + fun aProfileWithAValidAttestationNamesItsOwner() { + val tag = OwnerAttestation.sign(agent, "", owner.privKey!!).toTag() + assertEquals(owner.pubKey.toHexKey(), info(tag).nipOaOwner) + } + + @Test + fun noAttestationOrOneForAnotherKeyMeansNoOwner() { + assertNull(info().nipOaOwner) + val forSomeoneElse = OwnerAttestation.sign(KeyPair().pubKey.toHexKey(), "", owner.privKey!!).toTag() + assertNull(info(forSomeoneElse).nipOaOwner) + // Without the profile's own pubkey there is nothing to verify against. + assertNull(info(OwnerAttestation.sign(agent, "", owner.privKey!!).toTag(), pubKey = null).nipOaOwner) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserMetadataForKeyKindsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserMetadataForKeyKindsTest.kt new file mode 100644 index 0000000000..20700d981b --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserMetadataForKeyKindsTest.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.user.watchers + +import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import kotlin.test.Test +import kotlin.test.assertTrue + +class UserMetadataForKeyKindsTest { + @Test + fun agentProfilesLoadWithEveryUserProfile() { + assertTrue(MetadataEvent.KIND in UserMetadataForKeyKinds) + assertTrue(AgentProfileEvent.KIND in UserMetadataForKeyKinds) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/actions/BuzzInviteMinter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/actions/BuzzInviteMinter.kt index a4d1ed1c5b..6db364a911 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/actions/BuzzInviteMinter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/actions/BuzzInviteMinter.kt @@ -45,26 +45,47 @@ import okhttp3.coroutines.executeAsync * `crates/buzz-relay/src/api/invites.rs`. */ object BuzzInviteMinter { - /** A freshly minted invite: the opaque [code], the shareable [url], and its [expiresAt] (secs). */ + /** Shortest lifetime the relay accepts (`MIN_INVITE_TTL_SECS` in `buzz-core/src/invite.rs`). */ + const val MIN_TTL_SECS = 60L + + /** Longest lifetime the relay accepts: 30 days (`MAX_INVITE_TTL_SECS`). */ + const val MAX_TTL_SECS = 30L * 24 * 60 * 60 + + /** Largest `max_uses` the relay accepts (`MAX_INVITE_USES`, the database constraint). */ + const val MAX_USES = 10_000 + + /** + * A freshly minted invite: the opaque [code], the shareable [url], and its [expiresAt] (secs). + * [maxUses] / [usesRemaining] are null for an unlimited invite (the default). + */ data class MintedInvite( val code: String, val url: String, val expiresAt: Long, + val maxUses: Int? = null, + val usesRemaining: Int? = null, ) /** - * POST `/api/invites` on [relay]'s host with an optional [ttlSecs] (relay clamps to [60, 30d]; - * default 72 h). [httpAuth] signs the NIP-98 event over the exact URL + body; [okHttpClient] - * supplies the transport (use a trusted-relay-posture client so a Cloudflare-fronted relay is - * reached over clearnet). Throws [IllegalStateException] with the relay's error slug on failure. + * POST `/api/invites` on [relay]'s host with an optional [ttlSecs] (default 72 h) and an + * optional [maxUses] cap (default unlimited). The relay does **not** clamp: a [ttlSecs] outside + * [[MIN_TTL_SECS], [MAX_TTL_SECS]] or a [maxUses] outside [1, [MAX_USES]] is refused with a 400, + * so both are checked here first. [httpAuth] signs the NIP-98 event over the exact URL + body; + * [okHttpClient] supplies the transport (use a trusted-relay-posture client so a + * Cloudflare-fronted relay is reached over clearnet). Throws [IllegalStateException] with the + * relay's error message on failure. */ suspend fun mint( relay: NormalizedRelayUrl, ttlSecs: Long?, okHttpClient: (String) -> OkHttpClient, httpAuth: suspend (url: String, method: String, body: ByteArray?) -> HTTPAuthorizationEvent, + maxUses: Int? = null, ): MintedInvite = withContext(Dispatchers.IO) { + require(ttlSecs == null || ttlSecs in MIN_TTL_SECS..MAX_TTL_SECS) { "ttl_secs must be between $MIN_TTL_SECS and $MAX_TTL_SECS" } + require(maxUses == null || maxUses in 1..MAX_USES) { "max_uses must be between 1 and $MAX_USES" } + // wss://host[/..] -> https://host ; ws://host -> http://host. The endpoint is host-root. val wsUrl = relay.url val scheme = if (wsUrl.startsWith("wss", ignoreCase = true)) "https" else "http" @@ -75,7 +96,7 @@ object BuzzInviteMinter { val url = httpUrl.toString() // Exact bytes the NIP-98 payload hash is computed over — must equal what we send. - val bodyStr = ttlSecs?.let { "{\"ttl_secs\":$it}" } ?: "{}" + val bodyStr = requestBody(ttlSecs, maxUses) val bodyBytes = bodyStr.toByteArray(Charsets.UTF_8) val auth = httpAuth(url, "POST", bodyBytes) @@ -101,9 +122,21 @@ object BuzzInviteMinter { code = tree?.get("code")?.stringOrNull().orEmpty(), url = tree?.get("url")?.stringOrNull().orEmpty(), expiresAt = tree?.get("expires_at")?.longOrNull() ?: 0L, + maxUses = tree?.get("max_uses")?.longOrNull()?.toInt(), + usesRemaining = tree?.get("uses_remaining")?.longOrNull()?.toInt(), ) } } + + /** The exact JSON body of a mint request; omitted fields take the relay's defaults (72 h, unlimited). */ + fun requestBody( + ttlSecs: Long?, + maxUses: Int?, + ): String = + buildList { + ttlSecs?.let { add("\"ttl_secs\":$it") } + maxUses?.let { add("\"max_uses\":$it") } + }.joinToString(",", prefix = "{", postfix = "}") } private fun JsonElement.stringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/actions/BuzzInviteMinterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/actions/BuzzInviteMinterTest.kt new file mode 100644 index 0000000000..5277ffc2db --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/actions/BuzzInviteMinterTest.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import kotlin.test.Test +import kotlin.test.assertEquals + +class BuzzInviteMinterTest { + @Test + fun requestBodyOmitsUnsetFields() { + assertEquals("{}", BuzzInviteMinter.requestBody(null, null)) + assertEquals("""{"ttl_secs":3600}""", BuzzInviteMinter.requestBody(3600, null)) + assertEquals("""{"max_uses":5}""", BuzzInviteMinter.requestBody(null, 5)) + assertEquals("""{"ttl_secs":60,"max_uses":10000}""", BuzzInviteMinter.requestBody(60, 10_000)) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index b936aabc3e..9fe4e93353 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2354,6 +2354,11 @@ Edit canvas Save canvas Canvas (Markdown) + Someone else changed this canvas since you started editing. Copy your changes, reopen the canvas and apply them again. + The current canvas is timestamped too far in the future to be edited safely. + Couldn't save the canvas: %1$s + %1$s was removed by an administrator + This channel was archived automatically Editing message (edited) %1$s is typing… @@ -3149,6 +3154,12 @@ Hold an attestation Holding an attestation for this account. It is attached automatically when you authenticate to a Buzz relay. Grants: %1$s + Huddle live + Agent + managed by %1$s + managed by you + This attestation has expired. Buzz relays no longer accept it; ask the owner for a new one. + This attestation is not valid yet. Buzz relays will accept it once its start time passes. any kind, any time (unrestricted) Remove Paste an owner-signed auth tag issued to this account to authenticate to their Buzz workspace as a virtual member. @@ -3178,6 +3189,12 @@ Provider (optional) Runtime (optional) Avatar URL (optional) + Description (optional) + Public, up to 280 characters. + Share with the workspace + Other members can see and use this persona. Off keeps it to your own devices. + One conversation per thread + The agent keeps a separate conversation for each thread instead of one per channel. Publishing… Publish persona Follows you diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/buzz/ui/BuzzAgentLabel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/buzz/ui/BuzzAgentLabel.kt new file mode 100644 index 0000000000..d7e7ceb43d --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/buzz/ui/BuzzAgentLabel.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.buzz.ui + +import androidx.compose.foundation.clickable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzIdentityNames +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.buzz_agent_label +import com.vitorpamplona.amethyst.commons.resources.buzz_agent_managed_by +import com.vitorpamplona.amethyst.commons.resources.buzz_agent_managed_by_you +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent +import com.vitorpamplona.quartz.buzz.identityNames.ResolvedIdentityName +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce + +/** + * Whether [author] has published a Buzz agent profile (kind 10100). The profile is requested with + * every user's kind 0, so this only watches the cache; it never asks a relay itself. + */ +@Composable +fun rememberHasBuzzAgentProfile(author: User): Boolean { + val note = remember(author) { LocalCache.getOrCreateAddressableNote(AgentProfileEvent.createAddress(author.pubkeyHex)) } + val hasProfile by produceState(note.event is AgentProfileEvent, note) { + note + .flow() + .metadata.stateFlow + .collect { value = note.event is AgentProfileEvent } + } + return hasProfile +} + +/** + * "Agent · managed by Alice" beside an agent's name, the way Buzz labels agents. [author] counts as + * an agent when it published a kind-10100 agent profile or its kind 0 names an [owner] through a + * verified NIP-OA `auth` tag; the owner part appears only for the latter. Draws nothing for a person. + */ +@Composable +fun BuzzAgentLabel( + author: User, + owner: HexKey?, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + showOwner: Boolean = true, + onOwnerClick: ((HexKey) -> Unit)? = null, +) { + val hasAgentProfile = rememberHasBuzzAgentProfile(author) + if (owner == null && !hasAgentProfile) return + + val agent = stringRes(Res.string.buzz_agent_label) + val text = + when { + owner == null || !showOwner -> agent + owner == accountViewModel.userProfile().pubkeyHex -> "$agent · ${stringRes(Res.string.buzz_agent_managed_by_you)}" + else -> "$agent · ${stringRes(Res.string.buzz_agent_managed_by, observeUserNameByHex(owner, accountViewModel))}" + } + + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = if (owner != null && showOwner && onOwnerClick != null) modifier.clickable { onOwnerClick(owner) } else modifier, + ) +} + +/** + * [author]'s contextual name in the Buzz [channel] ([BuzzIdentityNames]): the plain name unless + * another member shares it, then "Alice’s Honey", "Honey (agent)" or "Honey · 7xk2". Recomputed when + * profiles, agent profiles or the roster change, at most every quarter second. + */ +@OptIn(FlowPreview::class) +@Composable +fun rememberBuzzContextualName( + channel: RelayGroupChannel, + author: User, + accountViewModel: AccountViewModel, +): ResolvedIdentityName? { + val versions = remember { BuzzIdentityNames.version.debounce(250) } + val version by versions.collectAsStateWithLifecycle(BuzzIdentityNames.version.value) + val viewer = accountViewModel.userProfile().pubkeyHex + return remember(channel, author, viewer, version) { BuzzIdentityNames.labelFor(channel, author.pubkeyHex, viewer) } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NIP05VerificationDisplay.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NIP05VerificationDisplay.kt index eb314f4e77..6f8cd9fad8 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NIP05VerificationDisplay.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NIP05VerificationDisplay.kt @@ -242,8 +242,22 @@ fun DisplayStatus( } } + // Buzz sets a plain emoji with a 2-element `["emoji", "🎉"]` tag instead of inlining it in the + // text; lead with it the way Buzz's own client does. NIP-30 custom emoji are handled above. + val text = + remember(event) { + val emoji = event.statusEmoji() + if (emoji == null) { + event.content + } else if (event.content.isBlank()) { + emoji + } else { + "$emoji ${event.content}" + } + } + DisplayStatusInner( - event.content, + text, event.dTag(), event.firstTaggedUrl()?.ifBlank { null }, event.firstTaggedAddress(), diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt index 451c3a7095..328f4855ef 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt @@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_attest_before_label import com.vitorpamplona.amethyst.commons.resources.buzz_attest_change_agent import com.vitorpamplona.amethyst.commons.resources.buzz_attest_conditions_hint import com.vitorpamplona.amethyst.commons.resources.buzz_attest_copy_tag +import com.vitorpamplona.amethyst.commons.resources.buzz_attest_expired import com.vitorpamplona.amethyst.commons.resources.buzz_attest_form_desc import com.vitorpamplona.amethyst.commons.resources.buzz_attest_generate import com.vitorpamplona.amethyst.commons.resources.buzz_attest_grants_prefix @@ -85,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_attest_hold_desc import com.vitorpamplona.amethyst.commons.resources.buzz_attest_hold_title import com.vitorpamplona.amethyst.commons.resources.buzz_attest_holding import com.vitorpamplona.amethyst.commons.resources.buzz_attest_kind_label +import com.vitorpamplona.amethyst.commons.resources.buzz_attest_not_yet_valid import com.vitorpamplona.amethyst.commons.resources.buzz_attest_readonly_desc import com.vitorpamplona.amethyst.commons.resources.buzz_attest_readonly_title import com.vitorpamplona.amethyst.commons.resources.buzz_attest_remove @@ -104,6 +106,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.delay @@ -122,7 +125,7 @@ private val KIND_OPTIONS = DropdownOption("9", "9 · Group chat message"), DropdownOption("1111", "1111 · Comment"), DropdownOption("30023", "30023 · Long-form article"), - DropdownOption("40002", "40002 · Buzz minichat message"), + DropdownOption("40002", "40002 · Buzz stream message (legacy)"), ) private val KIND_LABELS = KIND_OPTIONS.associate { it.value to it.label } @@ -208,6 +211,17 @@ private fun HoldAttestationSection( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + // Buzz checks the time clauses against each AUTH, so a held credential outside them + // is silently worthless; say so rather than keep showing it as working. + val now = TimeUtils.now() + if (!mine.isValidAt(now)) { + val expired = mine.validUntil()?.let { it < now } == true + Text( + text = stringRes(if (expired) Res.string.buzz_attest_expired else Res.string.buzz_attest_not_yet_valid), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } OutlinedButton(onClick = { attestation.clear() }) { Text(stringRes(Res.string.buzz_attest_remove)) } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentConsoleScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentConsoleScreen.kt index e2110fda0e..a20b9ae366 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentConsoleScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentConsoleScreen.kt @@ -231,8 +231,10 @@ private fun TokenBreakdown(totals: TokenTotals) { MetaRow("Input tokens", formatCount(totals.inputTokens)) MetaRow("Output tokens", formatCount(totals.outputTokens)) MetaRow("Total tokens", formatCount(totals.totalTokens)) - if (totals.cacheReadTokens > 0) MetaRow("Cache read", formatCount(totals.cacheReadTokens)) - if (totals.cacheWriteTokens > 0) MetaRow("Cache write", formatCount(totals.cacheWriteTokens)) + // Null = no turn reported the component. NIP-AM forbids reading that as zero, so show a dash + // rather than hide the row (which would look the same as a reported 0). + MetaRow("Cache read", totals.cacheReadTokens?.let(::formatCount) ?: "—") + MetaRow("Cache write", totals.cacheWriteTokens?.let(::formatCount) ?: "—") } @Composable diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentPersonaEditScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentPersonaEditScreen.kt index cb87858fa0..18a598f9c0 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentPersonaEditScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentPersonaEditScreen.kt @@ -41,6 +41,8 @@ import com.vitorpamplona.amethyst.commons.buzz.ui.DropdownOption import com.vitorpamplona.amethyst.commons.buzz.ui.EditableSuggestDropdown import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.buzz_persona_avatar +import com.vitorpamplona.amethyst.commons.resources.buzz_persona_description +import com.vitorpamplona.amethyst.commons.resources.buzz_persona_description_help import com.vitorpamplona.amethyst.commons.resources.buzz_persona_display_name import com.vitorpamplona.amethyst.commons.resources.buzz_persona_edit_title import com.vitorpamplona.amethyst.commons.resources.buzz_persona_model @@ -49,12 +51,17 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_persona_provider import com.vitorpamplona.amethyst.commons.resources.buzz_persona_publish import com.vitorpamplona.amethyst.commons.resources.buzz_persona_publishing import com.vitorpamplona.amethyst.commons.resources.buzz_persona_runtime +import com.vitorpamplona.amethyst.commons.resources.buzz_persona_shared +import com.vitorpamplona.amethyst.commons.resources.buzz_persona_shared_help import com.vitorpamplona.amethyst.commons.resources.buzz_persona_slug import com.vitorpamplona.amethyst.commons.resources.buzz_persona_slug_help import com.vitorpamplona.amethyst.commons.resources.buzz_persona_system_prompt +import com.vitorpamplona.amethyst.commons.resources.buzz_persona_thread_sessions +import com.vitorpamplona.amethyst.commons.resources.buzz_persona_thread_sessions_help import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.note.creators.contentWarning.SettingSwitchItem import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel @@ -161,6 +168,28 @@ fun AgentPersonaEditScreen( singleLine = true, modifier = Modifier.fillMaxWidth(), ) + OutlinedTextField( + value = state.description, + onValueChange = viewModel::onDescriptionChange, + label = { Text(stringRes(Res.string.buzz_persona_description)) }, + supportingText = { Text(stringRes(Res.string.buzz_persona_description_help)) }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + SettingSwitchItem( + modifier = Modifier.fillMaxWidth(), + checked = state.shared, + onCheckedChange = viewModel::onSharedChange, + title = Res.string.buzz_persona_shared, + description = Res.string.buzz_persona_shared_help, + ) + SettingSwitchItem( + modifier = Modifier.fillMaxWidth(), + checked = state.threadSessions, + onCheckedChange = viewModel::onThreadSessionsChange, + title = Res.string.buzz_persona_thread_sessions, + description = Res.string.buzz_persona_thread_sessions_help, + ) state.error?.let { Text( diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentPersonaEditViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentPersonaEditViewModel.kt index b8aeca7b13..2d6fe22c84 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentPersonaEditViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/AgentPersonaEditViewModel.kt @@ -44,18 +44,20 @@ import kotlin.coroutines.cancellation.CancellationException * owner and addressed by `(owner, 30175, slug)`. * * On edit, the existing [PersonaEvent] is loaded from [LocalCache] so fields the form does - * not expose (avatar, name pool, respond-to allowlist, parallelism) are preserved rather - * than dropped on the next publish. The slug (the `d` tag) is immutable once chosen — a - * different slug is a different persona. + * not expose (ACP command, name pool, respond-to allowlist, parallelism) are preserved rather + * than dropped on the next publish, and the new head is stamped after the one it replaces. The + * slug (the `d` tag) is immutable once chosen — a different slug is a different persona. * * Published to the workspace's Buzz-dialect relays (falling back to the owner's outbox when - * none are known), so the workspace and other members see it. + * none are known). The relay serves it to other members only when [FormState.shared] is on + * (the `["shared","true"]` tag); otherwise only the owner's own devices can read it. */ class AgentPersonaEditViewModel : ViewModel() { @Volatile private var account: Account? = null /** Set on edit; preserves fields the form doesn't surface. Null for a new persona. */ private var existing: PersonaContent? = null + private var existingCreatedAt: Long? = null private var editingSlug: String? = null private val _state = MutableStateFlow(FormState()) @@ -74,6 +76,7 @@ class AgentPersonaEditViewModel : ViewModel() { val event = note?.event as? PersonaEvent ?: return val content = event.personaOrNull() ?: return existing = content + existingCreatedAt = event.createdAt editingSlug = slug _state.value = FormState( @@ -85,6 +88,9 @@ class AgentPersonaEditViewModel : ViewModel() { runtime = content.runtime.orEmpty(), provider = content.provider.orEmpty(), avatarUrl = content.avatarUrl.orEmpty(), + description = content.description.orEmpty(), + shared = event.isShared(), + threadSessions = content.isThreadSessionPolicy(), ) } @@ -102,6 +108,12 @@ class AgentPersonaEditViewModel : ViewModel() { fun onAvatarUrlChange(v: String) = _state.update { it.copy(avatarUrl = v.trim(), error = null) } + fun onDescriptionChange(v: String) = _state.update { it.copy(description = v.take(PersonaContent.DESCRIPTION_MAX_CHARS), error = null) } + + fun onSharedChange(v: Boolean) = _state.update { it.copy(shared = v, error = null) } + + fun onThreadSessionsChange(v: Boolean) = _state.update { it.copy(threadSessions = v, error = null) } + /** * Validates, builds a [PersonaEvent] (preserving unexposed fields on edit), signs and * publishes it to the Buzz relays. Calls [onDone] on the main thread on success. @@ -127,14 +139,19 @@ class AgentPersonaEditViewModel : ViewModel() { val content = base.copy( displayName = current.displayName.trim(), - systemPrompt = current.systemPrompt.blankToNull(), + // Buzz writes an empty prompt as "" (not absent) to keep the content hash + // stable, so a blank field keeps whichever form the persona already had. + systemPrompt = current.systemPrompt.blankToNull() ?: base.systemPrompt?.takeIf { it.isBlank() }, model = current.model.blankToNull(), runtime = current.runtime.blankToNull(), provider = current.provider.blankToNull(), avatarUrl = current.avatarUrl.blankToNull(), + description = current.description.blankToNull(), + // Absent is the default `channel` policy; only `thread` is written. + sessionPolicy = if (current.threadSessions) PersonaContent.SESSION_POLICY_THREAD else null, ) - val template = PersonaEvent.build(content, slug) + val template = PersonaEvent.build(content, slug, shared = current.shared, priorHeadCreatedAt = existingCreatedAt) account.signAndSendPrivatelyOrBroadcast(template) { BuzzRelayDialect.flow.value .toList() @@ -161,6 +178,12 @@ class AgentPersonaEditViewModel : ViewModel() { val runtime: String = "", val provider: String = "", val avatarUrl: String = "", + /** Short public description, at most [PersonaContent.DESCRIPTION_MAX_CHARS] characters. */ + val description: String = "", + /** Publish to the community catalog (`["shared","true"]`) instead of owner-only. */ + val shared: Boolean = false, + /** A separate ACP conversation per channel thread (`session_policy: "thread"`). */ + val threadSessions: Boolean = false, val isSaving: Boolean = false, val error: String? = null, ) { diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt index ce7eeeb538..36c2df5159 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt @@ -53,25 +53,32 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCanvasWriter import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_body_label +import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_conflict import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_edit import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_empty +import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_head_in_future import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_save +import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_save_failed import com.vitorpamplona.amethyst.commons.resources.buzz_canvas_title import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler import com.vitorpamplona.amethyst.commons.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.buzz.stream.CanvasEvent import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch @@ -87,8 +94,10 @@ import kotlinx.coroutines.withContext * re-composes off `canvasUpdates` when a newer revision lands. * * The edit FAB flips into a plain markdown editor; saving publishes a fresh [CanvasEvent] to the - * channel's host [relayUrl] (last-write-wins on the relay too), and the consume path folds the new - * revision back into [BuzzWorkspaceStates] so the view updates without a manual refresh. + * channel's host [relayUrl] through [BuzzCanvasWriter], which asserts the revision the editor was + * opened on (`expected-revision`) so a concurrent edit is refused by the relay instead of silently + * overwritten. An accepted revision is folded back into [BuzzWorkspaceStates] so the view updates + * without a manual refresh; a conflict keeps the editor open with the draft intact. */ @Composable fun BuzzCanvasScreen( @@ -119,15 +128,17 @@ fun BuzzCanvasScreen( // that Buzz's own client would never show. val canEdit = channel?.event?.isBuzzDm() != true - var editing by remember { mutableStateOf(false) } + // The head the editor was opened on, snapshotted at edit start (not the live head): a newer + // revision landing while the editor is open must surface as a conflict, not be overwritten. + var editBase by remember { mutableStateOf(null) } - if (editing) { + editBase?.let { base -> CanvasEditor( channelId = channelId, relayUrl = relayUrl, - initial = content.orEmpty(), + base = base, accountViewModel = accountViewModel, - onClose = { editing = false }, + onClose = { editBase = null }, ) return } @@ -160,7 +171,10 @@ fun BuzzCanvasScreen( }, floatingActionButton = { if (canEdit) { - FloatingActionButton(onClick = { editing = true }, shape = CircleShape) { + FloatingActionButton( + onClick = { editBase = CanvasEditBase(content.orEmpty(), canvas?.idHex, canvas?.createdAt()) }, + shape = CircleShape, + ) { Icon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(Res.string.buzz_canvas_edit)) } } @@ -207,17 +221,24 @@ fun BuzzCanvasScreen( } } +/** The canvas revision an edit started from: its text, and its id/created_at (null when there was no canvas). */ +private class CanvasEditBase( + val content: String, + val headId: HexKey?, + val headCreatedAt: Long?, +) + /** The markdown editor: a full-height text field for the canvas body with a Save action. */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun CanvasEditor( channelId: String, relayUrl: String, - initial: String, + base: CanvasEditBase, accountViewModel: AccountViewModel, onClose: () -> Unit, ) { - var text by remember { mutableStateOf(initial) } + var text by remember { mutableStateOf(base.content) } var saving by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() @@ -249,15 +270,41 @@ private fun CanvasEditor( error = null scope.launch { try { - withContext(Dispatchers.IO) { - accountViewModel.account.signAndSendPrivatelyOrBroadcast( - CanvasEvent.build(channelId, text), - ) { listOf(relay) } + val outcome = + withContext(Dispatchers.IO) { + BuzzCanvasWriter.save( + account = accountViewModel.account, + relay = relay, + channelId = channelId, + markdown = text, + headId = base.headId, + headCreatedAt = base.headCreatedAt, + ) + } + when (outcome) { + is BuzzCanvasWriter.Outcome.Saved -> { + onClose() + } + + is BuzzCanvasWriter.Outcome.Conflict -> { + saving = false + error = loadStringRes(Res.string.buzz_canvas_conflict) + } + + BuzzCanvasWriter.Outcome.HeadTooFarInFuture -> { + saving = false + error = loadStringRes(Res.string.buzz_canvas_head_in_future) + } + + is BuzzCanvasWriter.Outcome.Failed -> { + saving = false + error = loadStringRes(Res.string.buzz_canvas_save_failed, outcome.message) + } } - onClose() } catch (e: Exception) { + if (e is CancellationException) throw e saving = false - error = "Failed to save: ${e.message ?: e::class.simpleName}" + error = loadStringRes(Res.string.buzz_canvas_save_failed, e.message ?: e::class.simpleName ?: "") } } }, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderBuzzSystemMessage.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderBuzzSystemMessage.kt index 15407c8a23..38d4f404d0 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderBuzzSystemMessage.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderBuzzSystemMessage.kt @@ -32,7 +32,9 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserName import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.buzz_system_admin_kick import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_archived +import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_auto_archived import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_created import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_deleted import com.vitorpamplona.amethyst.commons.resources.buzz_system_channel_unarchived @@ -179,6 +181,11 @@ fun buzzSystemMessageText( SystemMessagePayload.DM_CREATED -> stringRes(Res.string.buzz_system_dm_created, actor) + // Relay-authored with no actor: the administrator who acted is deliberately not named. + SystemMessagePayload.ADMIN_KICK -> stringRes(Res.string.buzz_system_admin_kick, target) + + SystemMessagePayload.CHANNEL_AUTO_ARCHIVED -> stringRes(Res.string.buzz_system_channel_auto_archived) + else -> stringRes(Res.string.buzz_system_unknown, actor, payload.type.replace('_', ' ')) } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt index 8fc523e52a..ddfd66bd87 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt @@ -218,7 +218,7 @@ private fun RelayGroupMembers( if (iCanModerate) { AddMemberBar( isAlreadyIn = { channel.membershipOf(it) != RelayGroupMembership.NONE }, - onAdd = { accountViewModel.putRelayGroupUser(channel, it, emptyList()) }, + onAdd = { accountViewModel.addRelayGroupUser(channel, it) }, accountViewModel = accountViewModel, ) } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt index 04fd5e1192..a950d55485 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt @@ -1876,6 +1876,11 @@ class AccountViewModel( pubkey: HexKey, ) = launchSigner { account.relayGroups.removeRelayGroupUser(channel, pubkey) } + fun addRelayGroupUser( + channel: RelayGroupChannel, + pubkey: HexKey, + ) = launchSigner { account.relayGroups.addRelayGroupUser(channel, pubkey) } + fun putRelayGroupUser( channel: RelayGroupChannel, pubkey: HexKey, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md index e1d2491c59..504e08b0f5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md @@ -76,7 +76,7 @@ hand-transcribed schemas: Each feature is a sub-package following the standard Quartz per-NIP shape (`Event` with a `KIND` companion, a `tags/` folder of tag classes, `TagArrayBuilderExt` write-DSL verbs, and `TagArrayExt` read accessors), mirroring e.g. `nip88Polls`. There is one event -class per kind, ~78 in total, each registered in `utils/EventFactory.kt` (except the +class per kind, ~84 in total, each registered in `utils/EventFactory.kt` (except the conflicts below). | Package | Buzz NIP | Kind(s) | @@ -86,15 +86,61 @@ conflicts below). | `aoObserver` | NIP-AO | 24200 | | `aeEngrams` | NIP-AE | 30174 | | `apPersonas` / `teams` / `managedAgents` / `agentProfiles` | NIP-AP + agent identity | 30175 / 30176 / 30177 / 10100 | +| `teamCatalog` | NIP-AP team catalog (shared projection with embedded members) | 30178 | | `erReminders` / `plPushLease` / `dvDmVisibility` / `wpWorkspaceProfile` | NIP-ER/PL/DV/WP | 30300 / 30350 / 30622 / 9033 | -| `iaIdentityArchival` / `cwChannelWindow` | NIP-IA / NIP-CW | 9035/9036/8002/8003/13535 / 39005/39006 | +| `iaIdentityArchival` / `cwChannelWindow` | NIP-IA / NIP-CW | 9035/9036/8002/8003/13535 / 39005/39006/39007 | | `relayAdmin` / `moderation` | admin + moderation | 9030-9032 / 9040-9044, 42000 | | `stream` / `stream.sidecars` | stream messaging | 40002-40008, 40099, 40100 / 40901, 40902 | | `dm` / `jobs` | DMs / agent jobs | 41001, 41010-41012 / 43001-43006 | | `workflow` / `forum` / `notifications` | workflow + social | 30620, 46001-46031 / 45001-45003 / 44100, 44101 | -| `presence` / `huddles` / `pairing` / `audit` / `media` | presence + misc | 20001, 20002 / 24810, 48100-48106 / 24134 / 48001 / 49001 | +| `arArtifacts` | NIP-AR channel artifacts (revision chain by `prev`) | 45010, 45011 (relay-signed removal) | +| `mpProjects` | NIP-MP multi-repo projects (global, not `h`-scoped) | 30621 | +| `presence` / `huddles` / `pairing` / `audit` / `media` | presence + misc | 20001, 20002 / 24810, 48100-48106 (48104 = relay-synthesized liveness) / 24134 / 48001 / 49001 | | `rsReadState` | NIP-RS | (helpers on `AppSpecificDataEvent`, kind 30078) | +Not modelled on purpose: **NIP-PMA `kind:30179`** (private managed agent) is only reserved upstream +— its spec says relays MUST reject it for now — and **NIP-FI** federated identity is an HTTP-upgrade +header, not an event. + +### Upstream sync + +Last reconciled against `block/buzz` `4ef23609b` (2026-09-29); the previous full sync was +`03fe19d6` (2026-07-21). Behaviour picked up in that pass, beyond the new kinds above: + +- **Read gates.** Personas (30175) and the team catalog (30178) are author-only unless the event + carries exactly `["shared","true"]` (`apPersonas/tags/SharedTag`); a malformed `shared` tag is + rejected at ingest. Persona content gained `acp_command`, `description` and `session_policy` + (upstream field order kept, so content bytes and hashes match), and a shared head publishes only a + portable harness alias (`PersonaContent.forSharedCatalog`). +- **NIP-OA time bounds** are enforced at admission against the AUTH event's `created_at` + (strictly, every clause, `kind=` ignored): `OwnerAttestation.verifyForAuthAt`. Owner keys and + signatures must be lowercase hex, and the `auth` tag exactly four elements. +- **NIP-10 threading** follows `buzz-core/src/nip10.rs`: markers need 4+ elements and a 64-hex id, + the last one wins, and a lone `root` marker is top-level (`threading/BuzzThreadMarkers`). A reply's + root must be derived with `buzzThreadRootForReplyTo` or the relay rejects it. +- **Channel messages are written as kind 9** in Buzz's tag shape (`stream/BuzzChatMessage`, mirroring + `build_message`): `h`, NIP-10 thread markers, `p` mentions, `broadcast` for a reply that also shows + in the channel. That is what Buzz's own clients write; kind 40002 is read-only now, kept so older + messages still render. `broadcast` means the same on either kind. +- **Edits (40003)** apply to any channel message kind. The relay stores them as-is; clients resolve + them: the newest edit by the message's author, or by the owner that author declares through a + verified NIP-OA `auth` tag on its kind-0 profile (`OwnerAttestation.verifiedOwnerOf`), wins. A + relay-signed message counts as written by its `actor` (else `p`). The edit's tags overlay the + original's (`stream/BuzzEditTagOverlay`): attachments only from the edit, custom emoji from the + edit when it has any, added `p` mentions merged. So an edit re-sends every attachment and emoji + the new text still uses. +- **Contextual identity names** (`identityNames/IdentityNamePolicy`, Buzz's portable v1 contract, + run against its vendored fixtures in `IdentityNamePolicyTest`): within one context (a channel's + members) a name only grows on a real collision: a person keeps it, an agent becomes `Alice’s Honey` + or `Honey (agent)`, and anyone still colliding gets an npub suffix. `commons/.../BuzzIdentityNames` + applies it per relay group; agents are authors with a 10100 profile or a verified NIP-OA owner. +- **Put-user (9000)** without a `role` tag is "no role change" on Buzz; a plain add sends none. +- **Compare-and-swap writes**: canvas (40100) and workflow definitions (30620) take + `["expected-revision", ]` and the relay answers `conflict:` on a stale head. A workflow's + `d` must be a UUID. +- A REQ that hits the relay's query deadline ends with `CLOSED "error: query timed out"`, which the + pool treats as transient rather than a structural refusal. + ### Kind conflicts — where a Buzz kind number is already owned Several Buzz kind numbers collide with an existing Amethyst/Nostr class. `EventFactory` @@ -221,7 +267,7 @@ a Buzz relay once a canvas has arrived. The **edit composer (40003)** is wired: `ChannelNewMessageViewModel` has a Buzz edit mode (`editBuzzMessage`/`clearBuzzEdit`) whose next send publishes a 40003 targeting the original (minimal, mirroring Buzz's `build_edit`); an "Edit" action gated to the user's -own 40002 messages threads to the composer through the shared chat feed via an optional +own messages (kind 9 on a Buzz relay, or legacy 40002) threads to the composer through the shared chat feed via an optional `onWantsToEditBuzz` callback (default-null, so no other chat surface is affected), and `EditFieldRow` shows an editing banner. This closes the render↔create loop (edit overlays already rendered). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/agentProfiles/AgentProfileEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/agentProfiles/AgentProfileEvent.kt index 2351d4a9f9..67048c4d6b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/agentProfiles/AgentProfileEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/agentProfiles/AgentProfileEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.buzz.agentProfiles import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -75,6 +76,9 @@ class AgentProfileEvent( companion object { const val KIND = 10100 + /** The replaceable address of [pubKey]'s agent profile (`10100::`). */ + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "") + fun build( profile: AgentProfileContent, createdAt: Long = TimeUtils.now(), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/AgentTurnMetricPayload.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/AgentTurnMetricPayload.kt index 63783ca576..532eb36317 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/AgentTurnMetricPayload.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/AgentTurnMetricPayload.kt @@ -36,6 +36,11 @@ import kotlinx.serialization.json.Json * on purpose — `null` means "the harness did not report this", not zero. Unknown JSON * fields are ignored for forward compatibility, and an unrecognized [stopReason] is * kept verbatim (map it with [stopReasonOrUnknown]) rather than dropping the payload. + * + * [pricingIdentity] is optional but not nullable on the wire: it is present only when the + * publisher could prove the billing authority and the actually-requested model, and its absence + * means "price unknown" — never infer a price from [model], which stays the configured/session + * model. */ @Serializable data class AgentTurnMetricPayload( @@ -50,6 +55,7 @@ data class AgentTurnMetricPayload( val cumulative: TokenCounts? = null, val deltaReliable: Boolean = true, val stopReason: String? = null, + val pricingIdentity: PricingIdentity? = null, ) { /** Maps [stopReason] to a [StopReason], treating any unrecognized value as [StopReason.UNKNOWN]. */ fun stopReasonOrUnknown(): StopReason? = stopReason?.let { StopReason.fromWire(it) } @@ -79,9 +85,44 @@ data class AgentTurnMetricPayload( } } +/** + * The billing identity of a turn (NIP-AM `pricingIdentity`), mirroring `PricingIdentity` in + * `agent_turn_metric.rs`. When present, [authority] and [model] are required strings; [cacheClass] + * is omitted (never null) when not applicable. + * + * Pricing lookup is an exact string match on `(authority, model)`. The Rust parser does not + * restrict [authority] to the registered set, so neither does this one — check + * [isRegisteredAuthority] before trusting it for a price. + */ +@Serializable +data class PricingIdentity( + /** The billing namespace: an exact lowercase hostname such as `api.anthropic.com` (not the transport provider). */ + val authority: String, + /** The actually-requested billable model id, as resolved at request time. */ + val model: String, + /** The cache-write class (e.g. `ephemeral`), when applicable. */ + val cacheClass: String? = null, +) { + /** True when [authority] is one of the NIP's registered billing namespaces. */ + fun isRegisteredAuthority(): Boolean = authority in REGISTERED_AUTHORITIES + + companion object { + const val AUTHORITY_ANTHROPIC = "api.anthropic.com" + const val AUTHORITY_OPENAI = "api.openai.com" + const val AUTHORITY_OPENROUTER = "openrouter.ai" + + /** The registered `authority` values; the set only grows by amendment to NIP-AM. */ + val REGISTERED_AUTHORITIES = setOf(AUTHORITY_ANTHROPIC, AUTHORITY_OPENAI, AUTHORITY_OPENROUTER) + } +} + /** * Token usage counts. All fields nullable — `null` distinguishes "not reported" from * zero. Mirrors `TokenCounts` in `agent_turn_metric.rs`. + * + * The cache components ([cacheReadTokens], [cacheWriteTokens]) are informational subsets of + * [inputTokens]. NIP-AM has publishers keep an explicit zero and *omit* a component they cannot + * see, so an absent cache field is "unknown" — never sum it as zero. */ @Serializable data class TokenCounts( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaContent.kt index ae3b243f87..750ddb11f5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaContent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaContent.kt @@ -47,6 +47,9 @@ import kotlinx.serialization.json.Json data class PersonaContent( @SerialName("display_name") val displayName: String, @SerialName("system_prompt") val systemPrompt: String? = null, + // The ACP harness command the agent runs under. On a shared (catalog) head it carries only a + // portable alias - see [forSharedCatalog]. + @SerialName("acp_command") val acpCommand: String? = null, @SerialName("avatar_url") val avatarUrl: String? = null, val runtime: String? = null, val model: String? = null, @@ -58,9 +61,34 @@ data class PersonaContent( @SerialName("respond_to") val respondTo: String? = null, @EncodeDefault(EncodeDefault.Mode.NEVER) @SerialName("respond_to_allowlist") val respondToAllowlist: List = emptyList(), val parallelism: Int? = null, + // Appended after the original fields upstream so a record without them serializes + // byte-identically to the pre-revision era. [description] is short PUBLIC display text + // (max [DESCRIPTION_MAX_CHARS]); [sessionPolicy] is omitted for the default `channel`. + val description: String? = null, + @SerialName("session_policy") val sessionPolicy: String? = null, ) { fun encodeToJson(): String = JSON.encodeToString(this) + /** True when the agent keeps a separate ACP conversation per channel thread. */ + fun isThreadSessionPolicy() = sessionPolicy == SESSION_POLICY_THREAD + + /** + * The projection Buzz publishes on a shared (`["shared","true"]`) head: a catalog reader on + * another machine can only run a portable harness alias, so an unset command becomes the + * explicit stock [DEFAULT_ACP_COMMAND] (distinguishing a reset from an omitted override) and + * a machine-local command is dropped. Mirrors `build_persona_event` in Buzz's + * `desktop/src-tauri/src/managed_agents/persona_events.rs`. + */ + fun forSharedCatalog(): PersonaContent = + copy( + acpCommand = + when { + acpCommand == null -> DEFAULT_ACP_COMMAND + isPortableAcpCommand(acpCommand) -> acpCommand + else -> null + }, + ) + companion object { val JSON = Json { @@ -70,5 +98,27 @@ data class PersonaContent( } fun decodeFromJson(json: String): PersonaContent = JSON.decodeFromString(json) + + /** Buzz's stock ACP harness (`DEFAULT_ACP_COMMAND` in `managed_agents/types.rs`). */ + const val DEFAULT_ACP_COMMAND = "buzz-acp" + + /** The only non-default [sessionPolicy] value; anything else reads as `channel`. */ + const val SESSION_POLICY_THREAD = "thread" + + /** Upper bound Buzz puts on [description]. */ + const val DESCRIPTION_MAX_CHARS = 280 + + /** + * A harness command another machine can run: the stock `buzz-acp`, or `buzz--acp` + * with `` of ASCII letters, digits, `-` or `_`, at most 255 bytes in all. Mirrors + * `is_portable_acp_command` in Buzz's `managed_agents/backend.rs`. + */ + fun isPortableAcpCommand(command: String): Boolean { + if (command == DEFAULT_ACP_COMMAND) return true + if (command.length > 255 || !command.startsWith("buzz-") || !command.endsWith("-acp")) return false + if (command.length <= "buzz-".length + "-acp".length) return false + val name = command.substring("buzz-".length, command.length - "-acp".length) + return name.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '-' || it == '_' } + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaEvent.kt index 5ca7506c8e..f0af957616 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.buzz.apPersonas import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.buzz.apPersonas.tags.SharedTag import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -39,6 +40,11 @@ import kotlinx.coroutines.CancellationException * * The `content` is a plaintext JSON [PersonaContent]. Ground truth for the content projection * is `desktop/src-tauri/src/managed_agents/persona_events.rs`. + * + * Read access is **author-only unless shared**: the relay serves a persona to anyone but its + * author only when it carries exactly `["shared","true"]` ([SharedTag]); otherwise it is + * silently withheld from foreign REQs, COUNTs and id lookups. Device sync reads + * `authors:[self]`, so the owner always sees their own. */ @Immutable class PersonaEvent( @@ -63,6 +69,9 @@ class PersonaEvent( /** The persona slug — the `d` tag. */ fun slug() = dTag() + /** True when the persona is published to the community catalog (`["shared","true"]`). */ + fun isShared() = SharedTag.isShared(tags) + /** Parses the persona configuration, or throws if the JSON is malformed. */ fun persona(): PersonaContent = PersonaContent.decodeFromJson(content) @@ -77,14 +86,31 @@ class PersonaEvent( companion object { const val KIND = 30175 + /** + * Builds a persona the way Buzz's `build_persona_event` does: a `d` slug tag, plus + * `["shared","true"]` when [shared]. A shared head publishes the portable catalog + * projection of its content ([PersonaContent.forSharedCatalog]). + * + * An edit should pass [priorHeadCreatedAt] (the replaced head's `created_at`) so the new + * head sorts after it even when this clock lags (`monotonic_created_at` upstream). + */ fun build( persona: PersonaContent, slug: String, - createdAt: Long = TimeUtils.now(), + shared: Boolean = false, + priorHeadCreatedAt: Long? = null, + createdAt: Long = monotonicCreatedAt(priorHeadCreatedAt), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, persona.encodeToJson(), createdAt) { + ) = eventTemplate(KIND, (if (shared) persona.forSharedCatalog() else persona).encodeToJson(), createdAt) { dTag(slug) + if (shared) addUnique(SharedTag.assemble()) initializer() } + + /** `max(now, prior + 1)`: a replacement head never sorts behind the one it replaces. */ + fun monotonicCreatedAt(priorHeadCreatedAt: Long?): Long { + val now = TimeUtils.now() + return if (priorHeadCreatedAt == null) now else maxOf(now, priorHeadCreatedAt + 1) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/tags/SharedTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/tags/SharedTag.kt new file mode 100644 index 0000000000..a430548262 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/tags/SharedTag.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.apPersonas.tags + +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +/** + * The catalog opt-in on Buzz's shared-gated kinds (the persona `kind:30175` and the team + * catalog `kind:30178`): exactly `["shared","true"]`. Without it the relay serves the event + * to its author only; with it, to the whole community. It is a tag rather than a content field + * so toggling it leaves the content bytes (and the persona content hash) unchanged. + * + * The relay refuses any other `shared` shape at ingest (a different value, a third element, or + * a second `shared` tag), and its read gate fails closed on them. Ground truth: + * `event_is_shared` in Buzz's `buzz-core/src/kind.rs` and `validate_shared_tag` in + * `buzz-relay/src/handlers/ingest.rs`. + */ +object SharedTag { + const val TAG_NAME = "shared" + const val VALUE = "true" + + fun assemble() = arrayOf(TAG_NAME, VALUE) + + /** + * True only when [tags] carry exactly one `shared` tag and it is exactly `["shared","true"]`; + * any malformed or duplicated `shared` tag reads as not shared, like the relay's read gate. + */ + fun isShared(tags: TagArray): Boolean { + var count = 0 + for (tag in tags) { + if (tag.isEmpty() || tag[0] != TAG_NAME) continue + if (tag.size != 2 || tag[1] != VALUE) return false + count++ + } + return count == 1 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEnvelope.kt new file mode 100644 index 0000000000..b09a5e5e4e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEnvelope.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.OpTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.PrevTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.RootTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TitleTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TypeTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.VersionTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag + +/** + * The authoritative NIP-AR envelope of a revision, independent of any client's content + * schema. Mirrors `ArtifactEnvelope` in Buzz's `buzz-core/src/artifact.rs`. + */ +@Immutable +data class ArtifactEnvelope( + /** Community-local stable identity — the `d` UUID. */ + val id: String, + /** Home channel — the `h` UUID. */ + val home: String, + /** Immutable namespaced type name. */ + val type: String, + val op: ArtifactOp, + /** The revision this one replaces; null exactly on `create`. */ + val prev: HexKey?, + /** Optional conversation anchor. */ + val root: HexKey?, +) + +/** The outcome of [ArtifactValidator.validate]. */ +sealed interface ArtifactValidation { + data class Valid( + val envelope: ArtifactEnvelope, + ) : ArtifactValidation + + /** [reason] is upstream's rejection message, verbatim. */ + data class Invalid( + val reason: String, + ) : ArtifactValidation +} + +/** + * NIP-AR envelope validation — a line-for-line port of `validate` in Buzz's + * `buzz-core/src/artifact.rs`, which the relay runs on every `kind:45010` before + * authorization. Payloads (content and client tags) stay opaque; NIP-OA `auth` tag + * verification is left to the relay, as upstream does. + */ +object ArtifactValidator { + /** Maximum artifact tags, including the envelope. */ + const val MAX_TAGS = 256 + + /** Maximum UTF-8 bytes in a tag name. */ + const val MAX_TAG_NAME_BYTES = 128 + + /** Maximum UTF-8 bytes in each tag element. */ + const val MAX_TAG_VALUE_BYTES = 4096 + + /** Maximum UTF-8 bytes across all tag elements. */ + const val MAX_TAG_BYTES = 65536 + + /** The NIP-OA attestation tag, the only non-envelope tag a delete may carry. */ + const val AUTH_TAG_NAME = "auth" + + /** Envelope tag names: each at most once, with exactly two elements. */ + val ENVELOPE_TAG_NAMES = + setOf( + VersionTag.TAG_NAME, + DTag.TAG_NAME, + GroupIdTag.TAG_NAME, + TypeTag.TAG_NAME, + TitleTag.TAG_NAME, + OpTag.TAG_NAME, + RootTag.TAG_NAME, + PrevTag.TAG_NAME, + ) + + fun validate( + tags: TagArray, + content: String, + ): ArtifactValidation { + if (tags.size > MAX_TAGS) return invalid("too many tags") + + val fields = HashMap() + var bytes = 0 + for (tag in tags) { + if (tag.isEmpty()) return invalid("empty tag") + val name = tag[0] + if (name.utf8Size() > MAX_TAG_NAME_BYTES) return invalid("tag name too long") + for (value in tag) { + val size = value.utf8Size() + bytes += size + if (size > MAX_TAG_VALUE_BYTES) return invalid("tag value too long") + } + if (name in ENVELOPE_TAG_NAMES && (tag.size != 2 || fields.put(name, tag[1]) != null)) { + return invalid("envelope tags must occur once with exactly two elements") + } + } + if (bytes > MAX_TAG_BYTES) return invalid("total tag bytes exceeded") + + val version = fields[VersionTag.TAG_NAME] ?: return missing() + if (version != VersionTag.CURRENT) return invalid("unsupported artifact envelope version") + + val id = fields[DTag.TAG_NAME] ?: return missing() + if (!ArtifactIds.isCanonicalUuid(id)) return invalidUuid(id) + val home = fields[GroupIdTag.TAG_NAME] ?: return missing() + if (!ArtifactIds.isCanonicalUuid(home)) return invalidUuid(home) + + val type = fields[TypeTag.TAG_NAME] ?: return missing() + if (!TypeTag.isValid(type)) return invalid("invalid namespaced artifact type") + + val opCode = fields[OpTag.TAG_NAME] ?: return missing() + val op = ArtifactOp.parse(opCode) ?: return invalid("invalid artifact operation") + + val prev = fields[PrevTag.TAG_NAME] + if (prev != null && !ArtifactIds.isEventId(prev)) return invalid("event ID must be 64 lowercase hex characters") + if ((op == ArtifactOp.CREATE) != (prev == null)) return invalid("prev required exactly on non-create revisions") + + val root = fields[RootTag.TAG_NAME] + if (root != null && !ArtifactIds.isEventId(root)) return invalid("event ID must be 64 lowercase hex characters") + + if (op == ArtifactOp.DELETE) { + if (TitleTag.TAG_NAME in fields || content.isNotEmpty()) { + return invalid("delete must omit title and have empty content") + } + if (tags.fastAny { it[0] !in ENVELOPE_TAG_NAMES && it[0] != AUTH_TAG_NAME }) { + return invalid("delete allows only envelope and verified auth tags") + } + } else { + val title = fields[TitleTag.TAG_NAME] ?: return missing() + if (!TitleTag.isValid(title)) return invalid("title must be nonblank and at most 512 UTF-8 bytes") + } + + return ArtifactValidation.Valid(ArtifactEnvelope(id, home, type, op, prev, root)) + } + + private fun String.utf8Size() = encodeToByteArray().size + + private fun invalid(reason: String) = ArtifactValidation.Invalid(reason) + + private fun missing() = invalid("missing required envelope tag") + + // `canonical_uuid` distinguishes an unparseable value from a non-canonical one. + private fun invalidUuid(value: String) = + if (UUID_ANY_FORM.matches(value)) { + invalid("UUID must be canonical lowercase and non-nil") + } else { + invalid("invalid UUID") + } + + private val UUID_ANY_FORM = Regex("^(urn:uuid:)?\\{?[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}}?$") +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEvent.kt new file mode 100644 index 0000000000..daefc1e1a8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEvent.kt @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TitleTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TypeTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A Buzz NIP-AR artifact revision (`kind:45010`): one complete snapshot of an editable, + * channel-homed record (a task, a project card, …). User-signed and **regular**, not + * addressable — identity is `(community, d)` and the relay advances a per-identity head + * atomically, so every non-create revision must name the current head in `prev` + * (compare-and-swap; timestamps never pick a winner, see [ArtifactHeadResolver]). + * + * Envelope tags, each exactly once with two elements: `ar`=`1`, `d` (lowercase UUID), `h` + * (home channel UUID), `type` (namespaced, immutable), `title` (absent on delete), `op`, + * optional `root` (conversation anchor), and `prev` (required iff `op != create`). Any other + * tag is a client-defined, relay-matchable annotation ([clientTags]); `content` is opaque + * and empty on delete. At most 256 tags. + * + * Ground truth: `validate` in Buzz's `buzz-core/src/artifact.rs` (mirrored by + * [ArtifactValidator]), `accept_artifact` in `buzz-db/src/store/artifact.rs`, and + * `docs/nips/NIP-AR.md`. Buzz ships no SDK builder for this kind yet; [build] enforces the + * same envelope the relay validates. + * + * Reads: a plain REQ `{"kinds":[45010,45011],"#h":[channel]}` replays a channel's revisions + * and removals. Current-state and history views (and any multi-letter tag predicate such as + * `#project`) go through the relay's explicit artifact query (`{"artifact":"current"|"history", + * …}` on the NIP-98 HTTP `/query`); the relay rejects a REQ that mixes artifact kinds with + * multi-letter tag filters rather than silently dropping the predicate. + */ +@Immutable +class ArtifactEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The artifact's stable UUID — the `d` tag. */ + fun artifactId() = tags.artifactId() + + /** The home channel UUID — the `h` tag. */ + fun home() = tags.artifactHome() + + /** The namespaced content type — the `type` tag. */ + fun type() = tags.artifactType() + + /** The display title — the `title` tag; null on a delete. */ + fun title() = tags.artifactTitle() + + /** The lifecycle operation — the `op` tag. */ + fun op() = tags.artifactOp() + + /** The conversation anchor — the `root` tag. */ + fun root() = tags.artifactRoot() + + /** The replaced revision — the `prev` tag; null on a create. */ + fun prev() = tags.artifactPrev() + + /** True for a soft-delete revision. */ + fun isDelete() = op() == ArtifactOp.DELETE + + /** The client-defined annotation tags an editor must carry forward. */ + fun clientTags() = tags.artifactClientTags() + + /** Runs the relay's envelope validation over this revision. */ + fun validate(): ArtifactValidation = ArtifactValidator.validate(tags, content) + + /** The validated envelope, or null when the relay would reject this revision. */ + fun envelopeOrNull(): ArtifactEnvelope? = (validate() as? ArtifactValidation.Valid)?.envelope + + fun isWellFormed() = validate() is ArtifactValidation.Valid + + companion object { + const val KIND = 45010 + + /** + * Builds a revision and checks it against [ArtifactValidator] — including any client + * tags the [initializer] adds — so a template that the relay would reject never + * leaves this function. + * + * [title] must be null exactly when [op] is [ArtifactOp.DELETE] (and [content] then + * empty); [prev] must be null exactly when [op] is [ArtifactOp.CREATE]. + */ + fun build( + artifactId: String, + channelId: String, + type: String, + op: ArtifactOp, + title: String?, + content: String, + prev: HexKey? = null, + root: HexKey? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + require(ArtifactIds.isCanonicalUuid(artifactId)) { "artifact id must be a lowercase, non-nil UUID" } + require(ArtifactIds.isCanonicalUuid(channelId)) { "channel id must be a lowercase, non-nil UUID" } + require(TypeTag.isValid(type)) { "invalid namespaced artifact type: $type" } + if (op == ArtifactOp.DELETE) { + require(title == null) { "a delete must omit the title" } + require(content.isEmpty()) { "a delete must have empty content" } + } else { + require(title != null && TitleTag.isValid(title)) { "title must be nonblank and at most ${TitleTag.MAX_BYTES} UTF-8 bytes" } + } + require((op == ArtifactOp.CREATE) == (prev == null)) { "prev is required exactly on non-create revisions" } + require(prev == null || ArtifactIds.isEventId(prev)) { "prev must be a 64-char lowercase hex event id" } + require(root == null || ArtifactIds.isEventId(root)) { "root must be a 64-char lowercase hex event id" } + + val template = + eventTemplate(KIND, content, createdAt) { + artifactVersion() + artifactId(artifactId) + artifactHome(channelId) + artifactType(type) + title?.let { artifactTitle(it) } + artifactOp(op) + root?.let { artifactRoot(it) } + prev?.let { artifactPrev(it) } + initializer() + } + + val result = ArtifactValidator.validate(template.tags, template.content) + require(result is ArtifactValidation.Valid) { "invalid artifact envelope: ${(result as ArtifactValidation.Invalid).reason}" } + return template + } + + /** Creates a new artifact identity. Pass client tags (e.g. `assignee`) via [initializer]. */ + fun create( + artifactId: String, + channelId: String, + type: String, + title: String, + content: String, + root: HexKey? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(artifactId, channelId, type, ArtifactOp.CREATE, title, content, null, root, createdAt, initializer) + + /** + * An `update` on top of [current] (which must be the head the relay holds): keeps its + * `d`, `h`, `type` and `root`, names it in `prev`, and carries its client tags forward + * unless [keepClientTags] is false — NIP-AR editors must preserve annotations they do + * not understand. The [initializer] runs after the carry-over, so it can replace them. + */ + fun update( + current: ArtifactEvent, + title: String, + content: String, + keepClientTags: Boolean = true, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val envelope = requireNotNull(current.envelopeOrNull()) { "current revision has an invalid envelope" } + return build(envelope.id, envelope.home, envelope.type, ArtifactOp.UPDATE, title, content, current.id, envelope.root, createdAt) { + if (keepClientTags) current.clientTags().forEach { add(it) } + initializer() + } + } + + /** + * The soft delete of [current]: same `d`, `h`, `type` and `root` (the relay rejects a + * delete that changes the anchor), empty content, no title and no client tags. + */ + fun delete( + current: ArtifactEvent, + createdAt: Long = TimeUtils.now(), + ): EventTemplate { + val envelope = requireNotNull(current.envelopeOrNull()) { "current revision has an invalid envelope" } + return build(envelope.id, envelope.home, envelope.type, ArtifactOp.DELETE, null, "", current.id, envelope.root, createdAt) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactHeadResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactHeadResolver.kt new file mode 100644 index 0000000000..b2609bc317 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactHeadResolver.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** What a set of revisions says about one artifact's current state. See [ArtifactHeadResolver]. */ +@Immutable +sealed interface ArtifactHead { + /** No well-formed revision of the artifact was supplied. */ + data object Unknown : ArtifactHead + + /** + * [revision] is the current head. [chainComplete] is true when its `prev` chain reaches the + * `create` with no missing revision; false when the head was found across a gap (a redacted, + * expired or unreadable revision), which is still unambiguous for a linear history. + */ + data class Current( + val revision: ArtifactEvent, + val chainComplete: Boolean, + ) : ArtifactHead { + /** A soft-deleted artifact: hidden from active views until a `restore`. */ + val isDeleted get() = revision.op() == ArtifactOp.DELETE + } + + /** + * The newest visible revision, [lastRevision], was replaced by a move into a channel this + * reader cannot see — the relay-signed [removal] names it as `prev`. + */ + data class MovedAway( + val lastRevision: ArtifactEvent, + val removal: ArtifactRemovalEvent, + ) : ArtifactHead + + /** + * The revisions do not determine a single head: a fork (impossible on a Buzz relay, which + * serializes every identity, so it signals revisions from elsewhere), several gaps, or a + * `prev` cycle ([candidates] is then empty). Ask the relay's current-state query + * (`{"artifact":"current","#d":[id]}`) instead of guessing. + */ + data class Ambiguous( + val candidates: List, + ) : ArtifactHead +} + +/** + * Resolves an artifact's current head from its revisions the way a Buzz relay defines it: the + * latest *accepted* revision, where acceptance is a compare-and-swap on `prev` — every + * revision after the `create` names the head it replaced, so the accepted revisions form one + * linear chain. **Timestamps never choose the winner** (NIP-AR, "Identity and edits"), so + * neither does this resolver. + * + * The head is the one revision no other revision names as `prev` (a *tip*). Readers can see + * gaps — a NIP-29 `9005` redaction withholds a revision but keeps it accepted, retention + * expires old ones, and a moved artifact's earlier revisions stay under the source channel's + * access rules — which leaves more than one tip. Because the accepted history is linear, the + * segment that starts at the `create` is always the oldest, so when some other segment starts + * after a gap the create-rooted tip cannot be the head and is discarded. Anything still + * unresolved is reported as [ArtifactHead.Ambiguous] rather than picked by `created_at`. + * + * Ground truth: `accept_artifact` in Buzz's `buzz-db/src/store/artifact.rs` (the CAS), + * `removal_marker` in the same file, and `docs/nips/NIP-AR.md`. + */ +object ArtifactHeadResolver { + fun resolve( + artifactId: String, + revisions: Collection, + removals: Collection = emptyList(), + ): ArtifactHead { + val byId = LinkedHashMap() + val prevOf = HashMap() + val opOf = HashMap() + for (revision in revisions) { + if (revision.id in byId) continue + val envelope = revision.envelopeOrNull() ?: continue + if (envelope.id != artifactId) continue + byId[revision.id] = revision + prevOf[revision.id] = envelope.prev + opOf[revision.id] = envelope.op + } + if (byId.isEmpty()) return ArtifactHead.Unknown + + val replaced = HashSet() + prevOf.values.forEach { if (it != null) replaced.add(it) } + + val tips = byId.values.filter { it.id !in replaced } + if (tips.isEmpty()) return ArtifactHead.Ambiguous(emptyList()) + + // Walk each tip back to the oldest revision reachable from it. + val reachesCreate = tips.associateWith { tip -> opOf[segmentStart(tip.id, prevOf)] == ArtifactOp.CREATE } + + val head: ArtifactEvent + val complete: Boolean + if (tips.size == 1) { + head = tips[0] + complete = reachesCreate.getValue(head) + } else { + val afterGap = tips.filter { !reachesCreate.getValue(it) } + if (afterGap.size != 1) return ArtifactHead.Ambiguous(tips) + head = afterGap[0] + complete = false + } + + val removal = removals.firstOrNull { it.artifactId() == artifactId && it.replacedRevision() == head.id } + if (removal != null) return ArtifactHead.MovedAway(head, removal) + + return ArtifactHead.Current(head, complete) + } + + /** + * The known revisions from [head] back along `prev`, newest first. Stops at the `create`, + * at the first revision not in [revisions], or on a cycle. + */ + fun history( + head: ArtifactEvent, + revisions: Collection, + ): List { + val byId = revisions.associateBy { it.id } + val result = mutableListOf() + val seen = HashSet() + var current: ArtifactEvent? = head + while (current != null && seen.add(current.id)) { + result.add(current) + current = current.prev()?.let { byId[it] } + } + return result + } + + private fun segmentStart( + tip: HexKey, + prevOf: Map, + ): HexKey { + val seen = HashSet() + var current = tip + while (seen.add(current)) { + val prev = prevOf[current] ?: return current + if (prev !in prevOf) return current + current = prev + } + return current + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactIds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactIds.kt new file mode 100644 index 0000000000..2de923680c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactIds.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts + +/** + * The two identifier grammars NIP-AR's envelope uses, mirroring `canonical_uuid` and + * `event_id` in Buzz's `buzz-core/src/artifact.rs`. + */ +object ArtifactIds { + private val CANONICAL_UUID = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + private const val NIL_UUID = "00000000-0000-0000-0000-000000000000" + + /** + * True for a lowercase, hyphenated, non-nil UUID. Upstream parses with `Uuid::parse_str` + * and then requires `id.to_string() == value`, which only the lowercase hyphenated form + * satisfies (simple, braced, URN and uppercase forms all fail the round trip). + */ + fun isCanonicalUuid(value: String): Boolean = value.length == 36 && CANONICAL_UUID.matches(value) && value != NIL_UUID + + /** True for a 64-character lowercase hex event id. */ + fun isEventId(value: String): Boolean { + if (value.length != 64) return false + for (c in value) { + if (c !in '0'..'9' && c !in 'a'..'f') return false + } + return true + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactRemovalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactRemovalEvent.kt new file mode 100644 index 0000000000..c58f588d28 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactRemovalEvent.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ReasonTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.VersionTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A Buzz NIP-AR artifact removal (`kind:45011`), **signed by the relay** and stored in the + * *source* channel when an artifact moves out of it, in the same transaction that accepts the + * `op=move` revision. It says only that artifact `d` left channel `h`, replacing revision + * `prev` — never the destination, title or content. Tags, in order: `["ar","1"]`, + * `["d",]`, `["h",]`, `["reason","moved"]`, + * `["prev",]`; `content` is empty. + * + * Clients never publish this kind; [build] exists for fixtures/tests. Ground truth: + * `removal_marker` in Buzz's `buzz-db/src/store/artifact.rs`. + */ +@Immutable +class ArtifactRemovalEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The artifact that left the channel — the `d` tag. */ + fun artifactId() = tags.artifactId() + + /** The source channel the artifact was removed from — the `h` tag. */ + fun sourceChannel() = tags.artifactHome() + + /** Why it was removed — the `reason` tag (`moved`). */ + fun reason() = tags.artifactRemovalReason() + + /** The revision the move replaced (the last one readable in the source) — the `prev` tag. */ + fun replacedRevision() = tags.artifactPrev() + + /** True when the marker has the exact shape the relay emits. */ + fun isWellFormed(): Boolean { + if (content.isNotEmpty()) return false + if (tags.artifactVersion() != VersionTag.CURRENT) return false + val id = artifactId() ?: return false + val source = sourceChannel() ?: return false + val prev = replacedRevision() ?: return false + return ArtifactIds.isCanonicalUuid(id) && ArtifactIds.isCanonicalUuid(source) && ArtifactIds.isEventId(prev) && reason() != null + } + + companion object { + const val KIND = 45011 + + fun build( + artifactId: String, + sourceChannelId: String, + replacedRevision: HexKey, + reason: String = ReasonTag.MOVED, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + artifactVersion() + artifactId(artifactId) + artifactHome(sourceChannelId) + artifactRemovalReason(reason) + artifactPrev(replacedRevision) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..3859edab58 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/TagArrayBuilderExt.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts + +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.OpTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.PrevTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ReasonTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.RootTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TitleTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TypeTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.VersionTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag + +/** The `ar` envelope-version tag. */ +fun TagArrayBuilder.artifactVersion(version: String = VersionTag.CURRENT) = addUnique(VersionTag.assemble(version)) + +/** + * The artifact UUID as its `d` tag. Artifacts are regular (not addressable) events, so this + * is a plain `d` tag, not the NIP-01 addressable identifier. + */ +fun TagArrayBuilder.artifactId(artifactId: String) = addUnique(DTag.assemble(artifactId)) + +/** The home (or, on a removal, source) channel — the `h` tag. */ +fun TagArrayBuilder.artifactHome(channelId: String) = addUnique(GroupIdTag.assemble(channelId)) + +fun TagArrayBuilder.artifactType(type: String) = addUnique(TypeTag.assemble(type)) + +fun TagArrayBuilder.artifactTitle(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.artifactOp(op: ArtifactOp) = addUnique(OpTag.assemble(op)) + +fun TagArrayBuilder.artifactRoot(anchorEventId: HexKey) = addUnique(RootTag.assemble(anchorEventId)) + +fun TagArrayBuilder.artifactPrev(revisionId: HexKey) = addUnique(PrevTag.assemble(revisionId)) + +fun TagArrayBuilder.artifactRemovalReason(reason: String = ReasonTag.MOVED) = addUnique(ReasonTag.assemble(reason)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/TagArrayExt.kt new file mode 100644 index 0000000000..82b23c17a6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/TagArrayExt.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts + +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.OpTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.PrevTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ReasonTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.RootTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TitleTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.TypeTag +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.VersionTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag + +/** The envelope version — the `ar` tag. */ +fun TagArray.artifactVersion(): String? = fastFirstNotNullOfOrNull(VersionTag::parse) + +/** The artifact's stable UUID — the `d` tag. */ +fun TagArray.artifactId(): String? = firstTagValue(DTag.TAG_NAME) + +/** The artifact's home channel UUID (the source channel on a removal) — the `h` tag. */ +fun TagArray.artifactHome(): String? = fastFirstNotNullOfOrNull(GroupIdTag::parse) + +/** The namespaced content type — the `type` tag. */ +fun TagArray.artifactType(): String? = fastFirstNotNullOfOrNull(TypeTag::parse) + +/** The display title — the `title` tag (absent on a delete). */ +fun TagArray.artifactTitle(): String? = fastFirstNotNullOfOrNull(TitleTag::parse) + +/** The lifecycle operation — the `op` tag. */ +fun TagArray.artifactOp(): ArtifactOp? = fastFirstNotNullOfOrNull(OpTag::parse) + +/** The conversation anchor — the `root` tag. */ +fun TagArray.artifactRoot(): HexKey? = fastFirstNotNullOfOrNull(RootTag::parse) + +/** The replaced revision — the `prev` tag. */ +fun TagArray.artifactPrev(): HexKey? = fastFirstNotNullOfOrNull(PrevTag::parse) + +/** Why a removal (`kind:45011`) was issued — the `reason` tag. */ +fun TagArray.artifactRemovalReason(): String? = fastFirstNotNullOfOrNull(ReasonTag::parse) + +/** + * The client-defined tags: every tag that is neither envelope nor NIP-OA `auth`. NIP-AR + * editors MUST preserve these (and unfamiliar content fields) or decline the edit. + */ +fun TagArray.artifactClientTags(): List> = filter { it.isNotEmpty() && it[0] !in ArtifactValidator.ENVELOPE_TAG_NAMES && it[0] != ArtifactValidator.AUTH_TAG_NAME } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/ArtifactOp.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/ArtifactOp.kt new file mode 100644 index 0000000000..31142f1b00 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/ArtifactOp.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts.tags + +/** + * The lifecycle operation a NIP-AR revision performs, carried by the `op` tag ([OpTag]). + * Ground truth: `ArtifactOp` in Buzz's `buzz-core/src/artifact.rs`. + */ +enum class ArtifactOp( + val code: String, +) { + /** First revision of a new identity; the only op without `prev`. */ + CREATE("create"), + + /** Content change within the same home channel. */ + UPDATE("update"), + + /** Content snapshot published into a new home channel. */ + MOVE("move"), + + /** Soft delete: empty content, no `title`; only [RESTORE] may follow. */ + DELETE("delete"), + + /** Complete snapshot that revives a deleted artifact. */ + RESTORE("restore"), + ; + + companion object { + fun parse(code: String): ArtifactOp? = + when (code) { + CREATE.code -> CREATE + UPDATE.code -> UPDATE + MOVE.code -> MOVE + DELETE.code -> DELETE + RESTORE.code -> RESTORE + else -> null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/OpTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/OpTag.kt new file mode 100644 index 0000000000..b9d520515c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/OpTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The NIP-AR `op` tag — the revision's [ArtifactOp]. */ +object OpTag { + const val TAG_NAME = "op" + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Tag): ArtifactOp? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return ArtifactOp.parse(tag[1]) + } + + fun assemble(op: ArtifactOp) = arrayOf(TAG_NAME, op.code) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/PrevTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/PrevTag.kt new file mode 100644 index 0000000000..3490b5c6bc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/PrevTag.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The NIP-AR `prev` tag — the id of the revision this one replaces. Required on every op + * except `create`, where it MUST be absent; the relay accepts a revision only when `prev` + * names the artifact's current head (compare-and-swap). The relay-signed removal + * (`kind:45011`) reuses it for the revision a move replaced. Ground truth: `validate` in + * `buzz-core/src/artifact.rs`, `accept_artifact` in `buzz-db/src/store/artifact.rs`. + */ +object PrevTag { + const val TAG_NAME = "prev" + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Tag): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(revisionId: HexKey) = arrayOf(TAG_NAME, revisionId) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/ReasonTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/ReasonTag.kt new file mode 100644 index 0000000000..12eddfa775 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/ReasonTag.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `reason` tag on a relay-signed NIP-AR removal (`kind:45011`). The relay only ever + * emits [MOVED]. Ground truth: `removal_marker` in Buzz's `buzz-db/src/store/artifact.rs`. + */ +object ReasonTag { + const val TAG_NAME = "reason" + const val MOVED = "moved" + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Tag): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(reason: String = MOVED) = arrayOf(TAG_NAME, reason) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/RootTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/RootTag.kt new file mode 100644 index 0000000000..052229af6a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/RootTag.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The NIP-AR `root` tag — an optional conversation anchor (a `kind:9`, `40002`, `45001` or + * `45003` event in the home channel) the artifact is attached to. When set on creation, or + * changed from the previous revision, the relay requires the anchor to exist, undeleted, in + * `h`; a delete must keep the current value. Ground truth: `accept_artifact` in + * `buzz-db/src/store/artifact.rs`. + */ +object RootTag { + const val TAG_NAME = "root" + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Tag): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(anchorEventId: HexKey) = arrayOf(TAG_NAME, anchorEventId) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/TitleTag.kt new file mode 100644 index 0000000000..8705d27174 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/TitleTag.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The NIP-AR `title` tag — the artifact's display title. Required, nonblank and at most + * [MAX_BYTES] UTF-8 bytes on every revision except `op=delete`, where it MUST be absent. + * Ground truth: `validate` in Buzz's `buzz-core/src/artifact.rs`. + */ +object TitleTag { + const val TAG_NAME = "title" + const val MAX_BYTES = 512 + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Tag): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(title: String) = arrayOf(TAG_NAME, title) + + /** Nonblank and within [MAX_BYTES] UTF-8 bytes. */ + fun isValid(title: String) = title.isNotBlank() && title.encodeToByteArray().size <= MAX_BYTES +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/TypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/TypeTag.kt new file mode 100644 index 0000000000..73b49e9fd0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/TypeTag.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The NIP-AR `type` tag — the artifact's immutable, namespaced content type (e.g. + * `buzz.task`, `buzz.project`). `buzz.*` is reserved for published Buzz client contracts. + * Ground truth: `validate` in Buzz's `buzz-core/src/artifact.rs`. + */ +object TypeTag { + const val TAG_NAME = "type" + const val MAX_BYTES = 128 + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Tag): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(type: String) = arrayOf(TAG_NAME, type) + + /** + * The type grammar: at most [MAX_BYTES] bytes, at least one `.`, and every dot-separated + * component non-empty, starting with `a-z`, and otherwise only `a-z`, `0-9`, `_` or `-`. + */ + fun isValid(type: String): Boolean { + if (type.length > MAX_BYTES || '.' !in type) return false + var componentStart = true + for (c in type) { + if (c == '.') { + if (componentStart) return false + componentStart = true + } else if (componentStart) { + if (c !in 'a'..'z') return false + componentStart = false + } else if (c !in 'a'..'z' && c !in '0'..'9' && c != '_' && c != '-') { + return false + } + } + return !componentStart + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/VersionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/VersionTag.kt new file mode 100644 index 0000000000..d6f90ce46a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/tags/VersionTag.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The NIP-AR `ar` tag — the artifact envelope version. Only [CURRENT] (`"1"`) is defined; + * relays reject any other value ("unsupported artifact envelope version"). Carried by both + * the revision (`kind:45010`) and the relay-signed removal (`kind:45011`). Ground truth: + * `validate` in Buzz's `buzz-core/src/artifact.rs`. + */ +object VersionTag { + const val TAG_NAME = "ar" + const val CURRENT = "1" + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Tag): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(version: String = CURRENT) = arrayOf(TAG_NAME, version) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsContent.kt new file mode 100644 index 0000000000..cab6a0f859 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsContent.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.cwChannelWindow + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * The JSON `content` of a Buzz NIP-CW thread-bounds overlay (`kind:39007`): + * `{"version":1,"direction":"older","has_more":,"next_cursor":{"created_at":,"id":}|null}`. + * It is the sole authority on a thread window's exhaustion; clients echo [nextCursor] back as + * `until` + `before_id` to request the next (older) page. [isConsistent] carries the NIP's + * client-side checks on the body. Ground truth: `buzz-relay/src/api/bridge/thread_window.rs`. + */ +@Serializable +data class ThreadWindowBoundsContent( + val version: Int = VERSION, + val direction: String = DIRECTION_OLDER, + @SerialName("has_more") val hasMore: Boolean, + @SerialName("next_cursor") val nextCursor: NextCursor? = null, +) { + fun encodeToJson(): String = JSON.encodeToString(this) + + /** Version 1, direction `older`, and a cursor present exactly when [hasMore]. */ + fun isConsistent() = version == VERSION && direction == DIRECTION_OLDER && (nextCursor != null) == hasMore + + companion object { + const val VERSION = 1 + const val DIRECTION_OLDER = "older" + + // The relay writes `"next_cursor":null` on an exhausted page, so nulls stay explicit. + val JSON = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + fun decodeFromJson(json: String): ThreadWindowBoundsContent = JSON.decodeFromString(json) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsEvent.kt new file mode 100644 index 0000000000..e4512757f1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsEvent.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.cwChannelWindow + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException + +/** + * A Buzz NIP-CW thread-bounds overlay (`kind:39007`), **signed by the relay** and synthesized + * per thread-mode query (never stored; the relay rejects it at ingest). Exactly one is + * appended to every served thread window — including empty and exhausted pages — and it is + * the only authority on the thread window's exhaustion. Deliberately distinct from the + * channel-mode [WindowBoundsEvent] (`39006`), whose channel/cursor key cannot tell concurrent + * roots apart. + * + * Tags are exactly `["d","tw:1:"]` (the request binding, see + * [ThreadWindowRequest.binding]), `["h",]` and `["e",]`; `content` is a + * [ThreadWindowBoundsContent]. Before trusting it a client MUST check the relay signer and + * signature (the caller's job), the exact tags and binding ([matches]), and the body + * ([ThreadWindowBoundsContent.isConsistent]). Clients never publish this kind; [build] exists + * for fixtures/tests. Ground truth: `buzz-relay/src/api/bridge/thread_window.rs`, + * `buzz-core/src/thread_window.rs`, `docs/nips/NIP-CW.md` §Thread Bounds. + */ +@Immutable +class ThreadWindowBoundsEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The request binding — the `d` tag, `tw:1:`. */ + fun binding() = dTag() + + /** The channel id — the `h` tag. */ + fun channelId() = tags.firstNotNullOfOrNull(GroupIdTag::parse) + + /** The thread root id — the `e` tag. */ + fun rootId() = tags.firstNotNullOfOrNull(ETag::parseId) + + /** Parses the JSON bounds [content]. Throws on malformed content; use [boundsOrNull]. */ + fun bounds(): ThreadWindowBoundsContent = ThreadWindowBoundsContent.decodeFromJson(content) + + fun boundsOrNull(): ThreadWindowBoundsContent? = + try { + bounds() + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + + /** + * True when the tags are exactly one two-element `d`, `h` and `e` (and nothing else), the + * `d` is a `tw:1:` binding, the `h` a lowercase hyphenated UUID, the `e` a lowercase + * 64-hex id, and the body is consistent. Says nothing about which request it answers — + * use [matches] for that. + */ + fun isWellFormed(): Boolean { + if (tags.size != 3) return false + var d: String? = null + var h: String? = null + var e: String? = null + for (tag in tags) { + if (tag.size != 2) return false + when (tag[0]) { + DTag.TAG_NAME -> if (d == null) d = tag[1] else return false + GroupIdTag.TAG_NAME -> if (h == null) h = tag[1] else return false + ETag.TAG_NAME -> if (e == null) e = tag[1] else return false + else -> return false + } + } + if (d == null || h == null || e == null) return false + if (!d.startsWith(ThreadWindowRequest.BINDING_PREFIX) || !isLowercaseHex(d.substring(ThreadWindowRequest.BINDING_PREFIX.length), 64)) return false + if (!CANONICAL_UUID.matches(h) || !isLowercaseHex(e, 64)) return false + return boundsOrNull()?.isConsistent() == true + } + + /** + * True when this well-formed overlay answers [request], as issued by [readerPubKey] to the + * relay at [host]: the binding, channel and root all match. + */ + fun matches( + request: ThreadWindowRequest, + host: String, + readerPubKey: HexKey, + ): Boolean = + isWellFormed() && + binding() == request.binding(host, readerPubKey) && + channelId() == request.channelId.lowercase() && + rootId() == request.rootId.lowercase() + + companion object { + const val KIND = 39007 + + private val CANONICAL_UUID = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + + private fun isLowercaseHex( + value: String, + length: Int, + ) = value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' } + + /** Builds the overlay the relay would sign for [request] (fixtures/tests only). */ + fun build( + request: ThreadWindowRequest, + host: String, + readerPubKey: HexKey, + bounds: ThreadWindowBoundsContent, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, bounds.encodeToJson(), createdAt) { + dTag(request.binding(host, readerPubKey)) + addUnique(GroupIdTag.assemble(request.channelId.lowercase())) + addUnique(ETag.assemble(request.rootId.lowercase(), null, null)) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowRequest.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowRequest.kt new file mode 100644 index 0000000000..549bfffd82 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowRequest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.cwChannelWindow + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive + +/** + * The response-affecting arguments of a NIP-CW thread-mode request (`thread_window: true`, + * served on Buzz's NIP-98 HTTP `POST /query`), normalized the way the relay normalizes them, + * so a client can recompute the request binding a [ThreadWindowBoundsEvent] must carry. + * + * [kinds] must be drawn from `9`, `40002`, `45001`, `45003` (sorted and deduplicated here); + * [limit] is 1–200 (default 50), [depth] 1–100 (default 100); [cursor] is null for the head + * page, else the previous bounds' `next_cursor`. Ground truth: `Request::parse` and + * `Request::binding` in Buzz's `buzz-core/src/thread_window.rs`. + */ +data class ThreadWindowRequest( + val channelId: String, + val rootId: HexKey, + val kinds: List, + val limit: Int = DEFAULT_LIMIT, + val depth: Int = MAX_DEPTH, + val cursor: NextCursor? = null, + val includeAux: Boolean = false, +) { + init { + require(kinds.isNotEmpty() && kinds.all { it in ROW_KINDS }) { "thread_window supports row kinds 9, 40002, 45001, 45003 only" } + require(limit in 1..MAX_LIMIT) { "thread_window: limit must be an integer in 1..=$MAX_LIMIT" } + require(depth in 1..MAX_DEPTH) { "thread_window: depth_limit must be an integer in 1..=$MAX_DEPTH" } + require(rootId.length == 64 && rootId.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) { "thread_window: expected a full 64-hex event id" } + require(cursor == null || cursor.createdAt >= 0) { "thread_window: until must be nonnegative integer seconds" } + } + + /** + * The `d` value of the bounds event answering this request for [readerPubKey] on [host]: + * `"tw:1:" + hex(SHA-256(compact JSON of + * ["tw",1,"older",host,reader,channel,root,limit,depth,kinds,cursor,include_aux]))`, where + * `cursor` is `null` or `[created_at,""]`. [host] is the relay's normalized authority + * (e.g. `relay.example` or `localhost:3000`); ids and the reader are lowercased first. + */ + fun binding( + host: String, + readerPubKey: HexKey, + ): String { + val canonical = + JsonArray( + listOf( + JsonPrimitive("tw"), + JsonPrimitive(1), + JsonPrimitive(ThreadWindowBoundsContent.DIRECTION_OLDER), + JsonPrimitive(host), + JsonPrimitive(readerPubKey.lowercase()), + JsonPrimitive(channelId.lowercase()), + JsonPrimitive(rootId.lowercase()), + JsonPrimitive(limit), + JsonPrimitive(depth), + JsonArray(kinds.distinct().sorted().map { JsonPrimitive(it) }), + cursor?.let { JsonArray(listOf(JsonPrimitive(it.createdAt), JsonPrimitive(it.id.lowercase()))) } ?: JsonNull, + JsonPrimitive(includeAux), + ), + ) + return BINDING_PREFIX + sha256(canonical.toString().encodeToByteArray()).toHexKey() + } + + companion object { + const val BINDING_PREFIX = "tw:1:" + const val DEFAULT_LIMIT = 50 + const val MAX_LIMIT = 200 + const val MAX_DEPTH = 100 + + /** Conversation row kinds a thread window may request. */ + val ROW_KINDS = setOf(9, 40002, 45001, 45003) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLifecycleContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLifecycleContent.kt index 756e48cb61..9d28309e21 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLifecycleContent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLifecycleContent.kt @@ -29,12 +29,19 @@ import kotlinx.serialization.json.Json /** * The `content` JSON shared by every Buzz huddle-lifecycle event (`kind:48100`–`48103`): * the id of the ephemeral audio channel the lifecycle transition applies to. The parent - * (timeline) channel is carried separately in the `h` tag. Field name is snake_case on + * (timeline) channel is carried separately in the `h` tag. Field names are snake_case on * the wire. Ground truth: `buzz-relay/src/audio/handler.rs::emit_participant_event`. + * + * The relay also stamps the huddle session's [generation] (the same value the kind-48104 + * liveness answer carries, so a client can tell a restarted session from the old one) and, + * when it has them, the participant roster's [rosterRevision] and the joiner's [admissionId]. */ @Serializable data class HuddleLifecycleContent( @SerialName("ephemeral_channel_id") val ephemeralChannelId: String, + @SerialName("roster_revision") val rosterRevision: Long? = null, + @SerialName("admission_id") val admissionId: String? = null, + val generation: String? = null, ) { fun encodeToJson(): String = JSON.encodeToString(this) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessContent.kt new file mode 100644 index 0000000000..0854b8cca0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessContent.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.huddles + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * The `content` JSON of a relay-synthesized huddle-liveness snapshot (`kind:48104`): + * `{"ephemeral_channel_id": , "generation": }`. + * + * [generation] is a **string** on the wire (the relay `to_string()`s it): the mesh lease + * generation (a monotonic decimal integer) when the relay runs a mesh, otherwise an opaque + * per-process epoch. Compare with [compareGenerations], never numerically on a `Long`. + * Ground truth: `handle_huddle_liveness_req` in Buzz's `buzz-relay/src/handlers/req.rs`; + * reader in `desktop/src/features/huddle/lib/huddlePresence.ts`. + */ +@Serializable +data class HuddleLivenessContent( + @SerialName("ephemeral_channel_id") val ephemeralChannelId: String, + val generation: String, +) { + fun encodeToJson(): String = JSON.encodeToString(this) + + companion object { + val JSON = + Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + } + + fun decodeFromJson(json: String): HuddleLivenessContent = JSON.decodeFromString(json) + + fun decodeFromJsonOrNull(json: String): HuddleLivenessContent? = + try { + decodeFromJson(json) + } catch (_: Exception) { + null + } + + /** + * Orders two liveness generations the way Buzz's desktop does + * (`compareHuddleGenerations`): only when both are plain decimal integers (mesh + * generations, which are monotonic) is there an order, returned as -1/0/1 for + * [candidate] below/equal/above [current]. Anything else is an opaque epoch and yields + * null — a differing opaque epoch means "take a fresh snapshot", not "newer". + */ + fun compareGenerations( + candidate: String, + current: String, + ): Int? { + if (!candidate.isDecimal() || !current.isDecimal()) return null + val a = candidate.trimStart('0') + val b = current.trimStart('0') + if (a.length != b.length) return if (a.length < b.length) -1 else 1 + val cmp = a.compareTo(b) + return if (cmp < 0) { + -1 + } else if (cmp > 0) { + 1 + } else { + 0 + } + } + + private fun String.isDecimal() = isNotEmpty() && all { it in '0'..'9' } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessEvent.kt new file mode 100644 index 0000000000..a94f7359c8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessEvent.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.huddles + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A Buzz huddle-liveness snapshot (`kind:48104`): the relay's authoritative statement that a + * huddle session is live right now, **synthesized and signed by the relay** per REQ and + * never stored. One event per live session: `["d",]` (the ephemeral audio + * channel), `["h",]`, and a [HuddleLivenessContent] body. A session + * with no live room (or no mesh lease) simply gets no event, so absence from a snapshot is + * "not live". + * + * The relay only serves it to a REQ whose every filter has `kinds` exactly `[48104]` and + * that names at least one authorized `#h` channel — build it with [filter]. Clients never + * publish this kind; [build] exists for fixtures/tests. Ground truth: + * `filters_are_huddle_liveness_only`, `huddle_liveness_session_ids` and + * `handle_huddle_liveness_req` in Buzz's `buzz-relay/src/handlers/req.rs`. + */ +@Immutable +class HuddleLivenessEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The live huddle session (its ephemeral audio channel UUID) — the `d` tag. */ + fun sessionId(): String? = tags.firstTagValue(DTag.TAG_NAME) + + /** The parent (timeline) channel UUID — the `h` tag. */ + fun channelId(): String? = tags.huddleChannel() + + /** The parsed body, or null when malformed (a malformed snapshot is not authoritative). */ + fun liveness(): HuddleLivenessContent? = HuddleLivenessContent.decodeFromJsonOrNull(content) + + /** The session's liveness generation, or null when missing/empty. */ + fun generation(): String? = liveness()?.generation?.ifEmpty { null } + + companion object { + const val KIND = 48104 + + /** The relay truncates `#d` session ids (and bounds `#h` channels) to this many values. */ + const val MAX_EXPLICIT_VALUES = 128 + + /** + * The liveness REQ filter: `kinds` exactly `[48104]`, the parent channels as `#h` + * (required — the relay CLOSEs a liveness REQ without an authorized `#h`), and + * optionally the sessions of interest as `#d` with a matching `limit`, the shape + * Buzz's desktop sends. Without [sessionIds] the relay reports every live session it + * can link to those channels. Mixing any other kind into the filter turns it into an + * ordinary REQ that returns no liveness at all. + */ + fun filter( + channelIds: List, + sessionIds: List? = null, + ): Filter { + require(channelIds.isNotEmpty()) { "huddle liveness requires at least one #h channel" } + require(channelIds.size <= MAX_EXPLICIT_VALUES) { "at most $MAX_EXPLICIT_VALUES channels per liveness REQ" } + require(sessionIds == null || sessionIds.size <= MAX_EXPLICIT_VALUES) { "at most $MAX_EXPLICIT_VALUES sessions per liveness REQ" } + + val tags = + if (sessionIds.isNullOrEmpty()) { + mapOf(GroupIdTag.TAG_NAME to channelIds) + } else { + mapOf(GroupIdTag.TAG_NAME to channelIds, DTag.TAG_NAME to sessionIds) + } + + return Filter( + kinds = listOf(KIND), + tags = tags, + limit = sessionIds?.size?.takeIf { it > 0 }, + ) + } + + fun build( + sessionId: String, + channelId: String, + generation: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, HuddleLivenessContent(sessionId, generation).encodeToJson(), createdAt) { + add(DTag.assemble(sessionId)) + huddleChannel(channelId) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/identityNames/IdentityNamePolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/identityNames/IdentityNamePolicy.kt new file mode 100644 index 0000000000..3d25e21919 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/identityNames/IdentityNamePolicy.kt @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.identityNames + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub + +/** + * One naming fact for an identity: its [pubkey], the [name] a client would show, whether it + * [isAgent], and the [ownerPubkey] an agent declares (a verified NIP-OA owner). Keys are 64-char hex, + * either case. + */ +data class NamingIdentity( + val pubkey: HexKey, + val name: String, + val isAgent: Boolean = false, + val ownerPubkey: HexKey? = null, +) + +/** A resolved display label. [qualifier] is the key suffix, when one was added. */ +data class ResolvedIdentityName( + val name: String, + val qualifier: String? = null, +) + +/** + * Buzz's "contextual identity names" v1: distinct display labels for the identities one view shows + * (a channel's members, a mention picker's choices), so two identities with the same name never look + * identical. A label only grows when a real collision exists in that context: + * + * 1. Within a collision, the highest-priority identity keeps its plain name: the viewer, then other + * people, then the viewer's own agents, then everyone else's agents. + * 2. A colliding agent first becomes readable: `Alice’s Honey` (its owner's name) when it is not the + * viewer's, else `Honey (agent)` when a person shares the name. + * 3. Whoever still collides gets a growing suffix of their npub: `Honey · 7xk2`. + * + * Display policy only; a label grants no authority, so keep the key for every action. Ported from + * Buzz's `mobile/lib/shared/identity_names/identity_name_policy.dart` and checked against its + * vendored portable fixtures (`IdentityNamePolicyTest`). + */ +object IdentityNamePolicy { + const val VERSION = 1 + + private val HEX_KEY = Regex("^[0-9a-fA-F]{64}$") + + // ECMAScript String.prototype.trim code points, as the contract requires (U+0085 is kept). + private fun isContractWhitespace(c: Char): Boolean { + val u = c.code + return (u in 0x09..0x0D) || + u == 0x20 || + u == 0xA0 || + u == 0x1680 || + (u in 0x2000..0x200A) || + u == 0x2028 || + u == 0x2029 || + u == 0x202F || + u == 0x205F || + u == 0x3000 || + u == 0xFEFF + } + + /** Trims [value] exactly as the contract specifies. */ + fun trim(value: String): String { + var start = 0 + var end = value.length + while (start < end && isContractWhitespace(value[start])) start++ + while (end > start && isContractWhitespace(value[end - 1])) end-- + return value.substring(start, end) + } + + private fun requireKey( + key: String, + what: String, + ) = require(HEX_KEY.matches(key)) { "$what is not a 64-character hex public key: $key" } + + private class Row( + val key: String, + val identity: NamingIdentity, + val original: String, + val priority: Int, + val mine: Boolean, + ) { + var base: String = original + var label: String = original + var length: Int = 0 + var suffix: String? = null + } + + /** + * Resolves distinct labels for the [candidates] among [identities] (all of them when + * [candidates] is null; none for an empty set), as seen by [viewer]. Facts outside the + * candidates still serve as owner-name lookups. Throws on any invalid key. + */ + fun resolve( + identities: List, + viewer: HexKey? = null, + candidates: Iterable? = null, + ): Map { + identities.forEach { identity -> + requireKey(identity.pubkey, "pubkey") + identity.ownerPubkey?.let { requireKey(it, "ownerPubkey") } + } + viewer?.let { requireKey(it, "viewer") } + val selected = + candidates + ?.map { + requireKey(it, "candidates") + it.lowercase() + }?.toSet() + val normalizedViewer = viewer?.lowercase() + + // Last fact per key: the preferred alias and the owner-lookup name. + val preferred = LinkedHashMap() + identities.forEach { preferred[it.pubkey.lowercase()] = it } + + // One working row per (key, trimmed name); the last fact supplies metadata. + val aliases = LinkedHashMap, NamingIdentity>() + identities.forEach { aliases[it.pubkey.lowercase() to trim(it.name)] = it } + + val rows = + aliases.mapNotNull { (keyAndName, identity) -> + val (key, name) = keyAndName + if (selected != null && key !in selected) return@mapNotNull null + val owner = identity.ownerPubkey?.lowercase() + val mine = normalizedViewer != null && (key == normalizedViewer || owner == normalizedViewer) + Row( + key = key, + identity = identity, + original = name, + mine = mine, + priority = + if (identity.isAgent) { + if (mine) 2 else 3 + } else { + if (key == normalizedViewer) 0 else 1 + }, + ) + } + + val npubs = HashMap() + + fun npubFor(key: String) = npubs.getOrPut(key) { NPub.create(key) } + + while (true) { + val groups = LinkedHashMap>() + rows.forEach { groups.getOrPut(it.label) { mutableListOf() }.add(it) } + val collisions = groups.values.filter { group -> group.map { it.key }.toSet().size > 1 } + if (collisions.isEmpty()) break + + for (group in collisions) { + val best = group.minOf { it.priority } + val winners = group.filter { it.priority == best }.map { it.key }.toSet() + val changing = group.filter { winners.size != 1 || it.key !in winners } + val groupHasHuman = group.any { !it.identity.isAgent } + + var qualified = false + for (row in changing) { + if (!row.identity.isAgent || row.length != 0) continue + val ownerFact = + row.identity.ownerPubkey + ?.lowercase() + ?.let { preferred[it] } + val owner = ownerFact?.let { trim(it.name) } ?: "" + val readable = + when { + !row.mine && owner.isNotEmpty() -> "$owner’s ${row.original}" + groupHasHuman -> "${row.original} (agent)" + else -> row.base + } + if (readable != row.base) { + row.base = readable + row.label = readable + qualified = true + } + } + if (qualified) continue + + for (row in changing) { + row.length = if (row.length == 0) 4 else row.length + 1 + val npub = npubFor(row.key) + val suffix = if (row.length <= npub.length) npub.substring(npub.length - row.length) else "$npub · ${row.length - npub.length}" + row.suffix = suffix + row.label = "${row.base} · $suffix" + } + } + } + + val result = LinkedHashMap() + rows.forEach { row -> + if (row.original == trim(preferred.getValue(row.key).name)) { + result[row.key] = ResolvedIdentityName(row.label, row.suffix) + } + } + return result + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt index 1f1b0fbc37..828986034e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt @@ -31,16 +31,19 @@ import kotlin.io.encoding.ExperimentalEncodingApi * * - `.` (base64url, JWT-style but not a JWT) — the payload names the * community, the granted role, an expiry and a nonce. - * - `v2.` — a bare server-side handle. Nothing about the invite is readable here; - * the relay resolves it on claim. + * - `v2.` — a bare server-side handle: `v2.` plus the unpadded, canonical base64url + * encoding of a 32-byte secret (exactly what `validate_v2_code` in `buzz-core/src/invite.rs` + * accepts — see [BuzzInviteLink.isValidV2Code]). Nothing about the invite is readable here; the + * relay resolves it on claim. * * A Buzz invite is **not** a NIP-29 invite code (kind 9009) — it is redeemed over HTTP against * the relay's tenant host: `POST /api/invites/claim`, NIP-98-signed by the joining key, after * accepting any configured join policy. The relay verifies the token's MAC (its own key), so a * client only needs to read the payload, never validate the signature. * - * Ground truth: `buzz-relay/src/invite_token.rs` (token shape + `verify_invite`) and - * `buzz-relay/src/api/invites.rs` (`claim_invite`, `accept_policy`). + * Ground truth: `buzz-relay/src/invite_token.rs` (token shape + `verify_invite`), + * `buzz-core/src/invite.rs` (`validate_v2_code`) and `buzz-relay/src/api/invites.rs` + * (`claim_invite`, `accept_policy`). */ data class BuzzInvite( /** The tenant host the invite is scoped to, e.g. `team.communities.buzz.xyz`. */ @@ -74,6 +77,15 @@ object BuzzInviteLink { /** The payload segment of an opaque, server-resolved token. */ private const val V2_PREFIX = "v2" + /** The full prefix the relay routes on (`V2_PREFIX` in `buzz-core/src/invite.rs`). */ + const val V2_CODE_PREFIX = "v2." + + /** Random bytes behind a `v2.` code (`V2_SECRET_LEN` in `buzz-core/src/invite.rs`). */ + const val V2_SECRET_LEN = 32 + + @OptIn(ExperimentalEncodingApi::class) + private val B64_NO_PAD = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT) + /** What the relay grants when the token doesn't say — and it never says for `v2.`. */ private const val DEFAULT_ROLE = "member" @@ -115,12 +127,13 @@ object BuzzInviteLink { if (payloadB64 == code || payloadB64.isEmpty()) return null // `v2.` carries no client-readable payload — the community, role and expiry live - // only on the relay, which resolves the code on claim and returns them. There is nothing - // to decode and nothing to lose by admitting it: the join flow needs the host (for the - // relay url and the REST base) and the code, both of which the url itself carries, and the - // claim response supplies the rest. Matched on the literal prefix rather than by relaxing - // the decode below, so `…/invite/anything.else` still fails to parse. + // only on the relay, which resolves the code on claim and returns them. The join flow + // needs the host (for the relay url and the REST base) and the code, both of which the url + // itself carries, and the claim response supplies the rest. The relay routes on this exact + // prefix and never falls back to the v1 verifier, so a malformed `v2.` code is rejected + // here too rather than handed to a claim that can only answer `invite_invalid`. if (payloadB64 == V2_PREFIX) { + if (!isValidV2Code(code)) return null return BuzzInvite(host = host, code = code, communityId = "", role = DEFAULT_ROLE, expiresAt = null) } @@ -142,6 +155,24 @@ object BuzzInviteLink { ) } + /** + * True when [code] is exactly `v2.` followed by the unpadded base64url encoding of a 32-byte + * secret, in its canonical form — mirroring `validate_v2_code` in `buzz-core/src/invite.rs`, + * whose decode/re-encode comparison rejects padded or otherwise aliased encodings. + */ + @OptIn(ExperimentalEncodingApi::class) + fun isValidV2Code(code: String): Boolean { + if (!code.startsWith(V2_CODE_PREFIX)) return false + val encoded = code.substring(V2_CODE_PREFIX.length) + val secret = + try { + B64_NO_PAD.decode(encoded) + } catch (_: IllegalArgumentException) { + return false + } + return secret.size == V2_SECRET_LEN && B64_NO_PAD.encode(secret) == encoded + } + /** The host between the scheme's `//` and the `/invite/` marker, or null when absent. */ private fun extractHost( url: String, @@ -159,3 +190,49 @@ object BuzzInviteLink { return if (remainder == 0) s else s + "=".repeat(4 - remainder) } } + +/** + * The outcome vocabulary of Buzz's invite claim endpoint (`POST /api/invites/claim` in + * `buzz-relay/src/api/invites.rs`). A success answers 200 with a [STATUS_JOINED] or + * [STATUS_ALREADY_MEMBER] `status`; a refusal answers 403 with `{"error": }`. + */ +object BuzzInviteClaim { + /** 200: the claimer was added to the workspace. */ + const val STATUS_JOINED = "joined" + + /** 200: the claimer was already a member — nothing changed, and no use was consumed. */ + const val STATUS_ALREADY_MEMBER = "already_member" + + /** 403: the invite's lifetime has passed. */ + const val ERROR_EXPIRED = "invite_expired" + + /** 403: a `v2.` invite minted with `max_uses` has no uses left. */ + const val ERROR_EXHAUSTED = "invite_exhausted" + + /** 403: unknown, revoked, malformed, or minted for another workspace. */ + const val ERROR_INVALID = "invite_invalid" + + /** 403: the workspace has a join policy and no valid acceptance receipt was sent. */ + const val ERROR_JOIN_POLICY_REQUIRED = "join_policy_required" + + private val JSON = Json { ignoreUnknownKeys = true } + + @Serializable + private data class Body( + val status: String? = null, + val error: String? = null, + ) + + /** The `status` of a claim response body, or null when absent/unparseable. */ + fun statusOf(body: String): String? = parse(body)?.status + + /** The `error` slug of a claim response body, or null when absent/unparseable. */ + fun errorOf(body: String): String? = parse(body)?.error + + private fun parse(body: String): Body? = + try { + JSON.decodeFromString(body) + } catch (_: Exception) { + null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectEvent.kt new file mode 100644 index 0000000000..25f2fe34a0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectEvent.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.mpProjects + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMember +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectVisibility +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * A Buzz NIP-MP project (`kind:30621`): an addressable, signed, named grouping of NIP-34 + * repository announcements (`kind:30617`, + * [com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent]) referenced by + * coordinate, so one project can span repositories owned by different pubkeys. It is metadata + * only — its signer gains no authority over any member. + * + * Tags: exactly one non-empty `d` (the project slug), optional `name` (≤256 bytes; clients + * fall back to `d`), optional `description` (≤2048 bytes), 0–64 member `a` tags + * ([ProjectMember]; no two with the same coordinate), optional `buzz-channel` and + * `buzz-visibility` (`listed`|`unlisted`, anything else reads as listed). `content` is `""` + * and carries no meaning; unknown tags are ignored. Global-only: a stray `h` never scopes it. + * + * Ground truth: `build_project` / `validate_project_envelope` in Buzz's + * `buzz-sdk/src/builders.rs`, `validate_project_envelope` in + * `buzz-relay/src/handlers/ingest.rs` (mirrored by [ProjectValidator]), and + * `docs/nips/NIP-MP.md` with its `NIP-MP.fixtures.json` oracle. + */ +@Immutable +class ProjectEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The project slug — the `d` tag. */ + fun slug() = dTag() + + /** The `name` tag, if any. */ + fun name() = tags.projectName() + + /** What clients display: the `name`, falling back to the slug. */ + fun displayName() = name() ?: slug() + + fun description() = tags.projectDescription() + + /** The well-formed member repositories. */ + fun members(): List = tags.projectMembers() + + /** The member repositories' `30617` addresses. */ + fun memberAddresses(): List
= members().map { it.address } + + /** The discussion channel reference — metadata, not routing. */ + fun channelId() = tags.projectChannel() + + fun visibility(): ProjectVisibility = tags.projectVisibility() + + fun isListed() = visibility() == ProjectVisibility.LISTED + + /** The relay's ingest verdict: null when accepted, else the rule that rejects it. */ + fun validate(): ProjectRejection? = ProjectValidator.validate(tags) + + fun isWellFormed() = validate() == null + + companion object { + const val KIND = 30621 + + /** + * Builds a project with Buzz's writer policy (`build_project`): a non-empty slug of at + * most 1024 bytes, [channelId] a UUID, empty content, and the whole envelope checked + * by [ProjectValidator] (so duplicate or oversized members and metadata are refused + * here instead of by the relay). + */ + @OptIn(ExperimentalUuidApi::class) + fun build( + slug: String, + name: String? = null, + description: String? = null, + members: List = emptyList(), + channelId: String? = null, + visibility: ProjectVisibility? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + require(slug.isNotEmpty()) { "project slug must not be empty" } + require(slug.encodeToByteArray().size <= ProjectValidator.D_MAX_BYTES) { "project slug must not exceed ${ProjectValidator.D_MAX_BYTES} bytes" } + if (channelId != null) { + require(runCatching { Uuid.parse(channelId) }.isSuccess) { "buzz-channel must be a valid UUID (got $channelId)" } + } + + val template = + eventTemplate(KIND, "", createdAt) { + dTag(slug) + name?.let { projectName(it) } + description?.let { projectDescription(it) } + projectMembers(members) + channelId?.let { projectChannel(it) } + visibility?.let { projectVisibility(it) } + initializer() + } + + ProjectValidator.validate(template.tags)?.let { throw IllegalArgumentException("[${it.rule.id}] ${it.message}") } + return template + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectValidator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectValidator.kt new file mode 100644 index 0000000000..0113a43219 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectValidator.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.mpProjects + +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ChannelTag +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMemberTag +import com.vitorpamplona.quartz.buzz.mpProjects.tags.VisibilityTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag +import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag + +/** + * The eight NIP-MP ingest rules. [id] is the stable identifier the shared conformance + * fixtures (`docs/nips/NIP-MP.fixtures.json` in Buzz) name in `reject_rules`. + */ +enum class ProjectRule( + val id: String, +) { + D_CARDINALITY("d-cardinality"), + D_EMPTY("d-empty"), + MEMBER_CAP("member-cap"), + MEMBER_TAG_ARITY("member-tag-arity"), + MEMBER_COORDINATE_MALFORMED("member-coordinate-malformed"), + MEMBER_DUPLICATE("member-duplicate"), + METADATA_CARDINALITY("metadata-cardinality"), + METADATA_LENGTH("metadata-length"), +} + +/** A rejected project envelope: which [rule] fired and why. */ +data class ProjectRejection( + val rule: ProjectRule, + val message: String, +) + +/** + * NIP-MP envelope validation — a port of `validate_project_envelope` in Buzz's + * `buzz-relay/src/handlers/ingest.rs` (and the matching Layer-A validator in + * `buzz-sdk/src/builders.rs`), evaluated in the relay's order so the first failing rule is the + * one the relay would report. `content` is never inspected and unknown tags are ignored. + * Deliberately absent, as upstream: any membership authorization — referencing another + * owner's repository is legal and grants nothing. + */ +object ProjectValidator { + /** Maximum member `a` tags, counted over raw tags (duplicates included). */ + const val MEMBER_CAP = 64 + + /** The relay's generic `d` bound (`D_TAG_MAX_LEN`); the SDK reports it as `d-empty`. */ + const val D_MAX_BYTES = 1024 + const val NAME_MAX_BYTES = 256 + const val DESCRIPTION_MAX_BYTES = 2048 + const val METADATA_TAG_MAX_BYTES = 256 + + /** Metadata tags a project may carry at most once each, in the relay's check order. */ + private val SINGLETON_METADATA = + listOf( + NameTag.TAG_NAME to NAME_MAX_BYTES, + DescriptionTag.TAG_NAME to DESCRIPTION_MAX_BYTES, + ChannelTag.TAG_NAME to ChannelTag.MAX_BYTES, + VisibilityTag.TAG_NAME to VisibilityTag.MAX_BYTES, + ) + + /** Null when [tags] form a valid project envelope; otherwise the first rule that fails. */ + fun validate(tags: TagArray): ProjectRejection? { + val dValues = mutableListOf() + val members = mutableListOf>() + val singletonCounts = IntArray(SINGLETON_METADATA.size) + val singletonValues = arrayOfNulls(SINGLETON_METADATA.size) + + for (tag in tags) { + if (tag.isEmpty()) continue + val value = tag.getOrNull(1) ?: "" + when (val name = tag[0]) { + DTag.TAG_NAME -> dValues.add(value) + ProjectMemberTag.TAG_NAME -> members.add(tag) + else -> { + val index = SINGLETON_METADATA.indexOfFirst { it.first == name } + if (index >= 0) { + singletonCounts[index]++ + singletonValues[index] = value + } + } + } + } + + if (dValues.size != 1) return reject(ProjectRule.D_CARDINALITY, "project event must have exactly one `d` tag (got ${dValues.size})") + if (dValues[0].isEmpty()) return reject(ProjectRule.D_EMPTY, "project event `d` tag must not be empty") + if (dValues[0].utf8Size() > D_MAX_BYTES) return reject(ProjectRule.D_EMPTY, "project event `d` tag exceeds $D_MAX_BYTES bytes") + + if (members.size > MEMBER_CAP) return reject(ProjectRule.MEMBER_CAP, "project event must have at most $MEMBER_CAP member `a` tags (got ${members.size})") + for (member in members) { + if (member.size !in 2..3) return reject(ProjectRule.MEMBER_TAG_ARITY, "project event member `a` tag must have exactly 2 or 3 elements (got ${member.size})") + } + val seen = HashSet() + for (member in members) { + val coordinate = member[1] + if (!ProjectMemberTag.isValidCoordinate(coordinate)) { + return reject(ProjectRule.MEMBER_COORDINATE_MALFORMED, "project event member `a` tag must be `30617::` (got \"$coordinate\")") + } + if (!seen.add(coordinate)) return reject(ProjectRule.MEMBER_DUPLICATE, "project event has duplicate member coordinate \"$coordinate\"") + } + + for (i in SINGLETON_METADATA.indices) { + if (singletonCounts[i] > 1) { + return reject(ProjectRule.METADATA_CARDINALITY, "project event must have at most one `${SINGLETON_METADATA[i].first}` tag (got ${singletonCounts[i]})") + } + } + for (i in SINGLETON_METADATA.indices) { + val value = singletonValues[i] ?: continue + val (name, max) = SINGLETON_METADATA[i] + if (value.utf8Size() > max) return reject(ProjectRule.METADATA_LENGTH, "project event `$name` tag too long (${value.utf8Size()} bytes, max $max)") + } + return null + } + + private fun reject( + rule: ProjectRule, + message: String, + ) = ProjectRejection(rule, message) + + private fun String.utf8Size() = encodeToByteArray().size +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..5d834b5b9f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/TagArrayBuilderExt.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.mpProjects + +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ChannelTag +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMember +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMemberTag +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectVisibility +import com.vitorpamplona.quartz.buzz.mpProjects.tags.VisibilityTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag +import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag + +fun TagArrayBuilder.projectName(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.projectDescription(description: String) = addUnique(DescriptionTag.assemble(description)) + +/** One `a` tag per member. Duplicate coordinates are not merged — the relay rejects them. */ +fun TagArrayBuilder.projectMembers(members: List) = members.forEach { add(ProjectMemberTag.assemble(it)) } + +fun TagArrayBuilder.projectChannel(channelId: String) = addUnique(ChannelTag.assemble(channelId)) + +fun TagArrayBuilder.projectVisibility(visibility: ProjectVisibility) = addUnique(VisibilityTag.assemble(visibility)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/TagArrayExt.kt new file mode 100644 index 0000000000..3817145a5d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/TagArrayExt.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.mpProjects + +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ChannelTag +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMember +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMemberTag +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectVisibility +import com.vitorpamplona.quartz.buzz.mpProjects.tags.VisibilityTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull +import com.vitorpamplona.quartz.nip01Core.core.fastMapNotNullDense +import com.vitorpamplona.quartz.nip34Git.repository.tags.DescriptionTag +import com.vitorpamplona.quartz.nip34Git.repository.tags.NameTag + +/** The display name — the `name` tag. */ +fun TagArray.projectName(): String? = fastFirstNotNullOfOrNull(NameTag::parse) + +/** The description — the `description` tag. */ +fun TagArray.projectDescription(): String? = fastFirstNotNullOfOrNull(DescriptionTag::parse) + +/** The member repositories whose `a` tags are well-formed (malformed ones are skipped). */ +fun TagArray.projectMembers(): List = fastMapNotNullDense(ProjectMemberTag::parse) + +/** The discussion channel reference — the `buzz-channel` tag. */ +fun TagArray.projectChannel(): String? = fastFirstNotNullOfOrNull(ChannelTag::parse) + +/** The raw `buzz-visibility` value, uninterpreted. */ +fun TagArray.projectVisibilityCode(): String? = fastFirstNotNullOfOrNull(VisibilityTag::parse) + +/** The interpreted visibility: [ProjectVisibility.UNLISTED] only for exactly `unlisted`. */ +fun TagArray.projectVisibility(): ProjectVisibility = ProjectVisibility.interpret(projectVisibilityCode()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ChannelTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ChannelTag.kt new file mode 100644 index 0000000000..eb37af499d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ChannelTag.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.mpProjects.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The NIP-MP `buzz-channel` tag — the UUID of the channel a project's discussion lives in. + * Metadata only: it neither routes nor scopes the event (projects are global), and a value + * the viewer cannot resolve must render as "no channel link", never as a broken one. The + * relay bounds it to [MAX_BYTES] and does not interpret it; Buzz's writer requires a UUID. + * Ground truth: `build_project` in Buzz's `buzz-sdk/src/builders.rs`. + */ +object ChannelTag { + const val TAG_NAME = "buzz-channel" + const val MAX_BYTES = 256 + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Tag): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(channelId: String) = arrayOf(TAG_NAME, channelId) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ProjectMemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ProjectMemberTag.kt new file mode 100644 index 0000000000..2f4b019bad --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ProjectMemberTag.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.mpProjects.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.utils.arrayOfNotNull + +/** + * One member repository of a NIP-MP project: the NIP-34 repository announcement's + * [address] (`30617::`) and an optional, opaque, unauthenticated + * [relayHint]. Member identity is the coordinate alone — two tags naming one coordinate with + * different hints are duplicates. + */ +@Immutable +data class ProjectMember( + val address: Address, + val relayHint: String? = null, +) { + /** The canonical coordinate string, the identity members are compared by. */ + val coordinate: String get() = address.toValue() +} + +/** + * A NIP-MP member `a` tag: `["a", "30617::"]` or + * `["a", "30617::", ""]`. + * + * The coordinate grammar is stricter than a generic NIP-01 address: the kind is the literal + * `30617` (a repository *announcement*, never `30618` state), the owner is exactly 64 + * **lowercase** hex characters (`#a` matching is byte-exact, so an uppercase owner would be + * invisible to readers), and the repo `d` is non-empty and taken verbatim — the value splits + * on the first two colons only, so a repo `d` containing `:` stays addressable. The relay + * hint is never parsed or validated by content. Ground truth: + * `parse_project_member_coordinate` in Buzz's `buzz-relay/src/handlers/ingest.rs` and + * `ProjectMemberCoord` in `buzz-sdk/src/builders.rs`. + */ +object ProjectMemberTag { + const val TAG_NAME = "a" + const val MEMBER_KIND = GitRepositoryEvent.KIND + private const val MEMBER_KIND_SEGMENT = "30617" + + /** Parses a strict member coordinate, or null when it is malformed. */ + fun parseCoordinate(coordinate: String): Address? { + val parts = coordinate.split(':', limit = 3) + if (parts.size != 3) return null + val (kind, owner, repoD) = parts + if (kind != MEMBER_KIND_SEGMENT) return null + if (!isLowercaseHex64(owner)) return null + if (repoD.isEmpty()) return null + return Address(MEMBER_KIND, owner, repoD) + } + + fun isValidCoordinate(coordinate: String) = parseCoordinate(coordinate) != null + + /** + * Parses a member tag with the arity (two or three elements) and coordinate rules the + * relay enforces; null for anything else. + */ + fun parse(tag: Tag): ProjectMember? { + if (tag.size !in 2..3 || tag[0] != TAG_NAME) return null + val address = parseCoordinate(tag[1]) ?: return null + return ProjectMember(address, tag.getOrNull(2)) + } + + fun assemble( + coordinate: String, + relayHint: String? = null, + ) = arrayOfNotNull(TAG_NAME, coordinate, relayHint) + + fun assemble(member: ProjectMember) = assemble(member.coordinate, member.relayHint) + + fun assemble( + owner: HexKey, + repoD: String, + relayHint: String? = null, + ) = assemble("$MEMBER_KIND_SEGMENT:$owner:$repoD", relayHint) + + private fun isLowercaseHex64(value: String): Boolean { + if (value.length != 64) return false + for (c in value) { + if (c !in '0'..'9' && c !in 'a'..'f') return false + } + return true + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ProjectVisibility.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ProjectVisibility.kt new file mode 100644 index 0000000000..b66efa433f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/ProjectVisibility.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.mpProjects.tags + +/** + * A NIP-MP project's listing visibility. Absent or unrecognized values read as [LISTED]: a + * typo in a metadata field is not a privacy signal, so it must never hide a project. + */ +enum class ProjectVisibility( + val code: String, +) { + LISTED("listed"), + UNLISTED("unlisted"), + ; + + companion object { + /** The writer-side parse: only the two defined values. */ + fun parseStrict(code: String): ProjectVisibility? = + when (code) { + LISTED.code -> LISTED + UNLISTED.code -> UNLISTED + else -> null + } + + /** The reader-side interpretation: anything but `unlisted` is [LISTED]. */ + fun interpret(code: String?): ProjectVisibility = if (code == UNLISTED.code) UNLISTED else LISTED + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/VisibilityTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/VisibilityTag.kt new file mode 100644 index 0000000000..ded41b541a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/tags/VisibilityTag.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.mpProjects.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The NIP-MP `buzz-visibility` tag — `listed` (the default) or `unlisted`. The relay bounds + * it to [MAX_BYTES] and does not interpret it; readers apply [ProjectVisibility.interpret]. + */ +object VisibilityTag { + const val TAG_NAME = "buzz-visibility" + const val MAX_BYTES = 256 + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME + + /** The raw value, uninterpreted. */ + fun parse(tag: Tag): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1] + } + + fun assemble(visibility: ProjectVisibility) = arrayOf(TAG_NAME, visibility.code) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/AttestationConditions.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/AttestationConditions.kt index afa11d275a..6189252748 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/AttestationConditions.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/AttestationConditions.kt @@ -95,5 +95,83 @@ data class AttestationConditions( /** True when [conditions] is a syntactically valid canonical conditions string. */ fun isValid(conditions: String): Boolean = parse(conditions) != null + + /** + * True when an authentication event signed at [authCreatedAt] satisfies every time clause + * in [conditions]. Both operators are strict (equality satisfies neither) and every clause + * must pass, including repeated ones — so this walks the raw string rather than [parse], + * which keeps only the last clause of each kind. `kind=` is deliberately ignored: Buzz + * treats the credential as connection-wide at admission. Ground truth: + * `verify_auth_tag_for_auth_event` in `buzz-sdk/src/nip_oa.rs`, enforced by the relay on + * NIP-42 AUTH, NIP-98 HTTP, Git and media admission. + */ + fun timeBoundsAllow( + conditions: String, + authCreatedAt: Long, + ): Boolean { + if (conditions.isEmpty()) return true + for (clause in conditions.split("&")) { + when { + clause.startsWith("created_at<") -> { + val bound = clause.removePrefix("created_at<").toLongOrNull() ?: return false + if (authCreatedAt >= bound) return false + } + clause.startsWith("created_at>") -> { + val bound = clause.removePrefix("created_at>").toLongOrNull() ?: return false + if (authCreatedAt <= bound) return false + } + } + } + return true + } + + /** + * True when every clause in [conditions] applies to an event of [kind] signed at + * [createdAt]: `kind=` must match and both time bounds hold (strictly). Unlike + * [timeBoundsAllow], `kind=` counts - this is the check for an attestation carried on an + * event (e.g. an agent's kind-0 profile), not for connection admission. Any clause it + * does not recognize fails. Mirrors `profile_valid_oa_owner_pubkey` in Buzz's + * `desktop/src-tauri/src/nostr_convert.rs`. + */ + fun appliesToEvent( + conditions: String, + kind: Int, + createdAt: Long, + ): Boolean { + if (conditions.isEmpty()) return true + for (clause in conditions.split("&")) { + val ok = + when { + clause.startsWith("kind=") -> clause.removePrefix("kind=").toIntOrNull() == kind + clause.startsWith("created_at<") -> clause.removePrefix("created_at<").toLongOrNull()?.let { createdAt < it } ?: false + clause.startsWith("created_at>") -> clause.removePrefix("created_at>").toLongOrNull()?.let { createdAt > it } ?: false + else -> false + } + if (!ok) return false + } + return true + } + + /** + * The last second at which [conditions] still admit an authentication (the tightest + * `created_at<` bound minus one), or null when there is no upper bound. + */ + fun validUntil(conditions: String): Long? = + conditions + .split("&") + .mapNotNull { if (it.startsWith("created_at<")) it.removePrefix("created_at<").toLongOrNull() else null } + .minOrNull() + ?.let { it - 1 } + + /** + * The first second at which [conditions] admit an authentication (the tightest + * `created_at>` bound plus one), or null when there is no lower bound. + */ + fun validFrom(conditions: String): Long? = + conditions + .split("&") + .mapNotNull { if (it.startsWith("created_at>")) it.removePrefix("created_at>").toLongOrNull() else null } + .maxOrNull() + ?.let { it + 1 } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/OwnerAttestation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/OwnerAttestation.kt index 1142eabb2c..e4be21aa4c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/OwnerAttestation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/OwnerAttestation.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto -import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.sha256.sha256 /** @@ -65,26 +64,54 @@ data class OwnerAttestation( /** * Verifies this attestation authorizes [agentPubKey]. Checks structural validity - * (valid hex keys, canonical conditions, no self-attestation) and then the owner's - * Schnorr signature over the commitment hash. + * (lowercase hex keys and signature, canonical conditions, no self-attestation) and then + * the owner's Schnorr signature over the commitment hash. Time clauses are NOT evaluated + * here; see [verifyForAuthAt]. */ fun verify(agentPubKey: HexKey): Boolean { - if (!agentPubKey.isValid() || !ownerPubKey.isValid()) return false + if (!agentPubKey.isValid() || !ownerPubKey.isValid() || !isLowercaseHex(ownerPubKey, 64)) return false // Self-attestation is explicitly prohibited: the owner must differ from the agent. if (ownerPubKey == agentPubKey) return false if (!AttestationConditions.isValid(conditions)) return false - if (sig.length != 128 || !Hex.isHex(sig)) return false + // Buzz rejects uppercase hex before it reaches its (permissive) decoder. + if (!isLowercaseHex(sig, 128)) return false val message = sha256(commitment(agentPubKey).encodeToByteArray()) return Nip01Crypto.verify(sig.hexToByteArray(), message, ownerPubKey.hexToByteArray()) } + /** + * The check Buzz runs at admission: [verify], plus every `created_at<` / `created_at>` + * clause evaluated against the signed authentication event's [authCreatedAt] (strictly). + * An expired or not-yet-valid credential grants nothing, so the relay treats the agent as a + * non-member (`restricted`). + */ + fun verifyForAuthAt( + agentPubKey: HexKey, + authCreatedAt: Long, + ): Boolean = verify(agentPubKey) && isValidAt(authCreatedAt) + + /** True when the time clauses admit an authentication signed at [authCreatedAt]. */ + fun isValidAt(authCreatedAt: Long): Boolean = AttestationConditions.timeBoundsAllow(conditions, authCreatedAt) + + /** The last second this credential admits an authentication, or null when unbounded. */ + fun validUntil(): Long? = AttestationConditions.validUntil(conditions) + + /** The first second this credential admits an authentication, or null when unbounded. */ + fun validFrom(): Long? = AttestationConditions.validFrom(conditions) + /** Builds the NIP-OA `auth` tag for this attestation. */ fun toTag(): Array = AuthTag.assemble(this) companion object { const val COMMITMENT_PREFIX = "nostr:agent-auth:" + /** Exactly [length] characters of lowercase hex, the only form Buzz accepts on the wire. */ + fun isLowercaseHex( + value: String, + length: Int, + ): Boolean = value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' } + /** SHA-256 of the commitment string — the 32-byte message the owner signs. */ fun commitmentHash( agentPubKey: HexKey, @@ -119,6 +146,35 @@ data class OwnerAttestation( return sign(agentPubKey, conditions.encode(), priv) } + /** + * The owner an agent's event declares through NIP-OA, or null. The event must carry + * **exactly one** `auth` tag (a malformed second one still counts, so there is no + * first-valid-tag fallback), it must verify for the event's own author [pubKey], and every + * condition must apply to this event ([AttestationConditions.appliesToEvent]). + * + * Buzz reads an agent's owner this way off the agent's kind-0 profile: that owner may edit + * the agent's channel messages, and names the agent in the UI. The event's own signature + * is the caller's to have checked. Ground truth: `profile_valid_oa_owner_pubkey` in Buzz's + * `desktop/src-tauri/src/nostr_convert.rs`. + */ + fun verifiedOwnerOf( + pubKey: HexKey, + kind: Int, + createdAt: Long, + tags: Array>, + ): HexKey? { + var authTag: Array? = null + for (tag in tags) { + if (tag.isEmpty() || tag[0] != AuthTag.TAG_NAME) continue + if (authTag != null) return null + authTag = tag + } + val attestation = AuthTag.parse(authTag ?: return null) ?: return null + if (!attestation.verify(pubKey)) return null + if (!AttestationConditions.appliesToEvent(attestation.conditions, kind, createdAt)) return null + return attestation.ownerPubKey + } + /** Parses a NIP-OA `auth` tag; see [AuthTag.parse]. Does not verify the signature. */ fun parse(tag: Tag): OwnerAttestation? = AuthTag.parse(tag) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/tags/AuthTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/tags/AuthTag.kt index 4a48e676aa..668d053967 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/tags/AuthTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/tags/AuthTag.kt @@ -24,7 +24,6 @@ import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.ensure /** @@ -43,21 +42,22 @@ object AuthTag { /** * Parses a well-formed `auth` tag into an [OwnerAttestation]. Returns null on any - * structural problem: wrong name/arity, an owner pubkey that is not 64-char hex, a - * signature that is not 128-char hex, or non-canonical conditions. This is a + * structural problem: wrong name or not exactly 4 elements, an owner pubkey that is not + * 64-char lowercase hex, a signature that is not 128-char lowercase hex, or non-canonical + * conditions (mirrors `parse_auth_tag_fields` in Buzz's `buzz-sdk/src/nip_oa.rs`). This is a * *shape* check only — it does not verify the signature (call * [OwnerAttestation.verify]). */ fun parse(tag: Array): OwnerAttestation? { - ensure(tag.has(3)) { return null } + ensure(tag.size == 4) { return null } ensure(tag[0] == TAG_NAME) { return null } val owner = tag[1] val conditions = tag[2] val sig = tag[3] - ensure(Hex.isHex64(owner)) { return null } - ensure(sig.length == 128 && Hex.isHex(sig)) { return null } + ensure(OwnerAttestation.isLowercaseHex(owner, 64)) { return null } + ensure(OwnerAttestation.isLowercaseHex(sig, 128)) { return null } ensure(AttestationConditions.isValid(conditions)) { return null } return OwnerAttestation(owner, conditions, sig) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicy.kt index e2a1ee4236..5211806a95 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicy.kt @@ -67,13 +67,15 @@ open class BuzzMembershipPolicy( /** * Runs after the NIP-42 proof checks out (see [FullAuthPolicy.authorize]). If the auth event * carries an owner-signed attestation authorizing this agent, and the owner is a member, - * remember the agent as a member for this connection. We never throw here — a missing or - * invalid attestation just means the key is authenticated but not (yet) authorized, which the - * membership gate below handles. + * remember the agent as a member for this connection. Like Buzz, the credential's + * `created_at<` / `created_at>` clauses are checked against the AUTH event's own `created_at`, + * so an expired or not-yet-valid attestation grants nothing. We never throw here — a missing + * or invalid attestation just means the key is authenticated but not (yet) authorized, which + * the membership gate below handles. */ override suspend fun authorize(event: RelayAuthEvent) { val attestation = event.tags.firstNotNullOfOrNull(AuthTag::parse) ?: return - if (attestation.ownerPubKey in members && attestation.verify(event.pubKey)) { + if (attestation.ownerPubKey in members && attestation.verifyForAuthAt(event.pubKey, event.createdAt)) { authorizedAgents.add(event.pubKey) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadState.kt index c68ae5daec..9e6a5cd047 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadState.kt @@ -22,10 +22,12 @@ package com.vitorpamplona.quartz.buzz.rsReadState import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -36,7 +38,9 @@ import com.vitorpamplona.quartz.utils.TimeUtils * `kind:30078` [com.vitorpamplona.quartz.nip01Core.core.Event] subclass — a second class on the * same kind would break [com.vitorpamplona.quartz.utils.EventFactory] dispatch. Instead it is a * thin content/tag layer *on top of* [AppSpecificDataEvent]: - * - the `d` tag is `read-state:` ([dTagFor] / [slotIdFrom]), + * - the `d` tag is `read-state:` ([dTagFor] / [slotIdFrom]), where `` is exactly + * 32 lowercase hex characters ([isValidSlotId]) — any other shape MUST be ignored, since the + * fixed shape is what lets a relay recognize a read-state coordinate structurally, * - a single `["t", "read-state"]` tag enables relay-side filtering, * - `content` is a NIP-44 self-encrypted [ReadStateContent] (conversation key * `nip44(user_privkey, user_pubkey)` — the user's own key on both sides). @@ -47,18 +51,36 @@ object ReadState { const val D_TAG_PREFIX = "read-state:" const val T_TAG_VALUE = "read-state" + /** A `` is exactly this many lowercase hex characters (16 random bytes). */ + const val SLOT_ID_LENGTH = 32 + + /** True when [slotId] is exactly 32 lowercase hex characters (`[0-9a-f]{32}`). */ + fun isValidSlotId(slotId: String): Boolean = slotId.length == SLOT_ID_LENGTH && slotId.all { it in '0'..'9' || it in 'a'..'f' } + + /** A fresh random ``: 16 secure random bytes as lowercase hex. */ + fun newSlotId(): String = RandomInstance.bytes(SLOT_ID_LENGTH / 2).toHexKey() + /** The addressable `d`-tag value for a given slot id. */ fun dTagFor(slotId: String): String = "$D_TAG_PREFIX$slotId" - /** Extracts the `` from a `read-state:` d-tag value, or `null` if it does not match. */ - fun slotIdFrom(dTagValue: String): String? = dTagValue.removePrefix(D_TAG_PREFIX).takeIf { it != dTagValue && it.isNotEmpty() } + /** + * Extracts the `` from a `read-state:` d-tag value, or `null` if it does not + * match — including a slot id that is not exactly 32 lowercase hex characters. + */ + fun slotIdFrom(dTagValue: String): String? { + if (!dTagValue.startsWith(D_TAG_PREFIX)) return null + return dTagValue.substring(D_TAG_PREFIX.length).takeIf(::isValidSlotId) + } - /** True if [tags] are a well-formed NIP-RS coordinate: exactly one `read-state:` d-tag and one `["t","read-state"]`. */ + /** + * True if [tags] are a well-formed NIP-RS coordinate: exactly one `d` tag, holding + * `read-state:<32 lowercase hex>`, and exactly one `["t","read-state"]`. + */ fun isReadState(tags: TagArray): Boolean { val dTags = tags.count { it.size > 1 && it[0] == "d" } val readStateT = tags.count { it.size > 1 && it[0] == HashtagTag.TAG_NAME && it[1] == T_TAG_VALUE } val dValue = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.getOrNull(1) - return dTags == 1 && readStateT == 1 && dValue != null && dValue.startsWith(D_TAG_PREFIX) + return dTags == 1 && readStateT == 1 && dValue != null && slotIdFrom(dValue) != null } /** Adds the NIP-RS `["t", "read-state"]` filter tag to an [AppSpecificDataEvent] builder. */ @@ -74,6 +96,7 @@ object ReadState { signer: NostrSigner, createdAt: Long = TimeUtils.now(), ): AppSpecificDataEvent { + require(isValidSlotId(slotId)) { "NIP-RS slot id must be 32 lowercase hex characters" } val ciphertext = signer.nip44Encrypt(content.encodeToJson(), signer.pubKey) return signer.sign( AppSpecificDataEvent.build(dTagFor(slotId), ciphertext, createdAt) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateContent.kt index fe2662d1cc..cbf27d3a0b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateContent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateContent.kt @@ -22,9 +22,11 @@ package com.vitorpamplona.quartz.buzz.rsReadState import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive /** * The decrypted NIP-RS (Buzz cross-device read-state, `docs/nips/NIP-RS.md`) blob: a per-client @@ -34,6 +36,18 @@ import kotlinx.serialization.json.Json * blobs with an unknown [v]; [contexts] values are "all messages in this context at or before * this time are read." This is the plaintext that gets NIP-44 self-encrypted into an * [com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent] `content` — see [ReadState]. + * + * [contexts] is the **wire** map. It mixes two kinds of entry: + * - frontier entries, keyed by a context id escaped with [ReadStateKeys.escape] (an id that + * starts with the reserved `ov_` / `esc:` gets one `esc:` prepended) — read them with [frontiers]; + * - the manual-unread override layer's `ov_s:` / `ov_c:` / `ov_b:` counters — read them with + * [overrides], build them with [build]. + * + * [decodeFromJson] applies NIP-RS "Content Validation": the blob is rejected as a whole only for a + * structural fault (not an object, missing/non-integer `v`, missing `client_id` or `contexts`, more + * than [MAX_CONTEXT_ENTRIES] entries); a bad individual entry (a value outside `0..2^32-1`, a key + * over [MAX_KEY_BYTES] bytes) is dropped and the rest is kept, and override counters are validated + * as a complete per-context group before anything else touches them. */ @Serializable data class ReadStateContent( @@ -46,9 +60,48 @@ data class ReadStateContent( /** NIP-RS validity: the schema version is the one this client understands and `client_id` is 1–64 chars. */ fun isSupported(): Boolean = v == CURRENT_VERSION && clientId.length in 1..64 + /** + * The read frontiers keyed by **raw** context id: escaped wire keys are unescaped, and the + * reserved `ov_` override entries are left out. + */ + fun frontiers(): Map { + val result = LinkedHashMap() + contexts.forEach { (key, value) -> + if (!ReadStateKeys.isReservedWireKey(key)) result[ReadStateKeys.unescape(key)] = value + } + return result + } + + /** + * The manual-unread override registers keyed by raw context id. Every group that survived + * [decodeFromJson] is complete — a live `ov_s`/`ov_c`/`ov_b` triple or a lone `ov_c` tombstone + * floor — and every group has an `ov_c`, so that key anchors the lookup. + */ + fun overrides(): Map { + val result = LinkedHashMap() + contexts.forEach { (key, clear) -> + if (key.startsWith(ReadStateKeys.OV_CLEAR)) { + val ctx = key.substring(ReadStateKeys.OV_CLEAR.length) + val set = contexts[ReadStateKeys.OV_SET + ctx] ?: 0L + val baseline = contexts[ReadStateKeys.OV_BASELINE + ctx] ?: 0L + result[ctx] = OverrideRegister(set, clear, baseline) + } + } + return result + } + companion object { const val CURRENT_VERSION = 1 + /** A blob with more context entries than this is rejected outright. */ + const val MAX_CONTEXT_ENTRIES = 10_000 + + /** A context key longer than this many UTF-8 bytes is dropped (for an `ov_` key: its whole group). */ + const val MAX_KEY_BYTES = 256 + + /** The largest timestamp / counter value: `2^32 - 1`. */ + const val MAX_VALUE = 4_294_967_295L + val JSON = Json { ignoreUnknownKeys = true @@ -56,6 +109,96 @@ data class ReadStateContent( encodeDefaults = true } - fun decodeFromJson(json: String): ReadStateContent = JSON.decodeFromString(json) + /** + * Parses and validates a decrypted NIP-RS blob. Throws [IllegalArgumentException] (or a + * serialization exception for malformed JSON) when the whole blob must be discarded. A blob + * with an unknown [v] parses with empty [contexts] and fails [isSupported] — NIP-RS says to + * ignore it, and its `contexts` may not even follow this schema. + */ + fun decodeFromJson(json: String): ReadStateContent { + val root = JSON.parseToJsonElement(json) as? JsonObject ?: throw IllegalArgumentException("NIP-RS blob is not a JSON object") + + val v = (root["v"] as? JsonPrimitive)?.takeIf { !it.isString }?.content?.toIntOrNull() + requireNotNull(v) { "NIP-RS blob has a missing or non-integer v" } + + val clientId = (root["client_id"] as? JsonPrimitive)?.takeIf { it.isString }?.content + requireNotNull(clientId) { "NIP-RS blob has no client_id string" } + + if (v != CURRENT_VERSION) return ReadStateContent(v, clientId, emptyMap()) + + val contexts = root["contexts"] as? JsonObject ?: throw IllegalArgumentException("NIP-RS blob has no contexts object") + require(contexts.size <= MAX_CONTEXT_ENTRIES) { "NIP-RS blob has more than $MAX_CONTEXT_ENTRIES context entries" } + + return ReadStateContent(v, clientId, sanitizeContexts(contexts)) + } + + /** + * Builds a blob from raw context ids: [frontiers] are written under their escaped keys, and + * each of [overrides] is canonicalized against its context's frontier first (NIP-RS + * "Mandatory Canonical Publication") — a live register as its three keys, a dead one as a + * lone `ov_c` tombstone floor, a virgin one not at all. + */ + fun build( + clientId: String, + frontiers: Map, + overrides: Map = emptyMap(), + ): ReadStateContent { + val contexts = LinkedHashMap() + frontiers.forEach { (ctx, ts) -> contexts[ReadStateKeys.escape(ctx)] = ts } + overrides.forEach { (ctx, register) -> + register.canonicalize(frontiers[ctx] ?: 0L)?.let { canonical -> + if (canonical.isTombstone()) { + contexts[ReadStateKeys.OV_CLEAR + ctx] = canonical.clear + } else { + contexts[ReadStateKeys.OV_SET + ctx] = canonical.set + contexts[ReadStateKeys.OV_CLEAR + ctx] = canonical.clear + contexts[ReadStateKeys.OV_BASELINE + ctx] = canonical.baseline + } + } + } + return ReadStateContent(CURRENT_VERSION, clientId, contexts) + } + + private fun sanitizeContexts(contexts: JsonObject): Map { + val result = LinkedHashMap() + // Override counters are validated as a group BEFORE any per-entry rule: NIP-RS forbids + // dropping one bad sibling and keeping the rest (a partial group rebuilds a wrong register). + val groups = LinkedHashMap>() + + contexts.forEach { (key, value) -> + val prefix = ReadStateKeys.overridePrefixOf(key) + if (prefix != null) { + groups.getOrPut(key.substring(prefix.length)) { LinkedHashMap() }[prefix] = value + } else { + val ts = value.asUInt32() + if (ts != null && key.encodeToByteArray().size <= MAX_KEY_BYTES) result[key] = ts + } + } + + groups.forEach { (ctx, members) -> + val isLive = members.size == 3 + val isTombstone = members.size == 1 && ReadStateKeys.OV_CLEAR in members + if (!isLive && !isTombstone) return@forEach + + val values = LinkedHashMap() + members.forEach { (prefix, value) -> + val key = prefix + ctx + val counter = value.asUInt32() ?: return@forEach + if (key.encodeToByteArray().size > MAX_KEY_BYTES) return@forEach + values[key] = counter + } + // One invalid sibling rejects the whole group (its frontier entry is kept). + if (values.size == members.size) result.putAll(values) + } + return result + } + + /** An integer JSON number in `0..2^32-1`, or null (a string, a fraction, out of range, null…). */ + private fun JsonElement.asUInt32(): Long? { + val primitive = this as? JsonPrimitive ?: return null + if (primitive.isString) return null + val value = primitive.content.toLongOrNull() ?: return null + return value.takeIf { it in 0..MAX_VALUE } + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateOverrides.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateOverrides.kt new file mode 100644 index 0000000000..e1f025a1d5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateOverrides.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.rsReadState + +/** + * NIP-RS "Reserved Namespace": the key vocabulary of a read-state `contexts` map. + * + * The `ov_` stem belongs to the manual-unread override layer and `esc:` is its escape marker, so a + * raw context id starting with either is written with one extra leading `esc:` ([escape]) and read + * back by stripping exactly one ([unescape]) — a bijection. The `ov_s:`/`ov_c:`/`ov_b:` keys carry + * the **unescaped** raw context id as their suffix. + */ +object ReadStateKeys { + const val RESERVED_STEM = "ov_" + const val ESCAPE = "esc:" + + /** Set counter S of a context's override register. */ + const val OV_SET = "ov_s:" + + /** Clear counter C of a context's override register (also the lone key of a tombstone floor). */ + const val OV_CLEAR = "ov_c:" + + /** Baseline B: the effective frontier when the context was last marked unread. */ + const val OV_BASELINE = "ov_b:" + + /** The frontier wire key for raw context id [ctx]. */ + fun escape(ctx: String): String = if (ctx.startsWith(RESERVED_STEM) || ctx.startsWith(ESCAPE)) ESCAPE + ctx else ctx + + /** The raw context id behind frontier wire key [wireKey]: strips exactly one leading `esc:`. */ + fun unescape(wireKey: String): String = if (wireKey.startsWith(ESCAPE)) wireKey.substring(ESCAPE.length) else wireKey + + /** True for a wire key in the reserved `ov_` namespace — never a frontier entry. */ + fun isReservedWireKey(wireKey: String): Boolean = wireKey.startsWith(RESERVED_STEM) + + /** The override prefix ([OV_SET], [OV_CLEAR] or [OV_BASELINE]) [wireKey] starts with, or null. */ + fun overridePrefixOf(wireKey: String): String? = + when { + wireKey.startsWith(OV_SET) -> OV_SET + wireKey.startsWith(OV_CLEAR) -> OV_CLEAR + wireKey.startsWith(OV_BASELINE) -> OV_BASELINE + else -> null + } +} + +/** + * A NIP-RS manual-unread override register `(S, C, B)` for one context: [set] counts mark-unread + * actions, [clear] counts explicit mark-reads, and [baseline] is the effective frontier captured at + * the most recent mark-unread. Registers merge by componentwise max ([merge]); the override is live + * only while `S > 0 && F <= B && S > C` ([isActive]) — clear wins ties. + * + * Every value is a uint32 (`0..2^32-1`); a counter that would pass that is refused, never wrapped. + */ +data class OverrideRegister( + val set: Long, + val clear: Long, + val baseline: Long, +) { + init { + require(set in 0..ReadStateContent.MAX_VALUE && clear in 0..ReadStateContent.MAX_VALUE && baseline in 0..ReadStateContent.MAX_VALUE) { + "override counters must be uint32" + } + } + + /** Never activated: publishes nothing. */ + fun isVirgin(): Boolean = set == 0L && clear == 0L + + /** The tombstone-floor shape `(0, C, 0)` with `C > 0`: published as a lone `ov_c` key. */ + fun isTombstone(): Boolean = set == 0L && baseline == 0L && clear > 0L + + /** The liveness predicate against the merged effective [frontier]. */ + fun isActive(frontier: Long): Boolean = set > 0 && frontier <= baseline && set > clear + + /** Componentwise max — the only merge rule. */ + fun merge(other: OverrideRegister): OverrideRegister = OverrideRegister(maxOf(set, other.set), maxOf(clear, other.clear), maxOf(baseline, other.baseline)) + + /** + * The register as it must be published against [frontier]: unchanged while live, compacted to + * the tombstone floor `(0, max(S, C), 0)` once dead, and null (omit it) when virgin. + */ + fun canonicalize(frontier: Long): OverrideRegister? = + when { + isVirgin() -> null + isActive(frontier) -> this + else -> OverrideRegister(0, maxOf(set, clear), 0) + } + + /** + * Mark-unread at the current effective [frontier]: `S = max(S, C) + 1`, `B = frontier`. Null when + * `max(S, C)` is already the uint32 maximum — the action must then be refused. + */ + fun markUnread(frontier: Long): OverrideRegister? { + val top = maxOf(set, clear) + if (top >= ReadStateContent.MAX_VALUE) return null + return OverrideRegister(top + 1, clear, frontier) + } + + /** + * Explicit mark-read, evaluated against the frontier *after* the caller advanced it + * ([frontierAfter]): `C = max(S, C) + 1`. At the uint32 ceiling the counters stay as they are and + * the action succeeds only if the override is already inactive; null means it must be reported + * as failed. + */ + fun markRead(frontierAfter: Long): OverrideRegister? { + val top = maxOf(set, clear) + if (top < ReadStateContent.MAX_VALUE) return OverrideRegister(set, top + 1, baseline) + return if (isActive(frontierAfter)) null else this + } + + companion object { + val VIRGIN = OverrideRegister(0, 0, 0) + } +} + +/** + * The effective read state across every supported blob a user published: frontiers and override + * registers each merged by max. [isUnread] is the NIP-RS verdict + * `latest_message_ts > F || override_active(S, C, B, F)`. + */ +class MergedReadState( + val frontiers: Map, + val overrides: Map, +) { + /** The merged frontier of raw context [ctx], or 0 when none was published. */ + fun frontier(ctx: String): Long = frontiers[ctx] ?: 0L + + /** True while [ctx] carries a live manual-unread override. */ + fun isOverrideActive(ctx: String): Boolean = overrides[ctx]?.isActive(frontier(ctx)) == true + + /** Whether [ctx] reads as unread given the `created_at` of its newest message. */ + fun isUnread( + ctx: String, + latestMessageTs: Long, + ): Boolean = latestMessageTs > frontier(ctx) || isOverrideActive(ctx) + + companion object { + /** Merges [blobs], skipping any that fail [ReadStateContent.isSupported]. */ + fun merge(blobs: List): MergedReadState { + val frontiers = LinkedHashMap() + val overrides = LinkedHashMap() + blobs.forEach { blob -> + if (!blob.isSupported()) return@forEach + blob.frontiers().forEach { (ctx, ts) -> + val current = frontiers[ctx] + if (current == null || ts > current) frontiers[ctx] = ts + } + blob.overrides().forEach { (ctx, register) -> + overrides[ctx] = overrides[ctx]?.merge(register) ?: register + } + } + return MergedReadState(frontiers, overrides) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzChatMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzChatMessage.kt new file mode 100644 index 0000000000..6308a134b2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzChatMessage.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.stream + +import com.vitorpamplona.quartz.buzz.threading.buzzThread +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip29RelayGroups.hTag +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A Buzz channel message: a plain NIP-29 kind-9 [ChatEvent] in Buzz's tag shape. This is what every + * Buzz client writes today (desktop, mobile, CLI); kind 40002 ([StreamMessageV2Event]) is only a + * read-compat tail from the 10002 -> 40001 -> 40002 migration. + * + * Mirrors `build_message` in `buzz-sdk/src/builders.rs`, in its tag order: + * - `["h", channel]` + * - the NIP-10 thread markers when it is a reply ([buzzThread]: one `reply` marker for a direct + * reply, `root` + `reply` for a nested one) - derive [threadRoot] with + * `buzzThreadRootForReplyTo` on the parent or the relay rejects the ancestry + * - one `p` per mention (the author may mention themselves) + * - `["broadcast", "1"]` for a reply that should also show in the channel timeline instead of + * only in its thread + * + * Media (`imeta`) and NIP-30 `emoji` tags follow via [initializer]. + */ +object BuzzChatMessage { + fun build( + channelId: String, + content: String, + threadRoot: HexKey? = null, + replyTo: HexKey? = null, + mentions: List = emptyList(), + broadcast: Boolean = false, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = ChatEvent.build(content, createdAt) { + hTag(channelId) + if (replyTo != null) buzzThread(threadRoot ?: replyTo, replyTo) + mentions(mentions) + if (broadcast) broadcast() + initializer() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzEditTagOverlay.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzEditTagOverlay.kt new file mode 100644 index 0000000000..544a9fa1c3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzEditTagOverlay.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.stream + +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +/** + * The tags a Buzz message renders with once a kind-40003 edit is applied: the original message's + * tags with the edit's attachments, custom emoji and added mentions overlaid. Buzz's clients + * resolve this on read (the relay stores the edit as-is), so this mirrors `applyEditTagOverlay` + * in Buzz's `desktop/src/features/messages/lib/applyEditTagOverlay.mjs`, in its output order: + * + * - `imeta` comes **only** from the edit. The edit re-sends the full attachment set, so an edit + * without any `imeta` means "no attachments". + * - `emoji` (NIP-30) comes from the edit when the edit has any, otherwise the original's stay: + * an older or foreign client's edit carries none, and dropping them would break a `:shortcode:` + * the original rendered. + * - `p` tags in the edit are added to the original's (only newly added mentions notify). + * - `mention` reference tags are replaced by the edit's when it carries `["buzz:mention-snapshot"]`, + * except the send-time `agent-address` ones, which always stay. + * - everything else (`h`, `e`, thread markers, `broadcast`, …) comes from the original only, so an + * edit can't move a message to another channel or thread. + */ +object BuzzEditTagOverlay { + const val IMETA = "imeta" + const val EMOJI = "emoji" + const val PUBKEY = "p" + const val MENTION = "mention" + const val MENTION_SNAPSHOT = "buzz:mention-snapshot" + const val AGENT_ADDRESS_MARKER = "agent-address" + + private fun isAgentAddressMention(tag: Array) = tag.size > 2 && tag[0] == MENTION && tag[2] == AGENT_ADDRESS_MARKER + + fun apply( + originalTags: TagArray, + editTags: TagArray?, + ): TagArray { + if (editTags == null) return originalTags + + val editEmoji = editTags.filter { it.isNotEmpty() && it[0] == EMOJI } + val hasMentionSnapshot = editTags.any { it.isNotEmpty() && it[0] == MENTION_SNAPSHOT } + val editMentions = editTags.filter { it.isNotEmpty() && it[0] == MENTION && !isAgentAddressMention(it) } + + val fromOriginal = + originalTags.filter { tag -> + if (tag.isEmpty()) return@filter true + when { + tag[0] == IMETA -> false + editEmoji.isNotEmpty() && tag[0] == EMOJI -> false + hasMentionSnapshot && tag[0] == MENTION && !isAgentAddressMention(tag) -> false + else -> true + } + } + val fromEdit = editTags.filter { it.isNotEmpty() && (it[0] == IMETA || it[0] == PUBKEY || it[0] == MENTION_SNAPSHOT) } + + return (fromOriginal + fromEdit + editEmoji + editMentions).toTypedArray() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/CanvasEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/CanvasEvent.kt index 9275579285..573b7926c0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/CanvasEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/CanvasEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.buzz.stream import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.buzz.stream.tags.ExpectedRevisionTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -33,7 +34,13 @@ import com.vitorpamplona.quartz.utils.TimeUtils * A Buzz channel canvas (shared markdown document), `kind:40100`. * * Mirrors `build_set_canvas` in Buzz's `buzz-sdk/src/builders.rs`: a single `h` - * channel tag with the markdown document in [content]. + * channel tag with the markdown document in [content], plus an optional + * `["expected-revision", …]` compare-and-swap precondition ([ExpectedRevisionTag]). + * + * The live canvas is the newest revision under `created_at DESC, id ASC` (a same-second tie goes + * to the smallest id — see [isNewerHeadThan]). A write that edits a loaded head stamps + * `created_at = max(now, head.created_at + 1)` ([writeCreatedAt]) so it always sorts strictly + * ahead of the head it asserts. */ @Immutable class CanvasEvent( @@ -55,17 +62,82 @@ class CanvasEvent( fun channel() = tags.channel() + /** The `expected-revision` precondition: [ExpectedRevisionTag.NONE], a head id, or null for an unconditional write. */ + fun expectedRevision() = tags.firstNotNullOfOrNull(ExpectedRevisionTag::parse) + + /** + * True when this revision displaces [other] as the channel's live canvas under the relay's + * `created_at DESC, id ASC` read order: a newer `created_at` wins, and a same-second tie goes to + * the lexicographically smallest id (not to whichever arrived last). + */ + fun isNewerHeadThan(other: Event?): Boolean = isNewerHead(createdAt, id, other?.createdAt, other?.id) + companion object { const val KIND = 40100 + /** + * The furthest a canvas head may sit in the future before a writer refuses to ratchet past + * it (seconds). Mirrors `CANVAS_MAX_FUTURE_SKEW_SECS` in `buzz-sdk/src/builders.rs`: stamping + * `max(now, head + 1)` against a poisoned far-future head would drag every later write along. + */ + const val MAX_HEAD_FUTURE_SKEW_SECS = 60L + + /** + * How far in the future the relay accepts a canvas `created_at` (seconds) — + * `CANVAS_MAX_INGEST_FUTURE_SECS` in `buzz-relay/src/handlers/ingest.rs`. A write stamped + * by [writeCreatedAt] never exceeds `now + MAX_HEAD_FUTURE_SKEW_SECS + 1`, well inside it. + */ + const val RELAY_MAX_FUTURE_SECS = 300L + + /** OK-message prefix the relay uses when an `expected-revision` precondition fails. */ + const val CONFLICT_PREFIX = "conflict:" + fun build( channelId: String, markdown: String, createdAt: Long = TimeUtils.now(), + expectedRevision: String? = null, initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, markdown, createdAt) { channel(channelId) + expectedRevision?.let { addUnique(ExpectedRevisionTag.assemble(it)) } initializer() } + + /** + * Writer discipline for a canvas write composed against a head stamped [headCreatedAt]: + * `max(now, headCreatedAt + 1)`, so the write sorts strictly ahead of that head. Returns + * null — refuse the write — when the head sits more than [MAX_HEAD_FUTURE_SKEW_SECS] in the + * future. With no head ([headCreatedAt] null) it is just [now]. + * + * Mirrors `canvas_write_created_at` in `buzz-sdk/src/builders.rs`. + */ + fun writeCreatedAt( + headCreatedAt: Long?, + now: Long = TimeUtils.now(), + ): Long? { + if (headCreatedAt == null) return now + if (headCreatedAt > now + MAX_HEAD_FUTURE_SKEW_SECS) return null + return maxOf(now, headCreatedAt + 1) + } + + /** + * `created_at DESC, id ASC` head selection: true when revision ([createdAt], [id]) displaces + * the current head ([headCreatedAt], [headId]). No head at all is always displaced; the same + * revision never displaces itself. + */ + fun isNewerHead( + createdAt: Long, + id: String, + headCreatedAt: Long?, + headId: String?, + ): Boolean { + if (headCreatedAt == null || headId == null) return true + if (createdAt != headCreatedAt) return createdAt > headCreatedAt + return id < headId + } + + /** True when a relay OK message reports a failed `expected-revision` compare-and-swap. */ + fun isConflict(okMessage: String?): Boolean = okMessage?.startsWith(CONFLICT_PREFIX) == true } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/StreamMessageV2Event.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/StreamMessageV2Event.kt index e6afeda6de..a726cd5350 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/StreamMessageV2Event.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/StreamMessageV2Event.kt @@ -36,6 +36,10 @@ import com.vitorpamplona.quartz.utils.TimeUtils * replaceable range `10002`, which was wrong). Channel-scoped via the `h` tag; the * text lives in [content], optionally carrying `p` mentions and a `broadcast` flag. * See `KIND_STREAM_MESSAGE_V2` in Buzz's `buzz-core/src/kind.rs`. + * + * **Read-only in practice.** Buzz's own clients (and Amethyst) now write channel messages as kind 9 + * in the same tag shape ([BuzzChatMessage]); this class stays so older 40002 messages still parse + * and render. [build] is kept for tests and tooling. */ @Immutable class StreamMessageV2Event( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessagePayload.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessagePayload.kt index 7bdcdf8333..48cfd99f36 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessagePayload.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessagePayload.kt @@ -35,8 +35,10 @@ import kotlinx.serialization.json.Json * present; [actor] is present on every variant the relay emits today, and the rest are * variant-specific. Unknown keys are ignored for forward compatibility, so a relay that * grows a new field or a new [type] degrades to a plain line instead of failing to parse. + * Two relay-internal variants carry no [actor] at all: [ADMIN_KICK] and [CHANNEL_AUTO_ARCHIVED]. * - * The complete vocabulary, read off `side_effects.rs` / `command_executor.rs`: + * The complete vocabulary, read off `side_effects.rs` / `command_executor.rs` / + * `admin_outbox_worker.rs` / `main.rs`: * * | [type] | carries | emitted when | * |--|--|--| @@ -51,6 +53,8 @@ import kotlinx.serialization.json.Json * | [CHANNEL_CREATED] / [CHANNEL_DELETED] | [actor] | channel lifecycle | * | [MESSAGE_DELETED] | [actor], [targetEventId], optional [actionId] / [reasonCode] / [publicReason] | a message was deleted (moderation tombstone) | * | [DM_CREATED] | [actor], [participants] | a DM channel was opened | + * | [ADMIN_KICK] | [target], [actionId] — **no** [actor] | a relay/workspace administrator removed [target] (delivered by the admin outbox worker) | + * | [CHANNEL_AUTO_ARCHIVED] | nothing | the ephemeral-channel reaper archived an idle channel | */ @Serializable data class SystemMessagePayload( @@ -80,7 +84,7 @@ data class SystemMessagePayload( */ fun subject(): String? = when (type) { - MEMBER_JOINED, MEMBER_LEFT, MEMBER_REMOVED -> target ?: actor + MEMBER_JOINED, MEMBER_LEFT, MEMBER_REMOVED, ADMIN_KICK -> target ?: actor else -> actor } @@ -108,6 +112,12 @@ data class SystemMessagePayload( const val MESSAGE_DELETED = "message_deleted" const val DM_CREATED = "dm_created" + /** An administrator removed [target]; relay-authored with no [actor] (`admin_outbox_worker.rs`). */ + const val ADMIN_KICK = "admin_kick" + + /** The relay's ephemeral-channel reaper archived the channel; carries no [actor] (`main.rs`). */ + const val CHANNEL_AUTO_ARCHIVED = "channel_auto_archived" + /** Searchable, anyone can join. */ const val VISIBILITY_OPEN = "open" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/tags/ExpectedRevisionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/tags/ExpectedRevisionTag.kt new file mode 100644 index 0000000000..308d3a7f16 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/stream/tags/ExpectedRevisionTag.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.stream.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.ensure + +/** + * The optional compare-and-swap precondition on a Buzz canvas write (`kind:40100`): + * `["expected-revision", "none" | <64-hex event id>]`. + * + * A 64-hex id names the canvas head the write was composed against; the literal [NONE] asserts + * that no head exists yet. The relay reads the live head under a lock and rejects a mismatch with + * a `conflict:` OK message before storing anything; a canvas write without the tag is an + * unconditional append. + * + * Mirrors `build_set_canvas` in Buzz's `buzz-sdk/src/builders.rs` and + * `parse_canvas_expected_revision` in `buzz-relay/src/handlers/ingest.rs`: the tag has exactly one + * value, and the relay rejects (`invalid:`) any other shape, so [parse] only accepts that one. + */ +class ExpectedRevisionTag { + companion object { + const val TAG_NAME = "expected-revision" + + /** The sentinel value asserting the channel has no canvas head yet. */ + const val NONE = "none" + + /** True for [NONE] or a 64-character hex event id — the only values the relay accepts. */ + fun isValidValue(value: String): Boolean = value == NONE || (value.length == 64 && Hex.isHex(value)) + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag.size == 2) { return null } + ensure(isValidValue(tag[1])) { return null } + return tag[1] + } + + fun assemble(expectedRevision: String): Array { + require(isValidValue(expectedRevision)) { + "expected-revision must be the literal \"none\" or a 64-character hex event id (got \"$expectedRevision\")" + } + return arrayOf(TAG_NAME, expectedRevision) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/AcpSessionPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/AcpSessionPolicy.kt new file mode 100644 index 0000000000..393c1a9612 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/AcpSessionPolicy.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.teamCatalog + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive + +/** + * Whether one ACP conversation is shared by a whole channel ([CHANNEL], the default) or kept + * per thread ([THREAD]). Wire values are `"channel"` / `"thread"`; like upstream's lenient + * `Deserialize`, anything other than the string `"thread"` (including `null`, a number or an + * unknown string) reads as [CHANNEL]. Ground truth: `AcpSessionPolicy` in Buzz's + * `desktop/src-tauri/src/managed_agents/session_policy.rs`. + */ +@Serializable(with = AcpSessionPolicySerializer::class) +enum class AcpSessionPolicy( + val code: String, +) { + CHANNEL("channel"), + THREAD("thread"), +} + +object AcpSessionPolicySerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("buzz.AcpSessionPolicy", PrimitiveKind.STRING) + + override fun serialize( + encoder: Encoder, + value: AcpSessionPolicy, + ) = encoder.encodeString(value.code) + + override fun deserialize(decoder: Decoder): AcpSessionPolicy { + val element = (decoder as? JsonDecoder)?.decodeJsonElement() + val value = + if (element == null) { + decoder.decodeString() + } else { + (element as? JsonPrimitive)?.takeIf { it.isString }?.content + } + return if (value == AcpSessionPolicy.THREAD.code) AcpSessionPolicy.THREAD else AcpSessionPolicy.CHANNEL + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogContent.kt new file mode 100644 index 0000000000..6f8d34b885 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogContent.kt @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.teamCatalog + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * The JSON body of a Buzz team-catalog event ([TeamCatalogEvent], `kind:30178`): a team plus + * every member's safe, portable definition embedded in full, so a recipient can rebuild the + * team without reading the owner's personas. + * + * Mirrors `TeamCatalogContent` in Buzz's `desktop/src-tauri/src/managed_agents/team_catalog.rs` + * field for field **and in declaration order**: serde emits in that order, the content bytes + * are the canonical projection (they fix the event id, and the owner's freshness reconcile + * compares them), so a reorder here would read as a changed team. Optional fields are omitted + * when absent ([JSON] has `encodeDefaults = false`, matching serde's `skip_serializing_if`), + * while [v] and [members] are always written and are required on read — a body without `v` + * must not masquerade as v1. + * + * [validate] ports the v1 size/shape contract (`validate_team_catalog_content`); see its KDoc + * for the parts it leaves out. + */ +@Serializable +data class TeamCatalogContent( + /** Schema version; readers must refuse anything but [SCHEMA_VERSION]. */ + val v: Int, + val name: String, + val description: String? = null, + val instructions: String? = null, + /** Members in the team's own order — part of the canonical bytes. */ + val members: List, +) { + fun encodeToJson(): String = JSON.encodeToString(this) + + /** + * Returns the first reason upstream's reader would refuse this body, or null when it + * passes: the schema version, the team name/description/instructions bounds, the member + * cap, every member's field contract, unique member keys, and the total encoded size. + * + * Not ported: the concealment rule on visible text (`validate_visible_text` — + * invisible/bidi control rejection) and the recomputation of `projection_hash` from the + * member's own fields; only the hint pair's completeness and hash shape are checked. The + * avatar URL check approximates upstream's WHATWG parse (see [isSafeCatalogAvatarUrl]). + */ + fun validate(): String? { + if (v != SCHEMA_VERSION) return "unsupported team catalog schema version $v (expected $SCHEMA_VERSION)" + if (name.isBlank()) return "invalid team projection: the team name is empty" + if (name.utf8Size() > MAX_NAME_BYTES) return "team too large to share: the team name exceeds $MAX_NAME_BYTES bytes" + if (description != null && description.utf8Size() > MAX_TEXT_BYTES) return "team too large to share: the team description exceeds $MAX_TEXT_BYTES bytes" + if (instructions != null && instructions.utf8Size() > MAX_INSTRUCTIONS_BYTES) return "team too large to share: the team instructions exceed $MAX_INSTRUCTIONS_BYTES bytes" + if (members.size > MAX_MEMBERS) return "team too large to share: ${members.size} members (limit $MAX_MEMBERS)" + val seen = HashSet() + for (member in members) { + member.validate()?.let { return it } + if (!seen.add(member.memberKey)) return "invalid team projection: '${member.displayName}' repeats the member key '${member.memberKey}' of an earlier member" + } + val encoded = encodeToJson().utf8Size() + if (encoded > MAX_TOTAL_BYTES) return "team too large to share: the projection is $encoded bytes (limit $MAX_TOTAL_BYTES)" + return null + } + + fun isValid() = validate() == null + + companion object { + const val SCHEMA_VERSION = 1 + + const val MAX_MEMBERS = 64 + const val MAX_NAME_BYTES = 256 + const val MAX_TEXT_BYTES = 4 * 1024 + const val MAX_INSTRUCTIONS_BYTES = 16 * 1024 + const val MAX_SYSTEM_PROMPT_BYTES = 16 * 1024 + const val MAX_AVATAR_URL_BYTES = 32 * 1024 + const val MAX_NAME_POOL_ENTRIES = 64 + const val MAX_TOTAL_BYTES = 192 * 1024 + const val MAX_MEMBER_KEY_BYTES = 128 + const val MAX_IDENTIFIER_BYTES = 256 + const val MAX_BUILTIN_SLUG_BYTES = 128 + const val PROJECTION_HASH_HEX_LEN = 64 + + /** The `respond_to` modes upstream accepts (`RespondTo::parse_wire`). */ + val RESPOND_TO_MODES = setOf("owner-only", "allowlist", "anyone") + + val JSON = + Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = false + } + + fun decodeFromJson(json: String): TeamCatalogContent = JSON.decodeFromString(json) + + /** + * Upstream's avatar allowlist (`is_safe_catalog_avatar_url`): an `http(s)` URL of at + * most 2048 bytes with no whitespace or parentheses, an inline + * `data:image/svg+xml,` of at most 8192 bytes, or a strict-base64 inline + * png/jpeg/gif/webp of at most 256 KiB. Upstream also runs the WHATWG URL parser on + * the `http(s)` form; this checks only the scheme, so a malformed authority passes here. + */ + fun isSafeCatalogAvatarUrl(url: String): Boolean { + if (!url.startsWith("data:")) { + if (url.utf8Size() > 2048) return false + if (url.any { (it.isWhitespace() && it != '\u0085') || it == '\uFEFF' || it == '(' || it == ')' }) return false + val lower = url.lowercase() + return lower.startsWith("http:") || lower.startsWith("https:") + } + if (url.startsWith("data:image/svg+xml,")) return url.length <= 8192 + if (url.length > 256 * 1024) return false + val rest = url.removePrefix("data:image/") + if (rest.length == url.length) return false + for (mime in listOf("png", "jpeg", "gif", "webp")) { + val prefix = "$mime;base64," + if (!rest.startsWith(prefix)) continue + val b64 = rest.substring(prefix.length) + val trimmed = b64.trimEnd('=') + if (b64.length - trimmed.length <= 2 && + trimmed.all { it in 'A'..'Z' || it in 'a'..'z' || it in '0'..'9' || it == '+' || it == '/' } && + b64.length % 4 == 0 + ) { + return true + } + } + return false + } + + internal fun String.utf8Size() = encodeToByteArray().size + } +} + +/** + * One member's safe definition inside a [TeamCatalogContent]. Mirrors `TeamCatalogMember` in + * Buzz's `desktop/src-tauri/src/managed_agents/team_catalog.rs`, in declaration order. + * + * [memberKey] is an opaque, publication-unique identity (a domain-separated SHA-256 of the + * publisher's local id) — never resolve it as a `kind:30175` coordinate. [builtinSlug] and + * [projectionHash] are an all-or-nothing reuse *hint*, not an identity. + */ +@Serializable +data class TeamCatalogMember( + @SerialName("member_key") val memberKey: String, + @SerialName("display_name") val displayName: String, + @SerialName("system_prompt") val systemPrompt: String? = null, + @SerialName("avatar_url") val avatarUrl: String? = null, + val runtime: String? = null, + @SerialName("acp_command") val acpCommand: String? = null, + val model: String? = null, + val provider: String? = null, + @SerialName("name_pool") val namePool: List = emptyList(), + @SerialName("respond_to") val respondTo: String? = null, + val parallelism: Int? = null, + @SerialName("session_policy") val sessionPolicy: AcpSessionPolicy = AcpSessionPolicy.CHANNEL, + @SerialName("builtin_slug") val builtinSlug: String? = null, + @SerialName("projection_hash") val projectionHash: String? = null, +) { + /** The first v1-contract violation (`validate_member` upstream), or null. */ + fun validate(): String? { + val who = displayName + if (memberKey.isBlank()) return "invalid team projection: a member key is empty" + if (memberKey.utf8Size() > TeamCatalogContent.MAX_MEMBER_KEY_BYTES) return "team too large to share: a member key is too long" + if (displayName.isBlank()) return "invalid team projection: a member display name is empty" + if (displayName.utf8Size() > TeamCatalogContent.MAX_NAME_BYTES) return "team too large to share: a member display name is too long" + if (systemPrompt != null && systemPrompt.utf8Size() > TeamCatalogContent.MAX_SYSTEM_PROMPT_BYTES) return "team too large to share: the system prompt for '$who' is too long" + if (avatarUrl != null) { + if (avatarUrl.utf8Size() > TeamCatalogContent.MAX_AVATAR_URL_BYTES) return "team too large to share: the avatar for '$who' is too large" + if (!TeamCatalogContent.isSafeCatalogAvatarUrl(avatarUrl)) return "invalid team projection: the avatar for '$who' uses an unsafe URL scheme" + } + for ((value, label) in listOf(runtime to "runtime", model to "model", provider to "provider")) { + if (value == null) continue + if (value.isBlank()) return "invalid team projection: the $label for '$who' is empty" + if (value.utf8Size() > TeamCatalogContent.MAX_IDENTIFIER_BYTES) return "team too large to share: the $label for '$who' is too long" + } + if (namePool.size > TeamCatalogContent.MAX_NAME_POOL_ENTRIES) return "team too large to share: '$who' has ${namePool.size} name-pool entries" + for (entry in namePool) { + if (entry.isBlank()) return "invalid team projection: a name-pool entry for '$who' is empty" + if (entry.utf8Size() > TeamCatalogContent.MAX_NAME_BYTES) return "team too large to share: a name-pool entry for '$who' is too long" + } + if (respondTo != null && respondTo !in TeamCatalogContent.RESPOND_TO_MODES) return "definition respond_to '$respondTo' is not a recognized mode" + if (parallelism != null && parallelism !in 1..32) return "invalid team projection: parallelism $parallelism for '$who' is out of range" + if ((builtinSlug == null) != (projectionHash == null)) return "invalid team projection: '$who' has an incomplete built-in reuse hint" + if (builtinSlug != null && projectionHash != null) { + if (builtinSlug.isBlank()) return "invalid team projection: the built-in slug for '$who' is empty" + if (builtinSlug.utf8Size() > TeamCatalogContent.MAX_BUILTIN_SLUG_BYTES) return "team too large to share: the built-in slug for '$who' is too long" + if (projectionHash.length != TeamCatalogContent.PROJECTION_HASH_HEX_LEN || + !projectionHash.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } + ) { + return "invalid team projection: the reuse hash for '$who' is not a SHA-256 hex digest" + } + } + return null + } + + private fun String.utf8Size() = encodeToByteArray().size +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogEvent.kt new file mode 100644 index 0000000000..0aeb388e12 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogEvent.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.teamCatalog + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent +import com.vitorpamplona.quartz.buzz.apPersonas.tags.SharedTag +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import kotlinx.coroutines.CancellationException + +/** + * A Buzz team catalog entry (`kind:30178`): the shareable projection of a team — its name, + * description, instructions and every member's safe definition embedded in full + * ([TeamCatalogContent]) — so another community member can adopt the team without reading + * the owner's personas. Addressable by `(owner, 30178, d)` where `d` is the team's stable id, + * the same `d` as its `kind:30176` [com.vitorpamplona.quartz.buzz.teams.TeamEvent]. The two + * kinds are separate so an ordinary team edit (30176) cannot disturb the catalog's share + * state, which lives only on this head's [SharedTag]. + * + * Read access is **author-only unless shared**: the relay serves the head to others only when + * it carries exactly `["shared","true"]`; an untagged head is the durable "published but not + * discoverable" state unsharing produces. A reader keys by `(pubkey, d)`, takes the newest + * head, and only then checks sharing and parses — a newer unshared or malformed head must not + * resurrect an older shared one. + * + * Tags: exactly one `d` (non-empty, at most 64 characters, no control characters or + * whitespace — `single_bounded_d_tag`) and at most one exact `["shared","true"]` + * (`validate_shared_tag`). Ground truth: `build_team_catalog_event` / + * `team_catalog_content_from_event` in Buzz's + * `desktop/src-tauri/src/managed_agents/team_catalog.rs`, the reader in + * `desktop/src-tauri/src/team_catalog.rs`, and `validate_team_catalog_envelope` in + * `buzz-relay/src/handlers/ingest.rs`. + */ +@Immutable +class TeamCatalogEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The team's stable id — the `d` tag (shared with its `kind:30176`). */ + fun teamId() = dTag() + + /** True when the entry is discoverable by the community (`["shared","true"]`). */ + fun isShared() = SharedTag.isShared(tags) + + /** True when the tags pass the relay's ingest envelope check. */ + fun isEnvelopeValid() = envelopeError(tags) == null + + /** + * Parses the body, all-or-nothing like upstream: throws when the JSON is malformed, the + * schema version is not 1, or any field breaks the v1 contract. Use [catalogOrNull]. + */ + fun catalog(): TeamCatalogContent { + val parsed = TeamCatalogContent.decodeFromJson(content) + parsed.validate()?.let { throw IllegalArgumentException(it) } + return parsed + } + + fun catalogOrNull(): TeamCatalogContent? = + try { + catalog() + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + + companion object { + const val KIND = 30178 + + /** Maximum characters in the `d` tag (`single_bounded_d_tag`). */ + const val MAX_D_CHARS = 64 + + /** + * The relay's ingest check for this kind, or null when it passes: exactly one `d` + * (a valueless `["d"]` counts), non-empty, at most [MAX_D_CHARS] characters, with no + * control or whitespace characters; and no `shared` tag other than a single exact + * `["shared","true"]`. + */ + fun envelopeError(tags: TagArray): String? { + var sharedCount = 0 + var dCount = 0 + var d: String? = null + for (tag in tags) { + if (tag.isEmpty()) continue + when (tag[0]) { + SharedTag.TAG_NAME -> sharedCount++ + DTag.TAG_NAME -> { + dCount++ + d = tag.getOrNull(1) ?: "" + } + } + } + if (sharedCount > 0 && !SharedTag.isShared(tags)) return "team-catalog event `shared` tag must be exactly one [\"shared\",\"true\"]" + if (dCount != 1) return "team-catalog event must have exactly one `d` tag (got $dCount)" + return teamIdError(d ?: "") + } + + /** Why [teamId] cannot be a catalog `d` value, or null when it can. */ + fun teamIdError(teamId: String): String? { + if (teamId.isEmpty()) return "team-catalog event `d` tag must not be empty" + var chars = 0 + for (c in teamId) { + if (!c.isLowSurrogate()) chars++ + if (c.isISOControl() || c.isWhitespace()) return "team-catalog event `d` tag must not contain control characters or whitespace" + } + if (chars > MAX_D_CHARS) return "team-catalog event `d` tag too long ($chars chars, max $MAX_D_CHARS)" + return null + } + + /** + * Builds a catalog head the way `build_team_catalog_event` does: `d` = [teamId], plus + * `["shared","true"]` only when [shared] (unsharing republishes without it). The body + * must pass [TeamCatalogContent.validate]. Republishing should pass the replaced + * head's [priorHeadCreatedAt] so the new head sorts after it even when this clock + * lags (upstream signs with `monotonic_created_at`). + */ + fun build( + catalog: TeamCatalogContent, + teamId: String, + shared: Boolean, + priorHeadCreatedAt: Long? = null, + createdAt: Long = PersonaEvent.monotonicCreatedAt(priorHeadCreatedAt), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + teamIdError(teamId)?.let { throw IllegalArgumentException(it) } + catalog.validate()?.let { throw IllegalArgumentException(it) } + return eventTemplate(KIND, catalog.encodeToJson(), createdAt) { + dTag(teamId) + if (shared) addUnique(SharedTag.assemble()) + initializer() + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/threading/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/threading/TagArrayExt.kt index 132ab907b7..d36aec4c89 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/threading/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/threading/TagArrayExt.kt @@ -25,22 +25,60 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag /** - * Positional reader for Buzz's thread e-tags (`["e", id, "", "root"|"reply"]`). - * [MarkedETag.parse] rejects the empty relay slot Buzz emits, so the marker is read - * positionally: index 3 in the canonical 4-element form, tolerating the relay-less - * 3-element form (`["e", id, "reply"]`) with the marker at index 2. + * The `root` / `reply` markers on an event's `e` tags, read exactly the way Buzz's relay reads + * them (`buzz-core/src/nip10.rs`, shared by ingest, ACP anchoring and the CLI): + * + * - only `["e", , , ]` tags with at least 4 elements count, so the marker is + * always at index 3 (a 3-element `["e", id, "reply"]` is not a thread link); + * - the id must be exactly 64 hex characters, otherwise the tag is ignored; + * - the **last** valid occurrence of each marker wins. */ -private fun Array.buzzMarkedEventId(marker: MarkedETag.MARKER): HexKey? { - if (size < 3 || this[0] != MarkedETag.TAG_NAME || this[1].length != 64) return null - // The marker lives at index 3 in the canonical 4-element form. Only fall back to - // index 2 for the EXACT 3-element form: on a 4+-element tag, index 2 is the relay - // hint, and a hint that happened to read "root"/"reply" would misparse. - val markerIndex = if (size == 3) 2 else 3 - return if (this[markerIndex] == marker.code) this[1] else null +data class BuzzThreadMarkers( + val root: HexKey?, + val reply: HexKey?, +) { + /** + * Collapses the markers into this reply's `(root, parent)`, or null when the event is + * top-level. A lone `reply` marker is a direct reply to the thread root, so it is both; a lone + * `root` marker does **not** make a reply (the relay treats the event as top-level). + */ + fun resolve(): Pair? = + when { + reply == null -> null + root == null -> reply to reply + else -> root to reply + } } +private fun isEventIdHex(id: String): Boolean = id.length == 64 && id.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } + +/** Parses the Buzz thread markers from these tags (see [BuzzThreadMarkers]). */ +fun TagArray.buzzThreadMarkers(): BuzzThreadMarkers { + var root: HexKey? = null + var reply: HexKey? = null + for (tag in this) { + if (tag.size < 4 || tag[0] != MarkedETag.TAG_NAME || !isEventIdHex(tag[1])) continue + when (tag[3]) { + MarkedETag.MARKER.ROOT.code -> root = tag[1] + MarkedETag.MARKER.REPLY.code -> reply = tag[1] + } + } + return BuzzThreadMarkers(root, reply) +} + +/** This event's `(root, parent)` when it is a thread reply; null when it is top-level. */ +fun TagArray.buzzThreadAncestry(): Pair? = buzzThreadMarkers().resolve() + +/** + * The thread a reply to an event with these tags belongs under: the event's own resolved root + * when it is itself a reply, else [ownId] (the event starts the thread). This is the root the + * relay derives from the parent (`derive_ancestry_from_parent_tags`) and rejects a mismatch of + * with `root tag does not match thread ancestry`. + */ +fun TagArray.buzzThreadRootForReplyTo(ownId: HexKey): HexKey = buzzThreadAncestry()?.first ?: ownId + /** The thread root this event replies under, from its marked `root` e-tag. */ -fun TagArray.buzzThreadRoot(): HexKey? = firstNotNullOfOrNull { it.buzzMarkedEventId(MarkedETag.MARKER.ROOT) } +fun TagArray.buzzThreadRoot(): HexKey? = buzzThreadMarkers().root /** The direct parent this event replies to, from its marked `reply` e-tag. */ -fun TagArray.buzzThreadReply(): HexKey? = firstNotNullOfOrNull { it.buzzMarkedEventId(MarkedETag.MARKER.REPLY) } +fun TagArray.buzzThreadReply(): HexKey? = buzzThreadMarkers().reply diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/TagArrayBuilderExt.kt index a48aca0205..066919af14 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/TagArrayBuilderExt.kt @@ -41,5 +41,14 @@ fun TagArrayBuilder.workflowDTag(value: String) = addUnique(DTag. /** The optional human-readable workflow `name` tag on a definition. */ fun TagArrayBuilder.workflowName(name: String) = addUnique(arrayOf("name", name)) +/** + * The compare-and-swap precondition on a definition update: the id of the `kind:30620` head the + * edit was made against. The relay applies the update only while that event is still the head, + * and otherwise refuses with a `conflict:` reason. It must be a 64-hex event id; anything else + * is refused as `invalid: bad expected workflow revision`. Ground truth: + * `parse_expected_workflow_revision` in Buzz's `buzz-relay/src/handlers/command_executor.rs`. + */ +fun TagArrayBuilder.workflowExpectedRevision(headEventId: HexKey) = addUnique(arrayOf(WORKFLOW_EXPECTED_REVISION_TAG, headEventId)) + /** The `p` tag naming the approver a `kind:46010` approval-requested event is addressed to. */ fun TagArrayBuilder.workflowApprover(approverPubKey: HexKey) = addUnique(PTag.assemble(approverPubKey, null)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/TagArrayExt.kt index ead421801e..c6319fd66e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/TagArrayExt.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag +const val WORKFLOW_EXPECTED_REVISION_TAG = "expected-revision" + /** The `h` channel UUID a workflow event belongs to. */ fun TagArray.workflowChannel(): String? = firstTagValue(GroupIdTag.TAG_NAME) @@ -36,5 +38,8 @@ fun TagArray.workflowDTag(): String? = firstTagValue(DTag.TAG_NAME) /** The optional workflow `name`. */ fun TagArray.workflowName(): String? = firstTagValue("name") +/** The CAS precondition (`expected-revision`) a definition update was made against, if any. */ +fun TagArray.workflowExpectedRevision(): HexKey? = firstTagValue(WORKFLOW_EXPECTED_REVISION_TAG) + /** The approver pubkey (`p` tag) an approval-requested event is addressed to. */ fun TagArray.workflowApprover(): HexKey? = firstNotNullOfOrNull(PTag::parseKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/WorkflowDefEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/WorkflowDefEvent.kt index 0697315d48..503cd8f002 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/WorkflowDefEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workflow/WorkflowDefEvent.kt @@ -36,6 +36,11 @@ import com.vitorpamplona.quartz.utils.TimeUtils * a channel; an optional `name` tag labels it. The relay parses the YAML, upserts by the * `(owner, d)` address, and preserves any webhook secret across updates. Ground truth: * `handle_workflow_def` in Buzz's `buzz-relay/src/handlers/command_executor.rs`. + * + * The `d` tag MUST parse as a UUID (the relay refuses anything else with + * `invalid: bad workflow_id format`). An update should carry `expected-revision` = the id of the + * head it was edited from, as Buzz's `build_workflow_update` does, so a concurrent edit is + * refused with `conflict:` instead of silently overwritten. */ @Immutable class WorkflowDefEvent( @@ -65,6 +70,9 @@ class WorkflowDefEvent( /** The optional human-readable workflow name. */ fun name() = tags.workflowName() + /** The head this update was made against (the CAS precondition), if any. */ + fun expectedRevision() = tags.workflowExpectedRevision() + /** The workflow YAML source - the event `content`. */ fun yaml() = content @@ -76,12 +84,14 @@ class WorkflowDefEvent( channelId: String, yaml: String, name: String? = null, + expectedRevision: HexKey? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, yaml, createdAt) { dTag(workflowId) workflowChannel(channelId) name?.let { workflowName(it) } + expectedRevision?.let { workflowExpectedRevision(it) } initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt index 79b3aedbd3..be812e6010 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt @@ -59,6 +59,23 @@ const val BUZZ_CHANNEL_TYPE_STREAM = "stream" const val BUZZ_VISIBILITY_OPEN = "open" const val BUZZ_VISIBILITY_PRIVATE = "private" +/** + * The canonical form Buzz stores a channel name in: leading `#`s and whitespace (in any mix) and + * trailing whitespace removed, since clients render the `#` themselves. A `#` after the first real + * character is kept (`"channel#topic"`). + * + * Mirrors `canonical_channel_name` in `crates/buzz-core/src/channel.rs`, which the relay applies to + * the `name` of a create (9007) and a rename (9002), and which its SDK applies before sending. + */ +fun canonicalBuzzChannelName(name: String): String = name.trimStart { it == '#' || it.isWhitespace() }.trimEnd() + +/** + * True when [name] survives [canonicalBuzzChannelName] with something left. The relay refuses a + * create or rename whose canonical name is empty (`invalid: channel name is required`), so a name + * made only of `#`s and whitespace must be refused before sending. + */ +fun isValidBuzzChannelName(name: String): Boolean = canonicalBuzzChannelName(name).isNotBlank() + /** * A new Buzz channel id: a RFC-4122 v4 UUID string. * @@ -87,3 +104,6 @@ const val BUZZ_ROLE_ADMIN = "admin" const val BUZZ_ROLE_MEMBER = "member" const val BUZZ_ROLE_GUEST = "guest" const val BUZZ_ROLE_BOT = "bot" + +/** Every role string Buzz's put-user handler parses. */ +val BUZZ_ROLES = setOf(BUZZ_ROLE_OWNER, BUZZ_ROLE_ADMIN, BUZZ_ROLE_MEMBER, BUZZ_ROLE_GUEST, BUZZ_ROLE_BOT) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzCustomEmoji.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzCustomEmoji.kt new file mode 100644 index 0000000000..7a071ac522 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzCustomEmoji.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.workspace + +/** + * Buzz's custom-emoji shortcode rule (`normalize_custom_emoji_shortcode` in + * `crates/buzz-sdk/src/builders.rs`). The relay runs it over every `emoji` tag of a NIP-30 emoji + * set (`kind:30030`) or emoji list (`kind:10030`) at ingest (`validate_custom_emoji_tags` in + * `buzz-relay/src/handlers/ingest.rs`) and rejects the whole event on the first failure. + * + * It is NIP-30's `[A-Za-z0-9_-]` alphabet plus a length cap NIP-30 does not have + * ([MAX_SHORTCODE_BYTES]); surrounding whitespace and `:` delimiters are stripped first. + */ +object BuzzCustomEmoji { + /** `MAX_CUSTOM_EMOJI_SHORTCODE_LEN`: the longest shortcode a Buzz relay accepts, in bytes. */ + const val MAX_SHORTCODE_BYTES = 64 + + /** + * The shortcode as Buzz normalizes it — trimmed, `:` delimiters removed, lowercased — or null + * when a Buzz relay would reject it (empty, over [MAX_SHORTCODE_BYTES] bytes, or a character + * outside `[A-Za-z0-9_-]`). + */ + fun normalizeShortcode(shortcode: String): String? { + val trimmed = shortcode.trim().trim(':') + if (trimmed.isEmpty()) return null + // The alphabet is ASCII-only, so the byte length equals the char length once it passes. + if (trimmed.length > MAX_SHORTCODE_BYTES) return null + if (!trimmed.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '-' || it == '_' }) return null + return trimmed.lowercase() + } + + /** True when a Buzz relay would accept [shortcode] in a 30030/10030 `emoji` tag. */ + fun isValidShortcode(shortcode: String): Boolean = normalizeShortcode(shortcode) != null +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt index 16db5e817e..12e1fb959e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -549,6 +549,9 @@ class PoolRequests( * - `rate-limited` — the adaptive limiter spaces the REQ out; it's not doomed. * - no recognized prefix — lifecycle noise (`Subscription closed`, `not found`, …); * don't risk silencing a live sub on an unstructured message. + * - an `error:` that reports a timeout (Buzz closes a REQ that hits its query deadline + * with `error: query timed out` instead of EOSE) — a slow moment, not a refusal of the + * filter; counting it would stop re-sending a REQ the relay serves when less loaded. * * MUST be called while holding [state]'s lock. */ @@ -567,10 +570,10 @@ class PoolRequests( MachineReadablePrefix.DUPLICATE, MachineReadablePrefix.POW, -> false + MachineReadablePrefix.ERROR -> !isTimeout(reason) MachineReadablePrefix.BLOCKED, MachineReadablePrefix.INVALID, MachineReadablePrefix.RESTRICTED, - MachineReadablePrefix.ERROR, MachineReadablePrefix.UNSUPPORTED, -> true } @@ -579,6 +582,8 @@ class PoolRequests( state.recordRefusal(relay, forFilters, sameAsLast) } + private fun isTimeout(reason: String): Boolean = reason.contains("timed out", ignoreCase = true) || reason.contains("timeout", ignoreCase = true) + fun destroy() { relayState.clear() desiredSubs.clear() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupPutUserEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupPutUserEvent.kt index 6faf482d43..d9add45f58 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupPutUserEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupPutUserEvent.kt @@ -50,8 +50,10 @@ class GroupPutUserEvent( * what relay29 reads. * * [buzzRole] additionally emits a top-level `["role", …]` tag. Buzz reads **only** that — - * `extract_tag_value(event, "role")`, defaulting to `member` — so without it every put-user - * lands as a plain member and a promotion silently does nothing. Its vocabulary is also its + * `extract_tag_value(event, "role")` — so a promotion must carry it. Without it Buzz makes + * **no role change**: an existing member keeps their role and only a newcomer defaults to + * `member`, which is what a plain "add" wants. Changing an active member's role (either way) + * is owner/admin-only, and the last owner can't be demoted. Its vocabulary is also its * own (`owner`/`admin`/`member`/`guest`/`bot`, no moderator); an unparseable role fails the * whole handler, so callers map to Buzz's set before passing it here. Harmless on relay29, * which ignores the extra tag. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEvent.kt index db4faeae99..0f25484373 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEvent.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip38UserStatus.tags.StatusEmojiTag import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -53,6 +54,18 @@ class UserStatusEvent( fun firstTaggedUrl() = tags.firstTagValue("r") + /** + * The plain status emoji from a 2-element `["emoji", "🎉"]` tag (Buzz's status shape), or null. + * NIP-30 custom emoji tags (3+ elements) are not status emoji and are read by `taggedEmojis()`. + */ + fun statusEmoji(): String? = tags.firstNotNullOfOrNull(StatusEmojiTag::parse) + + /** + * True when this status carries nothing to show: blank text and no [statusEmoji]. Buzz clears a + * status that way, and an emoji with blank text is still a status ("🌴" alone reads as away). + */ + fun isCleared(): Boolean = content.isBlank() && statusEmoji() == null + companion object { const val KIND = 30315 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/tags/StatusEmojiTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/tags/StatusEmojiTag.kt new file mode 100644 index 0000000000..007c3576e6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/tags/StatusEmojiTag.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip38UserStatus.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * A plain status emoji on a NIP-38 user status: the **2-element** `["emoji", ]` that + * Buzz writes (`build_user_status` in `buzz-sdk/src/builders.rs`), e.g. `["emoji", "🎉"]`. + * + * This is not a NIP-30 custom emoji: those are `["emoji", , , …]` with at + * least three elements and are read by + * [com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag]. The two shapes never overlap, so + * [parse] only takes the exact 2-element form and leaves the NIP-30 one alone. + */ +class StatusEmojiTag { + companion object { + const val TAG_NAME = "emoji" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag.size == 2) { return null } + ensure(tag[0] == TAG_NAME) { return null } + val emoji = tag[1].trim() + ensure(emoji.isNotEmpty()) { return null } + return emoji + } + + fun assemble(emoji: String) = arrayOf(TAG_NAME, emoji) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index cb8d8ba883..8e42496847 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -27,8 +27,11 @@ import com.vitorpamplona.quartz.buzz.agentProfiles.AgentProfileEvent import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent +import com.vitorpamplona.quartz.buzz.arArtifacts.ArtifactEvent +import com.vitorpamplona.quartz.buzz.arArtifacts.ArtifactRemovalEvent import com.vitorpamplona.quartz.buzz.audit.AuditEntryEvent import com.vitorpamplona.quartz.buzz.cwChannelWindow.ThreadSummaryEvent +import com.vitorpamplona.quartz.buzz.cwChannelWindow.ThreadWindowBoundsEvent import com.vitorpamplona.quartz.buzz.cwChannelWindow.WindowBoundsEvent import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent @@ -41,6 +44,7 @@ import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent import com.vitorpamplona.quartz.buzz.forum.ForumVoteEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleEndedEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleGuidelinesEvent +import com.vitorpamplona.quartz.buzz.huddles.HuddleLivenessEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantJoinedEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleParticipantLeftEvent import com.vitorpamplona.quartz.buzz.huddles.HuddleReactionEvent @@ -62,6 +66,7 @@ import com.vitorpamplona.quartz.buzz.moderation.ModerationResolveReportEvent import com.vitorpamplona.quartz.buzz.moderation.ModerationTimeoutEvent import com.vitorpamplona.quartz.buzz.moderation.ModerationUntimeoutEvent import com.vitorpamplona.quartz.buzz.moderation.ProductFeedbackEvent +import com.vitorpamplona.quartz.buzz.mpProjects.ProjectEvent import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent import com.vitorpamplona.quartz.buzz.notifications.MemberRemovedNotificationEvent import com.vitorpamplona.quartz.buzz.pairing.PairingEvent @@ -82,6 +87,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamReminderEvent import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent +import com.vitorpamplona.quartz.buzz.teamCatalog.TeamCatalogEvent import com.vitorpamplona.quartz.buzz.teams.TeamEvent import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent @@ -481,8 +487,11 @@ class EventFactory { AgentProfileEvent.KIND -> AgentProfileEvent(id, pubKey, createdAt, tags, content, sig) ObserverFrameEvent.KIND -> ObserverFrameEvent(id, pubKey, createdAt, tags, content, sig) PersonaEvent.KIND -> PersonaEvent(id, pubKey, createdAt, tags, content, sig) + ArtifactEvent.KIND -> ArtifactEvent(id, pubKey, createdAt, tags, content, sig) + ArtifactRemovalEvent.KIND -> ArtifactRemovalEvent(id, pubKey, createdAt, tags, content, sig) AuditEntryEvent.KIND -> AuditEntryEvent(id, pubKey, createdAt, tags, content, sig) WindowBoundsEvent.KIND -> WindowBoundsEvent(id, pubKey, createdAt, tags, content, sig) + ThreadWindowBoundsEvent.KIND -> ThreadWindowBoundsEvent(id, pubKey, createdAt, tags, content, sig) DmAddMemberEvent.KIND -> DmAddMemberEvent(id, pubKey, createdAt, tags, content, sig) DmCreatedEvent.KIND -> DmCreatedEvent(id, pubKey, createdAt, tags, content, sig) DmHideEvent.KIND -> DmHideEvent(id, pubKey, createdAt, tags, content, sig) @@ -494,6 +503,7 @@ class EventFactory { ForumVoteEvent.KIND -> ForumVoteEvent(id, pubKey, createdAt, tags, content, sig) HuddleEndedEvent.KIND -> HuddleEndedEvent(id, pubKey, createdAt, tags, content, sig) HuddleGuidelinesEvent.KIND -> HuddleGuidelinesEvent(id, pubKey, createdAt, tags, content, sig) + HuddleLivenessEvent.KIND -> HuddleLivenessEvent(id, pubKey, createdAt, tags, content, sig) HuddleParticipantJoinedEvent.KIND -> HuddleParticipantJoinedEvent(id, pubKey, createdAt, tags, content, sig) HuddleParticipantLeftEvent.KIND -> HuddleParticipantLeftEvent(id, pubKey, createdAt, tags, content, sig) HuddleReactionEvent.KIND -> HuddleReactionEvent(id, pubKey, createdAt, tags, content, sig) @@ -515,6 +525,7 @@ class EventFactory { ModerationTimeoutEvent.KIND -> ModerationTimeoutEvent(id, pubKey, createdAt, tags, content, sig) ModerationUntimeoutEvent.KIND -> ModerationUntimeoutEvent(id, pubKey, createdAt, tags, content, sig) ProductFeedbackEvent.KIND -> ProductFeedbackEvent(id, pubKey, createdAt, tags, content, sig) + ProjectEvent.KIND -> ProjectEvent(id, pubKey, createdAt, tags, content, sig) MemberAddedNotificationEvent.KIND -> MemberAddedNotificationEvent(id, pubKey, createdAt, tags, content, sig) MemberRemovedNotificationEvent.KIND -> MemberRemovedNotificationEvent(id, pubKey, createdAt, tags, content, sig) PairingEvent.KIND -> PairingEvent(id, pubKey, createdAt, tags, content, sig) @@ -535,6 +546,7 @@ class EventFactory { ChannelSummaryEvent.KIND -> ChannelSummaryEvent(id, pubKey, createdAt, tags, content, sig) PresenceSnapshotEvent.KIND -> PresenceSnapshotEvent(id, pubKey, createdAt, tags, content, sig) TeamEvent.KIND -> TeamEvent(id, pubKey, createdAt, tags, content, sig) + TeamCatalogEvent.KIND -> TeamCatalogEvent(id, pubKey, createdAt, tags, content, sig) ApprovalDenyEvent.KIND -> ApprovalDenyEvent(id, pubKey, createdAt, tags, content, sig) ApprovalGrantEvent.KIND -> ApprovalGrantEvent(id, pubKey, createdAt, tags, content, sig) WorkflowApprovalDeniedEvent.KIND -> WorkflowApprovalDeniedEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/AgentTurnMetricPayloadTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/AgentTurnMetricPayloadTest.kt new file mode 100644 index 0000000000..2548535940 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/AgentTurnMetricPayloadTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.amTurnMetrics + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AgentTurnMetricPayloadTest { + @Test + fun decodesPricingIdentityWithCamelCaseFields() { + val payload = + AgentTurnMetricPayload.decodeFromJson( + """{"harness":"goose","timestamp":"2026-09-01T00:00:00Z","model":"sonnet",""" + + """"pricingIdentity":{"authority":"api.anthropic.com","model":"claude-sonnet-4-5","cacheClass":"ephemeral"}}""", + ) + val identity = payload.pricingIdentity!! + assertEquals("api.anthropic.com", identity.authority) + assertEquals("claude-sonnet-4-5", identity.model) + assertEquals("ephemeral", identity.cacheClass) + assertTrue(identity.isRegisteredAuthority()) + // The session model keeps its own, non-billing meaning. + assertEquals("sonnet", payload.model) + } + + @Test + fun omittedPricingIdentityMeansPriceUnknown() { + val payload = AgentTurnMetricPayload.decodeFromJson("""{"harness":"goose","timestamp":"t"}""") + assertNull(payload.pricingIdentity) + // Never serialized as null either. + assertFalse(payload.encodeToJson().contains("pricingIdentity")) + } + + @Test + fun cacheClassIsOmittedNotNullWhenAbsent() { + val payload = AgentTurnMetricPayload(harness = "h", timestamp = "t", pricingIdentity = PricingIdentity("openrouter.ai", "x/y")) + val json = payload.encodeToJson() + assertTrue(json.contains(""""pricingIdentity":{"authority":"openrouter.ai","model":"x/y"}"""), json) + assertEquals(payload, AgentTurnMetricPayload.decodeFromJson(json)) + } + + /** Like serde on the Rust side: a present identity without its required strings fails the payload. */ + @Test + fun pricingIdentityRequiresAuthorityAndModel() { + assertFailsWith { + AgentTurnMetricPayload.decodeFromJson("""{"harness":"h","timestamp":"t","pricingIdentity":{"model":"m"}}""") + } + } + + /** The Rust parser accepts any authority string; only the registered set is priceable. */ + @Test + fun unregisteredAuthorityParsesButIsFlagged() { + val identity = PricingIdentity("https://api.anthropic.com/", "m") + assertFalse(identity.isRegisteredAuthority()) + assertTrue(PricingIdentity("api.openai.com", "gpt").isRegisteredAuthority()) + } + + @Test + fun explicitZeroCacheIsKeptAndOmittedIsNull() { + val payload = + AgentTurnMetricPayload.decodeFromJson( + """{"harness":"h","timestamp":"t","turn":{"inputTokens":10,"cacheReadTokens":0}}""", + ) + assertEquals(0L, payload.turn?.cacheReadTokens) + assertNull(payload.turn?.cacheWriteTokens) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaEventTest.kt index 366114a465..0896357681 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/apPersonas/PersonaEventTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.buzz.apPersonas import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -100,4 +101,77 @@ class PersonaEventTest { ) assertNull(event.personaOrNull()) } + + // NIP-AP reference vector (docs/nips/NIP-AP.md): the content bytes are pinned, because they + // fix the event id and the desktop's persona content hash. + @Test + fun contentMatchesNipApVectorByteForByte() { + val persona = + PersonaContent( + displayName = "Test Agent", + systemPrompt = "You are a test assistant.", + avatarUrl = "https://example.com/avatar.png", + runtime = "goose", + model = "claude-opus-4", + provider = "anthropic", + namePool = listOf("Alpha", "Beta"), + ) + assertEquals( + """{"display_name":"Test Agent","system_prompt":"You are a test assistant.","avatar_url":"https://example.com/avatar.png","runtime":"goose","model":"claude-opus-4","provider":"anthropic","name_pool":["Alpha","Beta"]}""", + persona.encodeToJson(), + ) + } + + @Test + fun newFieldsKeepUpstreamOrderAndRoundTrip() { + val json = + """{"display_name":"A","system_prompt":"p","acp_command":"buzz-claude-acp","avatar_url":"u","parallelism":2,"description":"d","session_policy":"thread"}""" + val persona = PersonaContent.decodeFromJson(json) + assertEquals("buzz-claude-acp", persona.acpCommand) + assertEquals("d", persona.description) + assertTrue(persona.isThreadSessionPolicy()) + // Re-encoding is byte-identical, so an edit that changes nothing keeps the content hash. + assertEquals(json, persona.encodeToJson()) + } + + @Test + fun sharedTagIsWrittenAndRead() { + val persona = PersonaContent(displayName = "A", acpCommand = "buzz-goose-acp") + val shared = PersonaEvent.build(persona, slug = "a", shared = true) + assertEquals(listOf("shared", "true"), shared.tags.single { it[0] == "shared" }.toList()) + assertTrue(PersonaEvent("00", "00", 0L, shared.tags, shared.content, "00").isShared()) + + val private = PersonaEvent.build(persona, slug = "a") + assertTrue(private.tags.none { it[0] == "shared" }) + assertFalse(PersonaEvent("00", "00", 0L, private.tags, private.content, "00").isShared()) + } + + @Test + fun malformedSharedTagsReadAsNotShared() { + fun shared(vararg tags: Array) = PersonaEvent("00", "00", 0L, arrayOf(arrayOf("d", "a"), *tags), "{}", "00").isShared() + + assertFalse(shared(arrayOf("shared", "false"))) + assertFalse(shared(arrayOf("shared", "true", "extra"))) + assertFalse(shared(arrayOf("shared"))) + assertFalse(shared(arrayOf("shared", "true"), arrayOf("shared", "true"))) + assertTrue(shared(arrayOf("shared", "true"))) + } + + @Test + fun sharedHeadPublishesPortableAcpCommandOnly() { + // Unset becomes the explicit stock harness; a portable alias stays; a local path is dropped. + assertEquals("buzz-acp", PersonaContent(displayName = "A").forSharedCatalog().acpCommand) + assertEquals("buzz-codex-acp", PersonaContent(displayName = "A", acpCommand = "buzz-codex-acp").forSharedCatalog().acpCommand) + assertNull(PersonaContent(displayName = "A", acpCommand = "/usr/local/bin/my-acp").forSharedCatalog().acpCommand) + + assertTrue(PersonaContent.isPortableAcpCommand("buzz-acp")) + assertFalse(PersonaContent.isPortableAcpCommand("buzz--acp")) + assertFalse(PersonaContent.isPortableAcpCommand("buzz-a b-acp")) + } + + @Test + fun editStampsAfterThePriorHead() { + val future = 4_000_000_000L + assertEquals(future + 1, PersonaEvent.build(PersonaContent(displayName = "A"), "a", priorHeadCreatedAt = future).createdAt) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEventTest.kt new file mode 100644 index 0000000000..820b384491 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactEventTest.kt @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts + +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ArtifactEventTest { + private val artifactId = "04737c81-e5e8-4412-bb47-f446813cfeba" + private val home = "9b353519-f4fe-4757-aef4-bec6cc0ae54c" + private val other = "17cfe3ca-b3d0-4a76-a2a4-4eaf0d3a4109" + private val prev = "a".repeat(64) + + private fun EventTemplate.toEvent(id: String = "0".repeat(64)) = ArtifactEvent(id, "f".repeat(64), createdAt, tags, content, "sig") + + /** The same envelope as upstream's `event()` test helper in `buzz-core/src/artifact.rs`. */ + private fun envelope( + op: String, + extra: List> = emptyList(), + ): Array> { + val tags = + mutableListOf( + arrayOf("ar", "1"), + arrayOf("d", artifactId), + arrayOf("h", home), + arrayOf("type", "buzz.task"), + arrayOf("op", op), + ) + if (op != "delete") tags.add(arrayOf("title", "Title")) + if (op != "create") tags.add(arrayOf("prev", prev)) + tags.addAll(extra) + return tags.toTypedArray() + } + + private fun validate( + op: String, + extra: List> = emptyList(), + content: String = "", + ) = ArtifactValidator.validate(envelope(op, extra), content) + + @Test + fun lifecycleEnvelopesAreValid() { + for (op in listOf("create", "update", "move", "delete", "restore")) { + assertIs(validate(op), op) + } + // Client tags and non-JSON content are opaque to the envelope. + assertIs(validate("create", listOf(arrayOf("project", "opaque", "anything")), "not json")) + } + + @Test + fun malformedAndDeletePayloadsAreRejected() { + val cases = + listOf( + Triple(listOf(arrayOf("title", "duplicate")), "create", ""), + Triple(listOf(arrayOf("ar", "2")), "create", ""), + Triple(listOf(arrayOf("prev", "a".repeat(64))), "create", ""), + Triple(listOf(arrayOf("root", "A".repeat(64))), "create", ""), + Triple(listOf(arrayOf("project", "hidden")), "delete", ""), + Triple(listOf(arrayOf("title", "hidden")), "delete", ""), + Triple(emptyList(), "delete", "hidden"), + Triple(listOf(arrayOf("x", "x".repeat(4097))), "create", ""), + Triple(listOf(arrayOf("x".repeat(129), "x")), "create", ""), + ) + for ((extra, op, content) in cases) { + assertIs(validate(op, extra, content), "${extra.map { it.toList() }} $op") + } + } + + @Test + fun envelopeDetailRules() { + val base = envelope("create") + + fun replace( + name: String, + value: String, + ) = base.map { if (it[0] == name) arrayOf(name, value) else it }.toTypedArray() + + assertEquals(ArtifactValidation.Invalid("unsupported artifact envelope version"), ArtifactValidator.validate(replace("ar", "2"), "")) + assertIs(ArtifactValidator.validate(replace("d", artifactId.uppercase()), "")) + assertIs(ArtifactValidator.validate(replace("h", "00000000-0000-0000-0000-000000000000"), "")) + assertIs(ArtifactValidator.validate(replace("type", "task"), "")) + assertIs(ArtifactValidator.validate(replace("type", "buzz.Task"), "")) + assertIs(ArtifactValidator.validate(replace("type", "buzz..task"), "")) + assertIs(ArtifactValidator.validate(replace("type", "buzz.1task"), "")) + assertIs(ArtifactValidator.validate(replace("type", "acme.ops_board.v-2"), "")) + assertEquals(ArtifactValidation.Invalid("invalid artifact operation"), ArtifactValidator.validate(replace("op", "archive"), "")) + assertIs(ArtifactValidator.validate(replace("title", " "), "")) + assertIs(ArtifactValidator.validate(replace("title", "x".repeat(513)), "")) + assertIs(ArtifactValidator.validate(replace("title", "x".repeat(512)), "")) + // A three-element envelope tag is rejected even when the extra element is empty. + assertIs(ArtifactValidator.validate(base.map { if (it[0] == "title") arrayOf("title", "T", "") else it }.toTypedArray(), "")) + // Missing envelope tag. + assertEquals(ArtifactValidation.Invalid("missing required envelope tag"), ArtifactValidator.validate(base.filter { it[0] != "type" }.toTypedArray(), "")) + // Too many tags. + val tooMany = (base.toList() + List(ArtifactValidator.MAX_TAGS) { arrayOf("x", "$it") }).toTypedArray() + assertEquals(ArtifactValidation.Invalid("too many tags"), ArtifactValidator.validate(tooMany, "")) + // A delete may carry a NIP-OA auth tag. + assertIs(validate("delete", listOf(arrayOf("auth", "b".repeat(64), "", "c".repeat(128))))) + } + + @Test + fun canonicalIdentifiers() { + assertFalse(ArtifactIds.isCanonicalUuid("00000000-0000-0000-0000-000000000000")) + assertFalse(ArtifactIds.isCanonicalUuid(artifactId.replace("-", ""))) + assertFalse(ArtifactIds.isCanonicalUuid(artifactId.uppercase())) + assertTrue(ArtifactIds.isCanonicalUuid(artifactId)) + assertTrue(ArtifactIds.isEventId("f".repeat(64))) + assertFalse(ArtifactIds.isEventId("F".repeat(64))) + assertFalse(ArtifactIds.isEventId("f".repeat(63))) + } + + @Test + fun createRoundTrips() { + val tpl = + ArtifactEvent.create(artifactId, home, "buzz.task", "Fix the payment timeout", "{\"version\":1,\"status\":\"open\"}") { + add(arrayOf("assignee", "b".repeat(64))) + add(arrayOf("project", other)) + } + val ev = tpl.toEvent() + + assertEquals(45010, ev.kind) + assertEquals( + listOf("ar", "d", "h", "type", "title", "op", "assignee", "project"), + ev.tags.map { it[0] }, + ) + assertEquals(artifactId, ev.artifactId()) + assertEquals(home, ev.home()) + assertEquals("buzz.task", ev.type()) + assertEquals("Fix the payment timeout", ev.title()) + assertEquals(ArtifactOp.CREATE, ev.op()) + assertNull(ev.prev()) + assertNull(ev.root()) + assertEquals(listOf("assignee", "project"), ev.clientTags().map { it[0] }) + + val envelope = ev.envelopeOrNull()!! + assertEquals(ArtifactEnvelope(artifactId, home, "buzz.task", ArtifactOp.CREATE, null, null), envelope) + } + + @Test + fun updateCarriesEnvelopeAndClientTagsForward() { + val root = "c".repeat(64) + val created = + ArtifactEvent + .create(artifactId, home, "buzz.task", "Title", "v1", root) { + add(arrayOf("project", other)) + }.toEvent("1".repeat(64)) + + val updated = ArtifactEvent.update(created, "Renamed", "v2").toEvent("2".repeat(64)) + assertEquals(ArtifactOp.UPDATE, updated.op()) + assertEquals(created.id, updated.prev()) + assertEquals(root, updated.root()) + assertEquals(home, updated.home()) + assertEquals("Renamed", updated.title()) + assertEquals(listOf(other), updated.clientTags().map { it[1] }) + assertTrue(updated.isWellFormed()) + + val dropped = ArtifactEvent.update(created, "Renamed", "v2", keepClientTags = false).toEvent() + assertTrue(dropped.clientTags().isEmpty()) + } + + @Test + fun deletePreservesTypeHomeAndRootAndStripsEverythingElse() { + val root = "c".repeat(64) + val current = + ArtifactEvent + .create(artifactId, home, "buzz.task", "Title", "payload", root) { + add(arrayOf("assignee", "b".repeat(64))) + }.toEvent("1".repeat(64)) + + val deletion = ArtifactEvent.delete(current).toEvent("2".repeat(64)) + assertEquals(ArtifactOp.DELETE, deletion.op()) + assertTrue(deletion.isDelete()) + assertEquals("", deletion.content) + assertNull(deletion.title()) + assertEquals(root, deletion.root()) + assertEquals("buzz.task", deletion.type()) + assertEquals(current.id, deletion.prev()) + assertTrue(deletion.clientTags().isEmpty()) + assertTrue(deletion.isWellFormed()) + } + + @Test + fun buildRefusesWhatTheRelayWouldReject() { + assertFailsWith { ArtifactEvent.build(artifactId.uppercase(), home, "buzz.task", ArtifactOp.CREATE, "T", "") } + assertFailsWith { ArtifactEvent.build(artifactId, home, "task", ArtifactOp.CREATE, "T", "") } + assertFailsWith { ArtifactEvent.build(artifactId, home, "buzz.task", ArtifactOp.CREATE, " ", "") } + assertFailsWith { ArtifactEvent.build(artifactId, home, "buzz.task", ArtifactOp.CREATE, "T", "", prev = prev) } + assertFailsWith { ArtifactEvent.build(artifactId, home, "buzz.task", ArtifactOp.UPDATE, "T", "") } + assertFailsWith { ArtifactEvent.build(artifactId, home, "buzz.task", ArtifactOp.DELETE, "T", "", prev = prev) } + assertFailsWith { ArtifactEvent.build(artifactId, home, "buzz.task", ArtifactOp.DELETE, null, "x", prev = prev) } + assertFailsWith { ArtifactEvent.build(artifactId, home, "buzz.task", ArtifactOp.CREATE, "T", "", root = "A".repeat(64)) } + // Client tags added by the initializer are validated too. + assertFailsWith { + ArtifactEvent.build(artifactId, home, "buzz.task", ArtifactOp.CREATE, "T", "") { add(arrayOf("x", "x".repeat(4097))) } + } + assertFailsWith { + ArtifactEvent.build(artifactId, home, "buzz.task", ArtifactOp.DELETE, null, "", prev = prev) { add(arrayOf("project", other)) } + } + } + + @Test + fun moveBuildsIntoANewHome() { + val ev = ArtifactEvent.build(artifactId, other, "buzz.task", ArtifactOp.MOVE, "Title", "snapshot", prev = prev).toEvent() + assertEquals(other, ev.home()) + assertEquals(ArtifactOp.MOVE, ev.op()) + assertTrue(ev.isWellFormed()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactHeadResolverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactHeadResolverTest.kt new file mode 100644 index 0000000000..f29f11afc7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactHeadResolverTest.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts + +import com.vitorpamplona.quartz.buzz.arArtifacts.tags.ArtifactOp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ArtifactHeadResolverTest { + private val artifactId = "04737c81-e5e8-4412-bb47-f446813cfeba" + private val home = "9b353519-f4fe-4757-aef4-bec6cc0ae54c" + private val destination = "17cfe3ca-b3d0-4a76-a2a4-4eaf0d3a4109" + + private fun id(n: Int) = n.toString().padStart(64, '0') + + private fun revision( + n: Int, + op: ArtifactOp, + prev: Int?, + createdAt: Long = 1000L + n, + channel: String = home, + d: String = artifactId, + ): ArtifactEvent { + val title = if (op == ArtifactOp.DELETE) null else "T$n" + val tpl = ArtifactEvent.build(d, channel, "buzz.task", op, title, if (op == ArtifactOp.DELETE) "" else "c$n", prev?.let { id(it) }, createdAt = createdAt) + return ArtifactEvent(id(n), "f".repeat(64), tpl.createdAt, tpl.tags, tpl.content, "sig") + } + + @Test + fun emptyIsUnknown() { + assertEquals(ArtifactHead.Unknown, ArtifactHeadResolver.resolve(artifactId, emptyList())) + } + + @Test + fun linearChainResolvesToTheTipRegardlessOfTimestampsOrOrder() { + val create = revision(1, ArtifactOp.CREATE, null, createdAt = 5000) + val update = revision(2, ArtifactOp.UPDATE, 1, createdAt = 10) + val update2 = revision(3, ArtifactOp.UPDATE, 2, createdAt = 20) + + val head = ArtifactHeadResolver.resolve(artifactId, listOf(update2, create, update, update)) + assertIs(head) + assertEquals(id(3), head.revision.id) + assertTrue(head.chainComplete) + + assertEquals(listOf(id(3), id(2), id(1)), ArtifactHeadResolver.history(head.revision, listOf(create, update, update2)).map { it.id }) + } + + @Test + fun otherIdentitiesAndMalformedRevisionsAreIgnored() { + val create = revision(1, ArtifactOp.CREATE, null) + val foreign = revision(2, ArtifactOp.UPDATE, 1, d = "5b0d1c1e-8a7e-4c1a-9d3b-2f1e0a9b8c7d") + val head = ArtifactHeadResolver.resolve(artifactId, listOf(create, foreign)) + assertIs(head) + assertEquals(id(1), head.revision.id) + } + + @Test + fun aForkIsAmbiguousNotResolvedByTimestamp() { + val create = revision(1, ArtifactOp.CREATE, null) + val x = revision(2, ArtifactOp.UPDATE, 1, createdAt = 2000) + val y = revision(3, ArtifactOp.UPDATE, 1, createdAt = 3000) + val head = ArtifactHeadResolver.resolve(artifactId, listOf(create, x, y)) + assertIs(head) + assertEquals(setOf(id(2), id(3)), head.candidates.map { it.id }.toSet()) + } + + @Test + fun aRedactedMiddleRevisionStillResolvesAcrossTheGap() { + // 1 <- 2 <- (3 redacted) <- 4: tips are 2 and 4; the create-rooted segment is older. + val head = + ArtifactHeadResolver.resolve( + artifactId, + listOf(revision(1, ArtifactOp.CREATE, null), revision(2, ArtifactOp.UPDATE, 1), revision(4, ArtifactOp.UPDATE, 3)), + ) + assertIs(head) + assertEquals(id(4), head.revision.id) + assertEquals(false, head.chainComplete) + } + + @Test + fun twoGapsAreAmbiguous() { + val head = + ArtifactHeadResolver.resolve( + artifactId, + listOf(revision(1, ArtifactOp.CREATE, null), revision(4, ArtifactOp.UPDATE, 3), revision(6, ArtifactOp.UPDATE, 5)), + ) + assertIs(head) + } + + @Test + fun destinationReaderSeesOnlyTheMoveAndLater() { + val move = revision(3, ArtifactOp.MOVE, 2, channel = destination) + val update = revision(4, ArtifactOp.UPDATE, 3, channel = destination) + val head = ArtifactHeadResolver.resolve(artifactId, listOf(update, move)) + assertIs(head) + assertEquals(id(4), head.revision.id) + assertEquals(false, head.chainComplete) + } + + @Test + fun sourceReaderSeesTheRemoval() { + val create = revision(1, ArtifactOp.CREATE, null) + val update = revision(2, ArtifactOp.UPDATE, 1) + val removalTpl = ArtifactRemovalEvent.build(artifactId, home, id(2)) + val removal = ArtifactRemovalEvent(id(9), "e".repeat(64), removalTpl.createdAt, removalTpl.tags, removalTpl.content, "sig") + + val head = ArtifactHeadResolver.resolve(artifactId, listOf(create, update), listOf(removal)) + assertIs(head) + assertEquals(id(2), head.lastRevision.id) + + // A reader of both channels sees the move revision, which supersedes the removal's prev. + val move = revision(3, ArtifactOp.MOVE, 2, channel = destination) + val both = ArtifactHeadResolver.resolve(artifactId, listOf(create, update, move), listOf(removal)) + assertIs(both) + assertEquals(id(3), both.revision.id) + } + + @Test + fun deletedHeadIsReportedAsDeletedAndRestoreRevivesIt() { + val create = revision(1, ArtifactOp.CREATE, null) + val delete = revision(2, ArtifactOp.DELETE, 1) + val deleted = ArtifactHeadResolver.resolve(artifactId, listOf(create, delete)) + assertIs(deleted) + assertTrue(deleted.isDeleted) + + val restore = revision(3, ArtifactOp.RESTORE, 2) + val restored = ArtifactHeadResolver.resolve(artifactId, listOf(create, delete, restore)) + assertIs(restored) + assertEquals(false, restored.isDeleted) + } + + @Test + fun aPrevCycleIsAmbiguous() { + val head = ArtifactHeadResolver.resolve(artifactId, listOf(revision(2, ArtifactOp.UPDATE, 3), revision(3, ArtifactOp.UPDATE, 2))) + assertIs(head) + assertTrue(head.candidates.isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactRemovalEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactRemovalEventTest.kt new file mode 100644 index 0000000000..422b84ded8 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/arArtifacts/ArtifactRemovalEventTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.arArtifacts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ArtifactRemovalEventTest { + private val artifactId = "04737c81-e5e8-4412-bb47-f446813cfeba" + private val source = "9b353519-f4fe-4757-aef4-bec6cc0ae54c" + private val replaced = "a".repeat(64) + + @Test + fun relayShapedRemovalParses() { + // Exactly the tag list `removal_marker` in buzz-db/src/store/artifact.rs emits. + val tags = + arrayOf( + arrayOf("ar", "1"), + arrayOf("d", artifactId), + arrayOf("h", source), + arrayOf("reason", "moved"), + arrayOf("prev", replaced), + ) + val ev = ArtifactRemovalEvent("0".repeat(64), "e".repeat(64), 1, tags, "", "sig") + + assertEquals(45011, ev.kind) + assertEquals(artifactId, ev.artifactId()) + assertEquals(source, ev.sourceChannel()) + assertEquals("moved", ev.reason()) + assertEquals(replaced, ev.replacedRevision()) + assertTrue(ev.isWellFormed()) + } + + @Test + fun buildMatchesTheRelayTagOrder() { + val tpl = ArtifactRemovalEvent.build(artifactId, source, replaced) + assertEquals(listOf("ar", "d", "h", "reason", "prev"), tpl.tags.map { it[0] }) + assertEquals("", tpl.content) + } + + @Test + fun malformedRemovalsAreFlagged() { + val tpl = ArtifactRemovalEvent.build(artifactId, source, replaced) + assertFalse(ArtifactRemovalEvent("0".repeat(64), "e".repeat(64), 1, tpl.tags, "content", "sig").isWellFormed()) + val badPrev = tpl.tags.map { if (it[0] == "prev") arrayOf("prev", "A".repeat(64)) else it }.toTypedArray() + assertFalse(ArtifactRemovalEvent("0".repeat(64), "e".repeat(64), 1, badPrev, "", "sig").isWellFormed()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsEventTest.kt new file mode 100644 index 0000000000..5599d8def1 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/cwChannelWindow/ThreadWindowBoundsEventTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.cwChannelWindow + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class ThreadWindowBoundsEventTest { + private val nilChannel = "00000000-0000-0000-0000-000000000000" + private val root = "ab".repeat(32) + private val reader = "ab".repeat(32) + private val host = "relay.example" + + @Test + fun bindingMatchesUpstreamKnownAnswer() { + // `binding_normalizes_defaults_and_binds_every_argument` in buzz-core/src/thread_window.rs. + val request = ThreadWindowRequest(nilChannel, root, kinds = listOf(9)) + assertEquals("tw:1:5252322dfd797ddb1d5f1150acd4cf914b9fc09e39bcf3048d25aed514b3546d", request.binding(host, reader)) + + // Explicit defaults and an uppercase root normalize to the same binding. + assertEquals(request.binding(host, reader), ThreadWindowRequest(nilChannel, root.uppercase(), listOf(9, 9), limit = 50, depth = 100, includeAux = false).binding(host, reader)) + + // Every argument is bound. + val base = request.binding(host, reader) + for (changed in listOf( + request.copy(limit = 49), + request.copy(depth = 1), + request.copy(includeAux = true), + request.copy(kinds = listOf(40002)), + request.copy(rootId = "cd".repeat(32)), + request.copy(channelId = "9b353519-f4fe-4757-aef4-bec6cc0ae54c"), + request.copy(cursor = NextCursor(0, "ab".repeat(32))), + )) { + assertNotEquals(base, changed.binding(host, reader), changed.toString()) + } + assertNotEquals(base, request.binding("other.example", reader)) + assertNotEquals(base, request.binding(host, "cd".repeat(32))) + } + + @Test + fun requestRejectsWhatTheRelayRejects() { + assertFailsWith { ThreadWindowRequest(nilChannel, root, kinds = listOf(7)) } + assertFailsWith { ThreadWindowRequest(nilChannel, root, kinds = emptyList()) } + assertFailsWith { ThreadWindowRequest(nilChannel, root, listOf(9), limit = 0) } + assertFailsWith { ThreadWindowRequest(nilChannel, root, listOf(9), limit = 201) } + assertFailsWith { ThreadWindowRequest(nilChannel, root, listOf(9), depth = 101) } + assertFailsWith { ThreadWindowRequest(nilChannel, "bad", listOf(9)) } + } + + @Test + fun relayShapedOverlayParsesAndMatches() { + val request = ThreadWindowRequest(nilChannel, root, kinds = listOf(9)) + val tags = arrayOf(arrayOf("d", request.binding(host, reader)), arrayOf("h", nilChannel), arrayOf("e", root)) + // serde_json's `json!` sorts keys, so the relay's body arrives alphabetized. + val content = """{"direction":"older","has_more":true,"next_cursor":{"created_at":1751500000,"id":"${"c".repeat(64)}"},"version":1}""" + val ev = ThreadWindowBoundsEvent("0".repeat(64), "e".repeat(64), 1, tags, content, "sig") + + assertEquals(39007, ev.kind) + assertEquals(nilChannel, ev.channelId()) + assertEquals(root, ev.rootId()) + assertTrue(ev.bounds().hasMore) + assertEquals(1751500000L, ev.bounds().nextCursor?.createdAt) + assertTrue(ev.isWellFormed()) + assertTrue(ev.matches(request, host, reader)) + assertFalse(ev.matches(request.copy(limit = 10), host, reader)) + assertFalse(ev.matches(request, "other.example", reader)) + } + + @Test + fun exhaustedPageRoundTrips() { + val request = ThreadWindowRequest(nilChannel, root, kinds = listOf(40002, 9), includeAux = true) + val tpl = ThreadWindowBoundsEvent.build(request, host, reader, ThreadWindowBoundsContent(hasMore = false)) + assertTrue(tpl.content.contains("\"next_cursor\":null"), tpl.content) + val ev = ThreadWindowBoundsEvent("0".repeat(64), "e".repeat(64), tpl.createdAt, tpl.tags, tpl.content, "sig") + assertEquals(listOf("d", "h", "e"), ev.tags.map { it[0] }) + assertTrue(ev.isWellFormed()) + assertTrue(ev.matches(request, host, reader)) + } + + @Test + fun malformedOverlaysAreRejected() { + val request = ThreadWindowRequest(nilChannel, root, kinds = listOf(9)) + val d = arrayOf("d", request.binding(host, reader)) + val h = arrayOf("h", nilChannel) + val e = arrayOf("e", root) + val good = """{"version":1,"direction":"older","has_more":false,"next_cursor":null}""" + + fun ev( + tags: Array>, + content: String = good, + ) = ThreadWindowBoundsEvent("0".repeat(64), "e".repeat(64), 1, tags, content, "sig") + + assertTrue(ev(arrayOf(d, h, e)).isWellFormed()) + assertFalse(ev(arrayOf(d, h)).isWellFormed()) + assertFalse(ev(arrayOf(d, h, e, arrayOf("p", reader))).isWellFormed()) + assertFalse(ev(arrayOf(d, h, arrayOf("e", root, "wss://relay"))).isWellFormed()) + assertFalse(ev(arrayOf(arrayOf("d", "$nilChannel:head"), h, e)).isWellFormed()) + assertFalse(ev(arrayOf(d, arrayOf("h", nilChannel.uppercase().replace('0', 'A')), e)).isWellFormed()) + assertFalse(ev(arrayOf(d, h, arrayOf("e", root.uppercase()))).isWellFormed()) + // has_more without a cursor, a cursor without has_more, wrong version or direction. + assertFalse(ev(arrayOf(d, h, e), """{"version":1,"direction":"older","has_more":true,"next_cursor":null}""").isWellFormed()) + assertFalse(ev(arrayOf(d, h, e), """{"version":1,"direction":"older","has_more":false,"next_cursor":{"created_at":1,"id":"$root"}}""").isWellFormed()) + assertFalse(ev(arrayOf(d, h, e), """{"version":2,"direction":"older","has_more":false,"next_cursor":null}""").isWellFormed()) + assertFalse(ev(arrayOf(d, h, e), """{"version":1,"direction":"newer","has_more":false,"next_cursor":null}""").isWellFormed()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessEventTest.kt new file mode 100644 index 0000000000..cd223ddd38 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/huddles/HuddleLivenessEventTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.huddles + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HuddleLivenessEventTest { + private val session = "4e8a7c2e-3b1f-4d6a-9a1e-7c5b2d9f0e11" + private val channel = "9b353519-f4fe-4757-aef4-bec6cc0ae54c" + + @Test + fun relayShapedSnapshotParses() { + // The exact shape `handle_huddle_liveness_req` signs: d, h, and a string generation. + val tags = arrayOf(arrayOf("d", session), arrayOf("h", channel)) + val content = """{"ephemeral_channel_id":"$session","generation":"18446744073709551615"}""" + val ev = HuddleLivenessEvent("0".repeat(64), "e".repeat(64), 1, tags, content, "sig") + + assertEquals(48104, ev.kind) + assertEquals(session, ev.sessionId()) + assertEquals(channel, ev.channelId()) + assertEquals(session, ev.liveness()?.ephemeralChannelId) + assertEquals("18446744073709551615", ev.generation()) + } + + @Test + fun malformedContentIsNotAuthoritative() { + val tags = arrayOf(arrayOf("d", session), arrayOf("h", channel)) + assertNull(HuddleLivenessEvent("0".repeat(64), "e".repeat(64), 1, tags, "{", "sig").generation()) + assertNull(HuddleLivenessEvent("0".repeat(64), "e".repeat(64), 1, tags, """{"ephemeral_channel_id":"$session","generation":7}""", "sig").generation()) + assertNull(HuddleLivenessEvent("0".repeat(64), "e".repeat(64), 1, tags, """{"ephemeral_channel_id":"$session","generation":""}""", "sig").generation()) + } + + @Test + fun buildRoundTrips() { + val tpl = HuddleLivenessEvent.build(session, channel, "42") + assertEquals(listOf("d", "h"), tpl.tags.map { it[0] }) + val ev = HuddleLivenessEvent("0".repeat(64), "e".repeat(64), tpl.createdAt, tpl.tags, tpl.content, "sig") + assertEquals(session, ev.sessionId()) + assertEquals(channel, ev.channelId()) + assertEquals("42", ev.generation()) + } + + @Test + fun filterIsLivenessOnly() { + val filter = HuddleLivenessEvent.filter(listOf(channel), listOf(session)) + assertEquals(listOf(48104), filter.kinds) + assertEquals(mapOf("h" to listOf(channel), "d" to listOf(session)), filter.tags) + assertEquals(1, filter.limit) + + val json = filter.toJson() + assertTrue(json.contains("\"kinds\":[48104]"), json) + assertTrue(json.contains("\"#h\":[\"$channel\"]"), json) + assertTrue(json.contains("\"#d\":[\"$session\"]"), json) + + val allSessions = HuddleLivenessEvent.filter(listOf(channel)) + assertEquals(mapOf("h" to listOf(channel)), allSessions.tags) + assertNull(allSessions.limit) + + assertFailsWith { HuddleLivenessEvent.filter(emptyList()) } + assertFailsWith { HuddleLivenessEvent.filter(List(129) { channel }) } + } + + @Test + fun generationsCompareOnlyWhenBothAreDecimal() { + assertEquals(-1, HuddleLivenessContent.compareGenerations("9", "10")) + assertEquals(1, HuddleLivenessContent.compareGenerations("18446744073709551615", "18446744073709551614")) + assertEquals(0, HuddleLivenessContent.compareGenerations("007", "7")) + assertNull(HuddleLivenessContent.compareGenerations("epoch-a", "3")) + assertNull(HuddleLivenessContent.compareGenerations("", "3")) + assertNull(HuddleLivenessContent.compareGenerations("-1", "3")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt index 4583b09b99..5f9b54a2e7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.buzz.invite import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -105,4 +106,41 @@ class BuzzInviteLinkTest { // `v2` without the dot is a dotless token like any other. assertNull(BuzzInviteLink.parse("https://host.example/invite/v2")) } + + /** Exactly what `validate_v2_code` in `buzz-core/src/invite.rs` accepts — the relay never falls back to v1 for `v2.`. */ + @Test + fun v2CodesMustBeCanonicalBase64UrlOfThirtyTwoBytes() { + assertTrue(BuzzInviteLink.isValidV2Code(v2Token)) + + val secret = v2Token.removePrefix("v2.") + listOf( + // Upstream's own malformed cases. + "v2.", + "v2.not-base64!", + "v2.${secret.dropLast(2)}", // 31 bytes' worth + "$v2Token=", // padded alias + // Same 32 bytes, but the unused low bits of the last symbol are set: a non-canonical alias. + "v2.${secret.dropLast(1)}5", + // Standard (not url-safe) alphabet, and extra segments. + "v2.${secret.replace('_', '/')}+", + "$v2Token.sig", + // 33 bytes. + "v2.${secret}AA", + ).forEach { code -> + assertFalse(BuzzInviteLink.isValidV2Code(code), "accepted malformed v2 code: $code") + assertNull(BuzzInviteLink.parse("https://host.example/invite/$code"), "parsed malformed v2 invite: $code") + } + } + + @Test + fun claimResponseSlugsAreRead() { + assertEquals(BuzzInviteClaim.ERROR_EXHAUSTED, BuzzInviteClaim.errorOf("""{"error":"invite_exhausted"}""")) + assertEquals(BuzzInviteClaim.ERROR_EXPIRED, BuzzInviteClaim.errorOf("""{"error":"invite_expired"}""")) + assertEquals( + BuzzInviteClaim.STATUS_ALREADY_MEMBER, + BuzzInviteClaim.statusOf("""{"status":"already_member","community_id":"c","host":"h","role":"member"}"""), + ) + assertNull(BuzzInviteClaim.errorOf("bad gateway")) + assertNull(BuzzInviteClaim.statusOf("""{"error":"invite_invalid"}""")) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectEventTest.kt new file mode 100644 index 0000000000..741caa59d0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectEventTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.mpProjects + +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMember +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectMemberTag +import com.vitorpamplona.quartz.buzz.mpProjects.tags.ProjectVisibility +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ProjectEventTest { + private val ownerA = "a".repeat(64) + private val ownerB = "b".repeat(64) + private val channel = "3580ca9b-47b4-4af9-b22a-1068778f26c6" + + private fun EventTemplate.toEvent() = ProjectEvent("0".repeat(64), "f".repeat(64), createdAt, tags, content, "sig") + + private fun tags(vararg tags: Array) = arrayOf(*tags) + + // ---- Cases from Buzz's docs/nips/NIP-MP.fixtures.json (the full file runs in jvmTest). ---- + + @Test + fun fixtureValidFull() { + val t = + tags( + arrayOf("d", "platform"), + arrayOf("name", "Platform"), + arrayOf("description", "Relay, desktop, and mobile for the platform team."), + arrayOf("a", "30617:$ownerA:buzz"), + arrayOf("a", "30617:$ownerB:buzz-infra"), + arrayOf("buzz-channel", channel), + arrayOf("buzz-visibility", "listed"), + ) + assertNull(ProjectValidator.validate(t)) + val ev = ProjectEvent("0".repeat(64), "f".repeat(64), 1, t, "", "sig") + assertEquals("platform", ev.slug()) + assertEquals("Platform", ev.displayName()) + assertEquals(listOf("30617:$ownerA:buzz", "30617:$ownerB:buzz-infra"), ev.members().map { it.coordinate }) + assertEquals(Address(30617, ownerB, "buzz-infra"), ev.memberAddresses()[1]) + assertEquals(channel, ev.channelId()) + assertTrue(ev.isListed()) + } + + @Test + fun fixtureValidMemberDTagContainsColon() { + val t = tags(arrayOf("d", "platform"), arrayOf("a", "30617:$ownerA:buzz:infra")) + assertNull(ProjectValidator.validate(t)) + val ev = ProjectEvent("0".repeat(64), "f".repeat(64), 1, t, "", "sig") + assertEquals( + "buzz:infra", + ev + .members() + .single() + .address.dTag, + ) + assertEquals("platform", ev.displayName()) + } + + @Test + fun fixtureValidMemberRelayHint() { + val t = tags(arrayOf("d", "platform"), arrayOf("a", "30617:$ownerA:buzz", "wss://relay.example.com")) + assertNull(ProjectValidator.validate(t)) + assertEquals("wss://relay.example.com", ProjectEvent("0".repeat(64), "f".repeat(64), 1, t, "", "sig").members().single().relayHint) + } + + @Test + fun fixtureRejections() { + val cases = + listOf( + ProjectRule.D_CARDINALITY to tags(arrayOf("name", "Platform")), + ProjectRule.D_CARDINALITY to tags(arrayOf("d", "one"), arrayOf("d", "two")), + ProjectRule.D_EMPTY to tags(arrayOf("d", "")), + ProjectRule.MEMBER_DUPLICATE to + tags( + arrayOf("d", "platform"), + arrayOf("a", "30617:$ownerA:buzz", "wss://relay-one.example.com"), + arrayOf("a", "30617:$ownerA:buzz", "wss://relay-two.example.com"), + ), + ProjectRule.MEMBER_TAG_ARITY to tags(arrayOf("d", "platform"), arrayOf("a", "30617:$ownerA:buzz", "wss://relay.example.com", "unexpected")), + ProjectRule.MEMBER_COORDINATE_MALFORMED to tags(arrayOf("d", "platform"), arrayOf("a", "30617:${ownerA.uppercase()}:buzz")), + ProjectRule.MEMBER_COORDINATE_MALFORMED to tags(arrayOf("d", "platform"), arrayOf("a", "30618:$ownerA:buzz")), + ProjectRule.MEMBER_COORDINATE_MALFORMED to tags(arrayOf("d", "platform"), arrayOf("a", "30617:$ownerA:")), + ProjectRule.MEMBER_COORDINATE_MALFORMED to tags(arrayOf("d", "platform"), arrayOf("a", "30617:$ownerA")), + ProjectRule.MEMBER_CAP to tags(arrayOf("d", "platform"), *Array(65) { arrayOf("a", "30617:$ownerA:repo-$it") }), + ProjectRule.MEMBER_CAP to tags(arrayOf("d", "platform"), *Array(65) { arrayOf("a", "30617:$ownerA:buzz") }), + ProjectRule.METADATA_CARDINALITY to tags(arrayOf("d", "platform"), arrayOf("name", "A"), arrayOf("name", "B")), + ProjectRule.METADATA_LENGTH to tags(arrayOf("d", "platform"), arrayOf("name", "n".repeat(257))), + ProjectRule.METADATA_LENGTH to tags(arrayOf("d", "platform"), arrayOf("description", "n".repeat(2049))), + ProjectRule.METADATA_LENGTH to tags(arrayOf("d", "platform"), arrayOf("buzz-visibility", "v".repeat(257))), + ) + for ((rule, t) in cases) { + assertEquals(rule, ProjectValidator.validate(t)?.rule, t.joinToString { it.toList().toString() }.take(200)) + } + // The cap is inclusive. + assertNull(ProjectValidator.validate(tags(arrayOf("d", "platform"), *Array(64) { arrayOf("a", "30617:$ownerA:repo-$it") }))) + } + + @Test + fun metadataIsNotInterpretedAtIngest() { + val t = tags(arrayOf("d", "platform"), arrayOf("buzz-channel", "not-a-uuid"), arrayOf("buzz-visibility", "secret"), arrayOf("x-future", "1")) + assertNull(ProjectValidator.validate(t)) + // An unknown visibility token never hides a project. + assertEquals(ProjectVisibility.LISTED, ProjectEvent("0".repeat(64), "f".repeat(64), 1, t, "ignored", "sig").visibility()) + } + + @Test + fun buildMirrorsTheSdkWriterPolicy() { + val members = + listOf( + ProjectMember(Address(30617, ownerA, "buzz")), + ProjectMember(Address(30617, ownerB, "buzz-infra"), "wss://relay.example.com"), + ) + val ev = + ProjectEvent + .build("platform", "Platform", "Relay, desktop, and mobile.", members, channel, ProjectVisibility.UNLISTED, createdAt = 5) + .toEvent() + + assertEquals(30621, ev.kind) + assertEquals("", ev.content) + assertEquals(listOf("d", "name", "description", "a", "a", "buzz-channel", "buzz-visibility"), ev.tags.map { it[0] }) + assertEquals(members, ev.members()) + assertEquals(ProjectVisibility.UNLISTED, ev.visibility()) + assertTrue(ev.isWellFormed()) + + assertFailsWith { ProjectEvent.build("") } + assertFailsWith { ProjectEvent.build("p", channelId = "general") } + assertFailsWith { ProjectEvent.build("p", name = "n".repeat(257)) } + assertFailsWith { ProjectEvent.build("p", members = List(2) { members[0] }) } + } + + @Test + fun strictCoordinateParsing() { + assertEquals(Address(30617, ownerA, "a:b:c"), ProjectMemberTag.parseCoordinate("30617:$ownerA:a:b:c")) + assertNull(ProjectMemberTag.parseCoordinate("30617:${"g".repeat(64)}:x")) + assertNull(ProjectMemberTag.parseCoordinate("30617:${"a".repeat(63)}:x")) + assertNull(ProjectMemberTag.parse(arrayOf("a"))) + assertEquals(arrayOf("a", "30617:$ownerA:x").toList(), ProjectMemberTag.assemble(ownerA, "x").toList()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/OwnerAttestationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/OwnerAttestationTest.kt index 918581dbcf..60c318ec14 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/OwnerAttestationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/oaOwnerAttestation/OwnerAttestationTest.kt @@ -165,4 +165,57 @@ class OwnerAttestationTest { assertEquals(1713957000L, parsed?.createdAtBefore) assertEquals(1700000000L, parsed?.createdAtAfter) } + + @Test + fun timeBoundsAreStrictAndEveryClauseMustPass() { + assertTrue(AttestationConditions.timeBoundsAllow("", 0)) + assertTrue(AttestationConditions.timeBoundsAllow("created_at<200", 199)) + assertFalse(AttestationConditions.timeBoundsAllow("created_at<200", 200)) + assertTrue(AttestationConditions.timeBoundsAllow("created_at>100", 101)) + assertFalse(AttestationConditions.timeBoundsAllow("created_at>100", 100)) + // kind= is not evaluated at admission. + assertTrue(AttestationConditions.timeBoundsAllow("kind=1&created_at<200", 150)) + // Repeated clauses all apply, even though parse() would keep only the last one. + assertFalse(AttestationConditions.timeBoundsAllow("created_at<100&created_at<200", 150)) + } + + @Test + fun verifyForAuthAtCombinesSignatureAndTime() { + val attestation = OwnerAttestation.sign(agentPub, "created_at<2000", owner.privKey!!) + assertTrue(attestation.verifyForAuthAt(agentPub, 1999)) + assertFalse(attestation.verifyForAuthAt(agentPub, 2000)) + assertEquals(1999L, attestation.validUntil()) + assertNull(attestation.validFrom()) + } + + @Test + fun uppercaseHexIsRejected() { + val attestation = OwnerAttestation.sign(agentPub, "", owner.privKey!!) + // Buzz refuses uppercase owner keys and signatures before its permissive decoder. + assertFalse(OwnerAttestation(attestation.ownerPubKey.uppercase(), "", attestation.sig).verify(agentPub)) + assertFalse(OwnerAttestation(attestation.ownerPubKey, "", attestation.sig.uppercase()).verify(agentPub)) + assertNull(AuthTag.parse(arrayOf("auth", attestation.ownerPubKey.uppercase(), "", attestation.sig))) + assertNull(AuthTag.parse(arrayOf("auth", attestation.ownerPubKey, "", attestation.sig.uppercase()))) + // Exactly four elements. + assertNull(AuthTag.parse(arrayOf("auth", attestation.ownerPubKey, "", attestation.sig, "extra"))) + } + + @Test + fun verifiedOwnerOfAProfileNeedsExactlyOneApplicableAuthTag() { + val ownerPub = owner.pubKey.toHexKey() + val tag = OwnerAttestation.sign(agentPub, "", owner.privKey!!).toTag() + assertEquals(ownerPub, OwnerAttestation.verifiedOwnerOf(agentPub, 0, 1_000, arrayOf(arrayOf("name", "x"), tag))) + + // No first-valid-tag fallback: a second auth tag, even a malformed one, voids it. + assertNull(OwnerAttestation.verifiedOwnerOf(agentPub, 0, 1_000, arrayOf(tag, arrayOf("auth", "junk")))) + // It must authorize this author. + assertNull(OwnerAttestation.verifiedOwnerOf(KeyPair().pubKey.toHexKey(), 0, 1_000, arrayOf(tag))) + + // Every condition must apply to the profile event itself, kind= included. + val kindOne = OwnerAttestation.sign(agentPub, "kind=1", owner.privKey!!).toTag() + assertNull(OwnerAttestation.verifiedOwnerOf(agentPub, 0, 1_000, arrayOf(kindOne))) + val expired = OwnerAttestation.sign(agentPub, "created_at<500", owner.privKey!!).toTag() + assertNull(OwnerAttestation.verifiedOwnerOf(agentPub, 0, 1_000, arrayOf(expired))) + assertEquals(ownerPub, OwnerAttestation.verifiedOwnerOf(agentPub, 0, 400, arrayOf(expired))) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicyTest.kt index d9bc2d48dd..5b7ba8ff6c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicyTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicyTest.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import kotlinx.coroutines.test.runTest import kotlin.test.Test +import kotlin.test.assertFalse import kotlin.test.assertTrue class BuzzMembershipPolicyTest { @@ -142,4 +143,23 @@ class BuzzMembershipPolicyTest { assertTrue(reason(policy.accept(event(agentPub))).startsWith("restricted")) } + + @Test + fun attestationTimeBoundsAreCheckedAgainstTheAuthEvent() = + runTest { + // Valid only strictly between 1000 and 2000, like Buzz's verify_auth_tag_for_auth_event. + val attestation = OwnerAttestation.sign(agentPub, "created_at>1000&created_at<2000", owner.privKey!!) + + suspend fun admitted(authAt: Long): Boolean { + val policy = policyOn(setOf(agentPub)) + policy.onAuthenticated(RelayAuthEvent("00", agentPub, authAt, arrayOf(AuthTag.assemble(attestation)), "", "sig")) + return accepted(policy.accept(event(agentPub))) + } + + assertTrue(admitted(1500), "inside the window") + assertFalse(admitted(2500), "expired") + assertFalse(admitted(500), "not yet valid") + assertFalse(admitted(2000), "the upper bound is strict") + assertFalse(admitted(1000), "the lower bound is strict") + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateValidationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateValidationTest.kt new file mode 100644 index 0000000000..9a5221183c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/rsReadState/ReadStateValidationTest.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.rsReadState + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** NIP-RS `d`-tag shape, "Content Validation", "Reserved Namespace" and the manual-unread override layer. */ +class ReadStateValidationTest { + private val slot = "0123456789abcdef0123456789abcdef" + + @Test + fun slotIdMustBeExactlyThirtyTwoLowercaseHex() { + assertTrue(ReadState.isValidSlotId(slot)) + assertTrue(ReadState.isValidSlotId(ReadState.newSlotId())) + assertFalse(ReadState.isValidSlotId(slot.uppercase())) + assertFalse(ReadState.isValidSlotId(slot.dropLast(1))) + assertFalse(ReadState.isValidSlotId(slot + "0")) + assertFalse(ReadState.isValidSlotId("phone-slot-1")) + + assertEquals(slot, ReadState.slotIdFrom("read-state:$slot")) + assertNull(ReadState.slotIdFrom("read-state:phone-slot-1")) + assertNull(ReadState.slotIdFrom("read-state:${slot.uppercase()}")) + assertNull(ReadState.slotIdFrom(slot)) + + assertTrue(ReadState.isReadState(arrayOf(arrayOf("d", "read-state:$slot"), arrayOf("t", "read-state")))) + assertFalse(ReadState.isReadState(arrayOf(arrayOf("d", "read-state:abc"), arrayOf("t", "read-state")))) + } + + @Test + fun structuralFaultsDiscardTheWholeBlob() { + assertFailsWith { ReadStateContent.decodeFromJson("not json") } + assertFailsWith { ReadStateContent.decodeFromJson("[]") } + assertFailsWith { ReadStateContent.decodeFromJson("""{"client_id":"a","contexts":{}}""") } + assertFailsWith { ReadStateContent.decodeFromJson("""{"v":"1","client_id":"a","contexts":{}}""") } + assertFailsWith { ReadStateContent.decodeFromJson("""{"v":1.5,"client_id":"a","contexts":{}}""") } + assertFailsWith { ReadStateContent.decodeFromJson("""{"v":1,"contexts":{}}""") } + assertFailsWith { ReadStateContent.decodeFromJson("""{"v":1,"client_id":"a"}""") } + assertFailsWith { ReadStateContent.decodeFromJson("""{"v":1,"client_id":"a","contexts":[]}""") } + } + + @Test + fun unknownVersionIsIgnoredNotParsed() { + val blob = ReadStateContent.decodeFromJson("""{"v":2,"client_id":"a","contexts":"whatever"}""") + assertEquals(2, blob.v) + assertFalse(blob.isSupported()) + assertTrue(blob.contexts.isEmpty()) + } + + @Test + fun badEntriesAreDroppedAndTheRestKept() { + val longKey = "k".repeat(257) + val okKey = "k".repeat(256) + val blob = + ReadStateContent.decodeFromJson( + """{"v":1,"client_id":"phone","contexts":{""" + + """"good":1700000000,"zero":0,"max":4294967295,""" + + """"negative":-1,"tooBig":4294967296,"fraction":1.5,"text":"yesterday","nul":null,"obj":{},""" + + """"$longKey":1,"$okKey":2}}""", + ) + assertTrue(blob.isSupported()) + assertEquals( + mapOf("good" to 1_700_000_000L, "zero" to 0L, "max" to 4_294_967_295L, okKey to 2L), + blob.contexts, + ) + } + + @Test + fun keyLimitCountsUtf8Bytes() { + // 128 two-byte characters = 256 bytes (kept); 129 = 258 bytes (dropped). + val kept = "é".repeat(128) + val dropped = "é".repeat(129) + val blob = ReadStateContent.decodeFromJson("""{"v":1,"client_id":"a","contexts":{"$kept":1,"$dropped":2}}""") + assertEquals(setOf(kept), blob.contexts.keys) + } + + @Test + fun moreThanTenThousandEntriesRejectsTheBlob() { + fun blob(n: Int) = (0 until n).joinToString(",", prefix = """{"v":1,"client_id":"a","contexts":{""", postfix = "}}") { "\"c$it\":1" } + + assertEquals(10_000, ReadStateContent.decodeFromJson(blob(10_000)).contexts.size) + assertFailsWith { ReadStateContent.decodeFromJson(blob(10_001)) } + } + + @Test + fun escapingIsABijectionOverTheReservedPrefixes() { + assertEquals("esc:ov_s:evil", ReadStateKeys.escape("ov_s:evil")) + assertEquals("esc:esc:foo", ReadStateKeys.escape("esc:foo")) + assertEquals("esc:ov_anything", ReadStateKeys.escape("ov_anything")) + assertEquals("channel-uuid", ReadStateKeys.escape("channel-uuid")) + listOf("ov_s:evil", "esc:foo", "esc:esc:bar", "plain", "msg:ab").forEach { + assertEquals(it, ReadStateKeys.unescape(ReadStateKeys.escape(it))) + } + // Exactly one `esc:` is stripped. + assertEquals("esc:foo", ReadStateKeys.unescape("esc:esc:foo")) + } + + @Test + fun frontiersUnescapeAndSkipOverrideEntries() { + val blob = + ReadStateContent.decodeFromJson( + """{"v":1,"client_id":"a","contexts":{"chan":10,"esc:ov_s:evil":20,"esc:esc:foo":30,""" + + """"ov_s:chan":2,"ov_c:chan":1,"ov_b:chan":10,"ov_future:x":5}}""", + ) + assertEquals(mapOf("chan" to 10L, "ov_s:evil" to 20L, "esc:foo" to 30L), blob.frontiers()) + assertEquals(mapOf("chan" to OverrideRegister(2, 1, 10)), blob.overrides()) + } + + @Test + fun overrideGroupsAreValidatedAsAWhole() { + val blob = + ReadStateContent.decodeFromJson( + """{"v":1,"client_id":"a","contexts":{""" + + // live: complete triple + """"live":5,"ov_s:live":1,"ov_c:live":0,"ov_b:live":5,""" + + // tombstone floor: lone ov_c + """"dead":5,"ov_c:dead":3,""" + + // partial group (no baseline): whole group rejected, frontier kept + """"partial":5,"ov_s:partial":1,"ov_c:partial":0,""" + + // one invalid sibling: whole group rejected, frontier kept + """"badsib":5,"ov_s:badsib":1,"ov_c:badsib":0,"ov_b:badsib":-1,""" + + // lone ov_s is not a tombstone + """"lones":5,"ov_s:lones":1}}""", + ) + assertEquals( + mapOf("live" to OverrideRegister(1, 0, 5), "dead" to OverrideRegister(0, 3, 0)), + blob.overrides(), + ) + assertEquals(setOf("live", "dead", "partial", "badsib", "lones"), blob.frontiers().keys) + assertFalse(blob.contexts.keys.any { it.endsWith(":partial") || it.endsWith(":badsib") || it.endsWith(":lones") }) + } + + @Test + fun anOverlongKeyRejectsItsWholeGroup() { + val ctx = "c".repeat(252) // "ov_s:" + 252 = 257 bytes + val blob = + ReadStateContent.decodeFromJson( + """{"v":1,"client_id":"a","contexts":{"ov_s:$ctx":1,"ov_c:$ctx":0,"ov_b:$ctx":5}}""", + ) + assertTrue(blob.overrides().isEmpty()) + } + + @Test + fun livenessIsClearWins() { + assertTrue(OverrideRegister(1, 0, 10).isActive(frontier = 10)) + assertFalse(OverrideRegister(1, 0, 10).isActive(frontier = 11)) // natural read past B + assertFalse(OverrideRegister(1, 1, 10).isActive(frontier = 5)) // S == C: clear wins + assertFalse(OverrideRegister(0, 0, 10).isActive(frontier = 5)) + } + + @Test + fun canonicalPublicationCompactsDeadRegistersToTheTombstoneFloor() { + assertEquals(OverrideRegister(2, 1, 10), OverrideRegister(2, 1, 10).canonicalize(frontier = 10)) + assertEquals(OverrideRegister(0, 3, 0), OverrideRegister(3, 2, 10).canonicalize(frontier = 11)) + assertEquals(OverrideRegister(0, 4, 0), OverrideRegister(2, 4, 10).canonicalize(frontier = 0)) + assertNull(OverrideRegister.VIRGIN.canonicalize(frontier = 0)) + + val built = + ReadStateContent.build( + clientId = "phone", + frontiers = mapOf("live" to 10L, "dead" to 20L, "ov_s:weird" to 30L), + overrides = mapOf("live" to OverrideRegister(1, 0, 10), "dead" to OverrideRegister(1, 0, 10), "virgin" to OverrideRegister.VIRGIN), + ) + assertEquals( + mapOf( + "live" to 10L, + "dead" to 20L, + "esc:ov_s:weird" to 30L, + "ov_s:live" to 1L, + "ov_c:live" to 0L, + "ov_b:live" to 10L, + "ov_c:dead" to 1L, + ), + built.contexts, + ) + // Round-trips through the wire validator unchanged. + assertEquals(built, ReadStateContent.decodeFromJson(built.encodeToJson())) + } + + @Test + fun actionsBumpPastBothCountersAndRefuseAtTheCeiling() { + val unread = OverrideRegister.VIRGIN.markUnread(frontier = 50)!! + assertEquals(OverrideRegister(1, 0, 50), unread) + assertTrue(unread.isActive(frontier = 50)) + + val read = unread.markRead(frontierAfter = 50)!! + assertEquals(OverrideRegister(1, 2, 50), read) + assertFalse(read.isActive(frontier = 50)) + + val max = ReadStateContent.MAX_VALUE + assertNull(OverrideRegister(max, 0, 5).markUnread(frontier = 5)) + // At the ceiling a mark-read only succeeds when the override is already inactive. + assertNull(OverrideRegister(max, 0, 5).markRead(frontierAfter = 5)) + assertEquals(OverrideRegister(max, 0, 5), OverrideRegister(max, 0, 5).markRead(frontierAfter = 6)) + } + + /** Two independently-dead registers must not merge into a live one — the reason tombstones exist. */ + @Test + fun mergeTakesComponentwiseMaxAcrossBlobs() { + val phone = ReadStateContent.build("phone", mapOf("chan" to 10L), mapOf("chan" to OverrideRegister(1, 0, 10))) + val laptop = ReadStateContent.build("laptop", mapOf("chan" to 8L), mapOf("chan" to OverrideRegister(1, 2, 8))) + val legacy = ReadStateContent(v = 2, clientId = "future", contexts = mapOf("chan" to 999L)) + + val merged = MergedReadState.merge(listOf(phone, laptop, legacy)) + assertEquals(10L, merged.frontier("chan")) + assertEquals(OverrideRegister(1, 2, 10), merged.overrides["chan"]) + assertFalse(merged.isOverrideActive("chan")) + assertFalse(merged.isUnread("chan", latestMessageTs = 10)) + assertTrue(merged.isUnread("chan", latestMessageTs = 11)) + assertTrue(merged.isUnread("never-read", latestMessageTs = 1)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzChatMessageTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzChatMessageTest.kt new file mode 100644 index 0000000000..530f14e9e7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzChatMessageTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.stream + +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import kotlin.test.Test +import kotlin.test.assertEquals + +/** The kind-9 Buzz message must carry exactly the tags `build_message` emits, in its order. */ +class BuzzChatMessageTest { + private val channel = "6a39da2f-33c0-44f6-a050-c4da0138644a" + private val root = "a".repeat(64) + private val parent = "b".repeat(64) + private val alice = "c".repeat(64) + + private fun tags(t: EventTemplate) = t.tags.map { it.toList() } + + @Test + fun plainMessageIsKind9WithOnlyTheChannelTag() { + val t = BuzzChatMessage.build(channel, "hello") + assertEquals(ChatEvent.KIND, t.kind) + assertEquals("hello", t.content) + assertEquals(listOf(listOf("h", channel)), tags(t)) + } + + @Test + fun directReplyCollapsesToOneReplyMarker() { + val t = BuzzChatMessage.build(channel, "hi", threadRoot = parent, replyTo = parent, mentions = listOf(alice)) + assertEquals( + listOf( + listOf("h", channel), + listOf("e", parent, "", "reply"), + listOf("p", alice), + ), + tags(t), + ) + } + + @Test + fun nestedBroadcastReplyCarriesRootReplyMentionsThenBroadcast() { + val t = BuzzChatMessage.build(channel, "hi", threadRoot = root, replyTo = parent, mentions = listOf(alice, alice), broadcast = true) + assertEquals( + listOf( + listOf("h", channel), + listOf("e", root, "", "root"), + listOf("e", parent, "", "reply"), + listOf("p", alice), + listOf("broadcast", "1"), + ), + tags(t), + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzEditTagOverlayTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzEditTagOverlayTest.kt new file mode 100644 index 0000000000..1637587b91 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/BuzzEditTagOverlayTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.stream + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** Ports the cases in Buzz's `applyEditTagOverlay.test.mjs`. */ +class BuzzEditTagOverlayTest { + private fun imeta(url: String) = arrayOf("imeta", "url $url", "m image/png", "x x", "size 1") + + private fun emoji( + shortcode: String, + url: String, + ) = arrayOf("emoji", shortcode, url) + + private fun overlay( + original: Array>, + edit: Array>, + ) = BuzzEditTagOverlay.apply(original, edit).map { it.toList() } + + private fun List>.named(name: String) = filter { it[0] == name } + + @Test + fun noEditIsAPassThrough() { + val tags = arrayOf(arrayOf("h", "uuid"), imeta("https://b/a.png")) + assertSame(tags, BuzzEditTagOverlay.apply(tags, null)) + } + + @Test + fun editReplacesImetaAndKeepsOriginalNonImeta() { + val out = + overlay( + arrayOf(arrayOf("h", "uuid"), arrayOf("p", "mention1"), imeta("https://b/a.png"), imeta("https://b/b.png")), + arrayOf(arrayOf("h", "uuid"), arrayOf("e", "orig"), imeta("https://b/a.png"), imeta("https://b/c.png")), + ) + assertEquals(listOf(listOf("h", "uuid"), listOf("p", "mention1")), out.filter { it[0] != "imeta" }) + assertEquals(listOf("url https://b/a.png", "url https://b/c.png"), out.named("imeta").map { it[1] }) + } + + @Test + fun editWithoutImetaStripsAttachments() { + val out = overlay(arrayOf(arrayOf("h", "uuid"), imeta("https://b/a.png")), arrayOf(arrayOf("h", "uuid"), arrayOf("e", "x"))) + assertTrue(out.named("imeta").isEmpty()) + assertEquals(1, out.named("h").size) + } + + @Test + fun editsOwnChannelAndTargetTagsNeverLeak() { + val out = + overlay( + arrayOf(arrayOf("h", "uuid-original"), arrayOf("p", "mention1")), + arrayOf(arrayOf("h", "uuid-from-edit"), arrayOf("e", "target"), imeta("https://b/a.png")), + ) + assertEquals(listOf(listOf("h", "uuid-original")), out.named("h")) + assertTrue(out.named("e").isEmpty()) + assertEquals(1, out.named("imeta").size) + } + + @Test + fun addedMentionsJoinTheOriginalOnes() { + val out = overlay(arrayOf(arrayOf("h", "uuid"), arrayOf("p", "original")), arrayOf(arrayOf("h", "uuid"), arrayOf("e", "x"), arrayOf("p", "added"))) + assertEquals(listOf(listOf("p", "original"), listOf("p", "added")), out.named("p")) + } + + @Test + fun mentionSnapshotReplacesReferencesButKeepsAgentAddress() { + val original = arrayOf(arrayOf("h", "uuid"), arrayOf("mention", "agent", "agent-address"), arrayOf("mention", "old")) + val out = overlay(original, arrayOf(arrayOf("buzz:mention-snapshot"), arrayOf("mention", "agent", "agent-address"), arrayOf("mention", "new"))) + assertEquals(listOf(listOf("mention", "agent", "agent-address"), listOf("mention", "new")), out.named("mention")) + + val removed = overlay(arrayOf(arrayOf("h", "uuid"), arrayOf("mention", "old")), arrayOf(arrayOf("buzz:mention-snapshot"))) + assertTrue(removed.named("mention").isEmpty()) + + val legacy = overlay(arrayOf(arrayOf("h", "uuid"), arrayOf("mention", "old")), arrayOf(arrayOf("h", "uuid"), arrayOf("e", "x"))) + assertEquals(listOf(listOf("mention", "old")), legacy.named("mention")) + } + + @Test + fun emojiComesFromTheEditOnlyWhenItHasSome() { + val original = arrayOf(arrayOf("h", "uuid"), imeta("https://b/a.png"), emoji("catjam", "https://b/catjam.gif")) + + val replaced = overlay(original, arrayOf(arrayOf("e", "x"), emoji("catjam", "https://b/catjam.gif"), emoji("rickroll", "https://b/rr.gif"))) + assertEquals(listOf("catjam", "rickroll"), replaced.named("emoji").map { it[1] }) + + // A tag-less edit keeps the emoji but still clears the attachments. + val tagless = overlay(original, arrayOf(arrayOf("h", "uuid"), arrayOf("e", "x"))) + assertEquals(listOf(listOf("emoji", "catjam", "https://b/catjam.gif")), tagless.named("emoji")) + assertTrue(tagless.named("imeta").isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/CanvasEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/CanvasEventTest.kt index 0a625443d4..8d13f8d576 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/CanvasEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/CanvasEventTest.kt @@ -20,8 +20,13 @@ */ package com.vitorpamplona.quartz.buzz.stream +import com.vitorpamplona.quartz.buzz.stream.tags.ExpectedRevisionTag import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue class CanvasEventTest { private val channelId = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" @@ -57,4 +62,82 @@ class CanvasEventTest { assertEquals(channelId, event.channel()) assertEquals("# Doc", event.indexableContent()) } + + private val headId = "ab".repeat(32) + + @Test + fun buildOmitsExpectedRevisionByDefault() { + val template = CanvasEvent.build(channelId, "x", createdAt = 1L) + assertTrue(template.tags.none { it[0] == ExpectedRevisionTag.TAG_NAME }) + } + + @Test + fun buildEmitsExpectedRevisionHeadAndNone() { + val onHead = CanvasEvent.build(channelId, "x", createdAt = 1L, expectedRevision = headId) + assertEquals(listOf("expected-revision", headId), onHead.tags.single { it[0] == "expected-revision" }.toList()) + + val onNone = CanvasEvent.build(channelId, "x", createdAt = 1L, expectedRevision = ExpectedRevisionTag.NONE) + assertEquals(listOf("expected-revision", "none"), onNone.tags.single { it[0] == "expected-revision" }.toList()) + } + + @Test + fun buildRejectsMalformedExpectedRevision() { + assertFailsWith { CanvasEvent.build(channelId, "x", expectedRevision = "") } + assertFailsWith { CanvasEvent.build(channelId, "x", expectedRevision = "NONE") } + assertFailsWith { CanvasEvent.build(channelId, "x", expectedRevision = "ab".repeat(31)) } + assertFailsWith { CanvasEvent.build(channelId, "x", expectedRevision = "zz".repeat(32)) } + } + + @Test + fun expectedRevisionParsesOnlyTheExactTwoElementShape() { + fun canvas(vararg tags: Array) = CanvasEvent("00", "00", 0L, arrayOf(arrayOf("h", channelId), *tags), "", "00") + + assertEquals(headId, canvas(arrayOf("expected-revision", headId)).expectedRevision()) + assertEquals("none", canvas(arrayOf("expected-revision", "none")).expectedRevision()) + assertNull(canvas().expectedRevision()) + assertNull(canvas(arrayOf("expected-revision", headId, "extra")).expectedRevision()) + assertNull(canvas(arrayOf("expected-revision", "bogus")).expectedRevision()) + } + + @Test + fun writeCreatedAtStampsStrictlyAheadOfTheHead() { + val now = 1_700_000_000L + assertEquals(now, CanvasEvent.writeCreatedAt(null, now)) + assertEquals(now, CanvasEvent.writeCreatedAt(now - 100, now)) + assertEquals(now + 1, CanvasEvent.writeCreatedAt(now, now)) + assertEquals(now + 11, CanvasEvent.writeCreatedAt(now + 10, now)) + } + + @Test + fun writeCreatedAtRefusesAHeadPastTheSixtySecondCeiling() { + val now = 1_700_000_000L + // now + 60 is the last head a writer may ratchet past; the write lands at now + 61, + // inside the relay's 300 s canvas bound. + assertEquals(now + 61, CanvasEvent.writeCreatedAt(now + 60, now)) + assertTrue(now + 61 <= now + CanvasEvent.RELAY_MAX_FUTURE_SECS) + assertNull(CanvasEvent.writeCreatedAt(now + 61, now)) + assertNull(CanvasEvent.writeCreatedAt(Long.MAX_VALUE, now)) + } + + @Test + fun headOrderIsCreatedAtDescThenIdAsc() { + val small = "01".repeat(32) + val big = "ff".repeat(32) + assertTrue(CanvasEvent.isNewerHead(10, big, null, null)) + assertTrue(CanvasEvent.isNewerHead(11, big, 10, small)) + assertFalse(CanvasEvent.isNewerHead(9, small, 10, big)) + // Same second: the smallest id wins, regardless of arrival order. + assertTrue(CanvasEvent.isNewerHead(10, small, 10, big)) + assertFalse(CanvasEvent.isNewerHead(10, big, 10, small)) + // A revision never displaces itself. + assertFalse(CanvasEvent.isNewerHead(10, small, 10, small)) + } + + @Test + fun conflictPrefixMatchesTheRelayMarkers() { + assertTrue(CanvasEvent.isConflict("conflict: canvas changed since it was loaded")) + assertTrue(CanvasEvent.isConflict("conflict: canvas revision does not exist")) + assertFalse(CanvasEvent.isConflict("invalid: bad expected canvas revision")) + assertFalse(CanvasEvent.isConflict(null)) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessageEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessageEventTest.kt index 9f7284f6b7..c46d891865 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessageEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/stream/SystemMessageEventTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.buzz.stream import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull class SystemMessageEventTest { private val channelId = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" @@ -142,4 +143,24 @@ class SystemMessageEventTest { assertEquals("pinned_changed", payload.type) assertEquals(actor, payload.actor) } + + /** `admin_outbox_worker.rs` emits `{type, target, action_id}` with no actor at all. */ + @Test + fun adminKickCarriesTargetAndActionButNoActor() { + val payload = SystemMessagePayload.decodeFromJson("""{"type":"admin_kick","target":"$target","action_id":"5f0c"}""") + assertEquals(SystemMessagePayload.ADMIN_KICK, payload.type) + assertEquals(target, payload.target) + assertEquals("5f0c", payload.actionId) + assertNull(payload.actor) + assertEquals(target, payload.subject()) + } + + /** The ephemeral-channel reaper (`main.rs`) emits a bare `{type}`. */ + @Test + fun channelAutoArchivedHasNoPeople() { + val payload = SystemMessagePayload.decodeFromJson("""{"type":"channel_auto_archived"}""") + assertEquals(SystemMessagePayload.CHANNEL_AUTO_ARCHIVED, payload.type) + assertNull(payload.actor) + assertNull(payload.subject()) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogEventTest.kt new file mode 100644 index 0000000000..18232d97a7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/teamCatalog/TeamCatalogEventTest.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.teamCatalog + +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TeamCatalogEventTest { + private val teamId = "builtin-team:welcome" + + private fun EventTemplate.toEvent() = TeamCatalogEvent("0".repeat(64), "f".repeat(64), createdAt, tags, content, "sig") + + private fun event( + content: String, + tags: Array> = arrayOf(arrayOf("d", teamId), arrayOf("shared", "true")), + ) = TeamCatalogEvent("0".repeat(64), "f".repeat(64), 1, tags, content, "sig") + + private val reviewer = + TeamCatalogMember( + memberKey = "k".repeat(64), + displayName = "Reviewer", + systemPrompt = "Review every PR.", + runtime = "goose", + namePool = listOf("Ada", "Grace"), + respondTo = "owner-only", + parallelism = 2, + sessionPolicy = AcpSessionPolicy.THREAD, + ) + + @Test + fun encodesInUpstreamFieldOrderAndOmitsAbsentFields() { + val content = + TeamCatalogContent( + v = 1, + name = "Welcome", + description = "Starter team", + members = listOf(reviewer, TeamCatalogMember(memberKey = "m2", displayName = "Scribe")), + ) + // serde_json output of the same `TeamCatalogContent` (declaration order, skip_serializing_if). + val expected = + """{"v":1,"name":"Welcome","description":"Starter team","members":[""" + + """{"member_key":"${"k".repeat(64)}","display_name":"Reviewer","system_prompt":"Review every PR.","runtime":"goose",""" + + """"name_pool":["Ada","Grace"],"respond_to":"owner-only","parallelism":2,"session_policy":"thread"},""" + + """{"member_key":"m2","display_name":"Scribe"}]}""" + assertEquals(expected, content.encodeToJson()) + assertEquals(content, TeamCatalogContent.decodeFromJson(expected)) + } + + @Test + fun emptyMembersAreStillWritten() { + assertEquals("""{"v":1,"name":"Solo","members":[]}""", TeamCatalogContent(v = 1, name = "Solo", members = emptyList()).encodeToJson()) + } + + @Test + fun buildRoundTrips() { + val content = TeamCatalogContent(v = 1, name = "Welcome", instructions = "Be kind.", members = listOf(reviewer)) + val ev = TeamCatalogEvent.build(content, teamId, shared = true, createdAt = 100).toEvent() + + assertEquals(30178, ev.kind) + assertEquals(teamId, ev.teamId()) + assertTrue(ev.isShared()) + assertTrue(ev.isEnvelopeValid()) + assertEquals(content, ev.catalog()) + + val unshared = TeamCatalogEvent.build(content, teamId, shared = false, createdAt = 100).toEvent() + assertFalse(unshared.isShared()) + assertEquals(listOf("d"), unshared.tags.map { it[0] }) + } + + @Test + fun republishingNeverSortsBehindThePriorHead() { + val content = TeamCatalogContent(v = 1, name = "Welcome", members = emptyList()) + val future = 4_000_000_000L + assertEquals(future + 1, TeamCatalogEvent.build(content, teamId, shared = true, priorHeadCreatedAt = future).createdAt) + } + + @Test + fun readerRejectsWhatUpstreamRejects() { + // Missing `v`, unsupported `v`, wrong-typed field, over the member cap, repeated key. + assertNull(event("""{"name":"No Version","members":[]}""").catalogOrNull()) + assertNull(event("""{"v":2,"name":"Future","members":[]}""").catalogOrNull()) + assertNull(event("""{"v":1,"name":"Bad","members":[{"member_key":"m1","display_name":"One","parallelism":"lots"}]}""").catalogOrNull()) + val tooMany = (0..TeamCatalogContent.MAX_MEMBERS).joinToString(",") { """{"member_key":"m$it","display_name":"M$it"}""" } + assertNull(event("""{"v":1,"name":"Too Many","members":[$tooMany]}""").catalogOrNull()) + assertNull(event("""{"v":1,"name":"Twins","members":[{"member_key":"k","display_name":"One"},{"member_key":"k","display_name":"Two"}]}""").catalogOrNull()) + assertNull(event("""{"v":1,"name":" ","members":[]}""").catalogOrNull()) + } + + @Test + fun membersViolatingTheV1ContractAreRejected() { + val fields = + listOf( + """"parallelism":999""", + """"parallelism":0""", + """"respond_to":"everyone"""", + """"runtime":""""", + """"model":"${"m".repeat(257)}"""", + """"name_pool":[""]""", + """"builtin_slug":"reviewer"""", + """"projection_hash":"${"a".repeat(64)}"""", + """"builtin_slug":"reviewer","projection_hash":"xyz"""", + """"avatar_url":"javascript:alert(1)"""", + ) + for (field in fields) { + assertNull(event("""{"v":1,"name":"T","members":[{"member_key":"k","display_name":"One",$field}]}""").catalogOrNull(), field) + } + val ok = event("""{"v":1,"name":"T","members":[{"member_key":"k","display_name":"One","avatar_url":"https://example.com/a.png","parallelism":32}]}""") + assertNotNull(ok.catalogOrNull()) + } + + @Test + fun sessionPolicyIsLenientLikeUpstream() { + fun policy(raw: String) = TeamCatalogContent.decodeFromJson("""{"v":1,"name":"T","members":[{"member_key":"k","display_name":"One","session_policy":$raw}]}""").members[0].sessionPolicy + + assertEquals(AcpSessionPolicy.THREAD, policy("\"thread\"")) + assertEquals(AcpSessionPolicy.CHANNEL, policy("\"channel\"")) + assertEquals(AcpSessionPolicy.CHANNEL, policy("\"weird\"")) + assertEquals(AcpSessionPolicy.CHANNEL, policy("5")) + assertEquals(AcpSessionPolicy.CHANNEL, policy("null")) + // The default (channel) is never written. + assertFalse(TeamCatalogMember("k", "One").let { TeamCatalogContent(1, "T", members = listOf(it)) }.encodeToJson().contains("session_policy")) + } + + @Test + fun unknownFieldsAreIgnored() { + val parsed = event("""{"v":1,"name":"T","future":{"x":1},"members":[{"member_key":"k","display_name":"One","later":true}]}""").catalogOrNull() + assertNotNull(parsed) + assertEquals("One", parsed.members[0].displayName) + } + + @Test + fun envelopeRules() { + val ok = arrayOf(arrayOf("d", teamId)) + assertNull(TeamCatalogEvent.envelopeError(ok)) + assertNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", teamId), arrayOf("shared", "true")))) + assertNotNull(TeamCatalogEvent.envelopeError(emptyArray())) + assertNotNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", "")))) + assertNotNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d"), arrayOf("d", teamId)))) + assertNotNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", "a".repeat(65))))) + assertNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", "a".repeat(64))))) + assertNotNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", "team 1")))) + assertNotNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", "team\n1")))) + assertNotNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", teamId), arrayOf("shared", "false")))) + assertNotNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", teamId), arrayOf("shared", "true", "extra")))) + assertNotNull(TeamCatalogEvent.envelopeError(arrayOf(arrayOf("d", teamId), arrayOf("shared", "true"), arrayOf("shared", "true")))) + } + + @Test + fun buildRefusesInvalidInput() { + val content = TeamCatalogContent(v = 1, name = "T", members = emptyList()) + assertFailsWith { TeamCatalogEvent.build(content, "", shared = true) } + assertFailsWith { TeamCatalogEvent.build(content.copy(v = 2), teamId, shared = true) } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/threading/BuzzThreadMarkersTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/threading/BuzzThreadMarkersTest.kt new file mode 100644 index 0000000000..fc641382c5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/threading/BuzzThreadMarkersTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.threading + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** Mirrors the tests in Buzz's `buzz-core/src/nip10.rs`, plus the reply-root derivation built on it. */ +class BuzzThreadMarkersTest { + private val a = "a".repeat(64) + private val b = "b".repeat(64) + + private fun markers(vararg tags: Array) = arrayOf(*tags).buzzThreadMarkers() + + @Test + fun noETagsYieldNoMarkers() { + assertEquals(BuzzThreadMarkers(null, null), markers()) + } + + @Test + fun rootAndReplyBothParsed() { + val m = markers(arrayOf("e", a, "", "root"), arrayOf("e", b, "", "reply")) + assertEquals(a, m.root) + assertEquals(b, m.reply) + assertEquals(a to b, m.resolve()) + } + + @Test + fun replyOnlyIsADirectReplyToTheRoot() { + val m = markers(arrayOf("e", a, "", "reply")) + assertNull(m.root) + assertEquals(a to a, m.resolve()) + } + + @Test + fun rootOnlyIsTopLevel() { + assertNull(markers(arrayOf("e", a, "", "root")).resolve()) + } + + @Test + fun bareAndThreeElementTagsAreIgnored() { + assertEquals(BuzzThreadMarkers(null, null), markers(arrayOf("e", a))) + // The relay needs the marker at index 3; ["e", id, "reply"] is not a thread link. + assertEquals(BuzzThreadMarkers(null, null), markers(arrayOf("e", a, "reply"))) + } + + @Test + fun malformedIdsAreIgnored() { + assertEquals(BuzzThreadMarkers(null, null), markers(arrayOf("e", "bad", "", "root"), arrayOf("e", "z".repeat(64), "", "reply"))) + // A valid root with a malformed reply is top-level. + val m = markers(arrayOf("e", a, "", "root"), arrayOf("e", "bad", "", "reply")) + assertEquals(a, m.root) + assertNull(m.reply) + assertNull(m.resolve()) + } + + @Test + fun lastValidOccurrenceWins() { + val m = markers(arrayOf("e", a, "", "reply"), arrayOf("e", b, "", "reply")) + assertEquals(b, m.reply) + } + + @Test + fun replyRootFollowsTheRelaysDerivation() { + val own = "c".repeat(64) + // Parent is top-level: it starts the thread. + assertEquals(own, arrayOf>().buzzThreadRootForReplyTo(own)) + // Parent is a direct reply to a: the thread root is a. + assertEquals(a, arrayOf(arrayOf("e", a, "", "reply")).buzzThreadRootForReplyTo(own)) + // Parent is a nested reply: its root marker is the thread root. + assertEquals(a, arrayOf(arrayOf("e", a, "", "root"), arrayOf("e", b, "", "reply")).buzzThreadRootForReplyTo(own)) + // Parent carries only a root marker (NIP-10 style): the relay treats it as top-level, so + // replying to it roots at the parent itself, not at its root marker. + assertEquals(own, arrayOf(arrayOf("e", a, "", "root")).buzzThreadRootForReplyTo(own)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workflow/WorkflowDefEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workflow/WorkflowDefEventTest.kt index ecf0c994a6..5b35bd6eb1 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workflow/WorkflowDefEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workflow/WorkflowDefEventTest.kt @@ -61,4 +61,17 @@ class WorkflowDefEventTest { assertNull(ev.name()) assertEquals(channel, ev.channel()) } + + @Test + fun expectedRevisionIsOmittedOnCreateAndCarriedOnUpdate() { + val create = WorkflowDefEvent.build(workflowId, channel, yaml) + assertNull(WorkflowDefEvent("00", "f".repeat(64), create.createdAt, create.tags, create.content, "sig").expectedRevision()) + + val head = "a".repeat(64) + val update = WorkflowDefEvent.build(workflowId, channel, yaml, expectedRevision = head) + val ev = WorkflowDefEvent("00", "f".repeat(64), update.createdAt, update.tags, update.content, "sig") + assertEquals(head, ev.expectedRevision()) + // Same wire shape as Buzz's build_workflow_update: ["expected-revision", ]. + assertEquals(listOf("expected-revision", head), update.tags.single { it[0] == "expected-revision" }.toList()) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelNameTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelNameTest.kt new file mode 100644 index 0000000000..566d070745 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelNameTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.workspace + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Mirrors `channel_names_trim_whitespace_and_drop_all_leading_hashes` in `buzz-core/src/channel.rs`. */ +class BuzzChannelNameTest { + @Test + fun canonicalNameMatchesUpstreamVectors() { + assertEquals("channel", canonicalBuzzChannelName("channel")) + assertEquals("channel", canonicalBuzzChannelName("#channel")) + assertEquals("channel", canonicalBuzzChannelName("###channel")) + assertEquals("channel", canonicalBuzzChannelName(" ###channel ")) + assertEquals("channel", canonicalBuzzChannelName("# channel")) + assertEquals("channel", canonicalBuzzChannelName("### channel ")) + assertEquals("", canonicalBuzzChannelName(" ### ")) + assertEquals("", canonicalBuzzChannelName("# #")) + assertEquals("", canonicalBuzzChannelName("### ###")) + assertEquals("channel#topic", canonicalBuzzChannelName("channel#topic")) + } + + @Test + fun aNameOfOnlyHashesAndWhitespaceIsRefused() { + assertTrue(isValidBuzzChannelName("#design")) + assertFalse(isValidBuzzChannelName("")) + assertFalse(isValidBuzzChannelName(" ")) + assertFalse(isValidBuzzChannelName("# #")) + assertFalse(isValidBuzzChannelName("\t##\n")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzCustomEmojiTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzCustomEmojiTest.kt new file mode 100644 index 0000000000..c6480df17f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzCustomEmojiTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.workspace + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** `normalize_custom_emoji_shortcode` in `buzz-sdk/src/builders.rs`, which the relay runs on 30030/10030 `emoji` tags. */ +class BuzzCustomEmojiTest { + @Test + fun acceptsTheNip30AlphabetUpToSixtyFourBytes() { + assertTrue(BuzzCustomEmoji.isValidShortcode("party_parrot")) + assertTrue(BuzzCustomEmoji.isValidShortcode("Soap-Box_123")) + assertTrue(BuzzCustomEmoji.isValidShortcode("a".repeat(64))) + assertFalse(BuzzCustomEmoji.isValidShortcode("a".repeat(65))) + } + + @Test + fun rejectsCharactersOutsideTheAlphabet() { + assertFalse(BuzzCustomEmoji.isValidShortcode("")) + assertFalse(BuzzCustomEmoji.isValidShortcode("::")) + assertFalse(BuzzCustomEmoji.isValidShortcode("soap box")) + assertFalse(BuzzCustomEmoji.isValidShortcode("café")) + assertFalse(BuzzCustomEmoji.isValidShortcode("a.b")) + } + + @Test + fun normalizesLikeTheRelay() { + assertEquals("partyparrot", BuzzCustomEmoji.normalizeShortcode(" :PartyParrot: ")) + assertNull(BuzzCustomEmoji.normalizeShortcode(": spaced :")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsRefusalTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsRefusalTest.kt index 91f5c03c4e..d7247c89bc 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsRefusalTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsRefusalTest.kt @@ -99,6 +99,22 @@ class PoolRequestsRefusalTest { assertTrue(suppressed.filterIsInstance().isEmpty(), "a thrice-refused filter must not be replayed again") } + @Test + fun aQueryTimeoutIsNotARefusal() = + kotlinx.coroutines.test.runTest { + // Buzz ends a REQ that hits its query deadline with `error: query timed out` instead of + // EOSE. That is a slow moment, not "this relay won't serve this filter", so the pool must + // keep replaying it however many times it happens. + val pool = PoolRequests(maxRefusalsBeforeSuppress = 2) + pool.addOrUpdate("sub", mapOf(relay to plainFilter()), null) + + repeat(4) { attempt -> + val sent = reconnectAndSync(pool) + assertEquals(1, sent.filterIsInstance().size, "reconnect #$attempt should replay the REQ") + close(pool, "sub", "error: query timed out") + } + } + @Test fun aMeaningfulFilterChangeReEnablesTheReq() = kotlinx.coroutines.test.runTest { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEventTest.kt new file mode 100644 index 0000000000..9bf428cb07 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEventTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip38UserStatus + +import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class UserStatusEventTest { + private fun status( + content: String, + vararg tags: Array, + ) = UserStatusEvent("00", "00", 0L, arrayOf(arrayOf("d", "general"), *tags), content, "00") + + /** Buzz's `build_user_status` writes a plain emoji as a 2-element `["emoji", ]`. */ + @Test + fun readsBuzzTwoElementStatusEmoji() { + val event = status("In a meeting", arrayOf("emoji", "📅")) + assertEquals("📅", event.statusEmoji()) + // It is not a NIP-30 custom emoji, so it must not leak into that list. + assertTrue(event.taggedEmojis().isEmpty()) + assertFalse(event.isCleared()) + } + + @Test + fun nip30CustomEmojiIsNotAStatusEmoji() { + val event = status("hi :soapbox:", arrayOf("emoji", "soapbox", "https://example.com/soapbox.png")) + assertNull(event.statusEmoji()) + assertEquals(listOf("soapbox"), event.taggedEmojis().map { it.code }) + } + + @Test + fun bothShapesCanCoexist() { + val event = + status( + "hi :soapbox:", + arrayOf("emoji", "soapbox", "https://example.com/soapbox.png"), + arrayOf("emoji", "🌴"), + ) + assertEquals("🌴", event.statusEmoji()) + assertEquals(listOf("soapbox"), event.taggedEmojis().map { it.code }) + } + + @Test + fun anEmojiOnlyStatusIsNotCleared() { + assertFalse(status("", arrayOf("emoji", "🌴")).isCleared()) + assertTrue(status("").isCleared()) + assertTrue(status(" ", arrayOf("emoji", " ")).isCleared()) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/identityNames/IdentityNamePolicyTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/identityNames/IdentityNamePolicyTest.kt new file mode 100644 index 0000000000..b82684bef1 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/identityNames/IdentityNamePolicyTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.identityNames + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Runs Buzz's portable "contextual identity names" v1 fixtures (a vendored, unmodified copy of + * `mobile/test/shared/identity_names/identity-names.fixtures.json`, Apache-2.0) against the port. + */ +class IdentityNamePolicyTest { + private val fixtures: JsonObject = + Json + .parseToJsonElement( + IdentityNamePolicyTest::class.java + .getResourceAsStream("/buzz/identity-names.fixtures.json")!! + .bufferedReader() + .readText(), + ).jsonObject + + private fun JsonObject.string(key: String): String? = this[key]?.takeIf { it !is JsonNull }?.jsonPrimitive?.content + + @Test + fun conformsToEveryPortableFixture() { + assertEquals(IdentityNamePolicy.VERSION, fixtures["version"]!!.jsonPrimitive.int) + val cases = fixtures["cases"]!!.jsonArray + assertTrue(cases.isNotEmpty()) + + cases.forEach { element -> + val case = element.jsonObject + val identities = + case["identities"]!!.jsonArray.map { + val raw = it.jsonObject + NamingIdentity( + pubkey = raw.string("pubkey")!!, + name = raw.string("name")!!, + isAgent = raw["isAgent"]?.jsonPrimitive?.boolean ?: false, + ownerPubkey = raw.string("ownerPubkey"), + ) + } + val candidates = case["candidates"]?.takeIf { it !is JsonNull }?.jsonArray?.map { it.jsonPrimitive.content } + val actual = IdentityNamePolicy.resolve(identities, viewer = case.string("viewer"), candidates = candidates) + + val expected = + case["expected"]!!.jsonObject.mapValues { (_, value) -> + val obj = value.jsonObject + ResolvedIdentityName(obj.string("name")!!, obj.string("qualifier")) + } + assertEquals(expected, actual, "fixture ${case.string("name")}") + } + } + + @Test + fun trimsOnlyTheContractWhitespace() { + val key = "1".repeat(64) + // U+0085 is not ECMAScript whitespace; U+3000 is. + val result = IdentityNamePolicy.resolve(listOf(NamingIdentity(key, "\u0085Honey "))) + assertEquals(ResolvedIdentityName("\u0085Honey"), result[key]) + } + + @Test + fun rejectsAnInvalidKey() { + assertFailsWith { IdentityNamePolicy.resolve(listOf(NamingIdentity("nope", "Honey"))) } + assertFailsWith { IdentityNamePolicy.resolve(emptyList(), viewer = "nope") } + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectFixturesTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectFixturesTest.kt new file mode 100644 index 0000000000..6f070bb870 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/mpProjects/ProjectFixturesTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.buzz.mpProjects + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Runs every case of Buzz's NIP-MP ingest oracle (`docs/nips/NIP-MP.fixtures.json`, copied + * verbatim to `resources/buzz/NIP-MP.fixtures.json`) through [ProjectValidator]: each + * `accept` must pass, and each `reject` must fail on one of the case's `reject_rules` — so the + * port cannot pass a reject case by refusing it for an unrelated reason. Upstream's relay and + * SDK validators run the same file. + */ +class ProjectFixturesTest { + private val fixtures = + Json + .parseToJsonElement( + this::class.java + .getResourceAsStream("/buzz/NIP-MP.fixtures.json")!! + .readBytes() + .decodeToString(), + ).jsonObject + + private fun JsonArray.toTags() = map { tag -> tag.jsonArray.map { it.jsonPrimitive.content }.toTypedArray() }.toTypedArray() + + @Test + fun everyFixtureCaseMatchesItsExpectation() { + assertEquals(ProjectEvent.KIND, fixtures["kind"]!!.jsonPrimitive.int) + assertEquals(ProjectValidator.MEMBER_CAP, fixtures["member_cap"]!!.jsonPrimitive.int) + + val cases = fixtures["cases"]!!.jsonArray + assertTrue(cases.size >= 31, "fixture file shrank: ${cases.size} cases") + + var accepted = 0 + var rejected = 0 + for (case in cases) { + val obj = case.jsonObject + val name = obj["name"]!!.jsonPrimitive.content + val template = obj["template"]!!.jsonObject + assertEquals(ProjectEvent.KIND, template["kind"]!!.jsonPrimitive.int, name) + val tags = template["tags"]!!.jsonArray.toTags() + val result = ProjectValidator.validate(tags) + + when (obj["expect"]!!.jsonPrimitive.content) { + "accept" -> { + assertNull(result, "$name should be accepted, got $result") + val event = ProjectEvent("0".repeat(64), "f".repeat(64), 1, tags, template["content"]!!.jsonPrimitive.content, "sig") + assertTrue(event.isWellFormed(), name) + accepted++ + } + "reject" -> { + assertNotNull(result, "$name should be rejected") + val allowed = obj["reject_rules"]!!.jsonArray.map { it.jsonPrimitive.content } + assertTrue(result.rule.id in allowed, "$name rejected by ${result.rule.id}, expected one of $allowed") + rejected++ + } + else -> error("unknown expectation in $name") + } + } + assertEquals(11, accepted) + assertEquals(20, rejected) + } +} diff --git a/quartz/src/jvmTest/resources/buzz/NIP-MP.fixtures.json b/quartz/src/jvmTest/resources/buzz/NIP-MP.fixtures.json new file mode 100644 index 0000000000..cfc570ec65 --- /dev/null +++ b/quartz/src/jvmTest/resources/buzz/NIP-MP.fixtures.json @@ -0,0 +1,1462 @@ +{ + "$comment": "NIP-MP conformance fixtures \u2014 the shared ingest contract for the relay validator, the Rust event builder, and the TypeScript event builder. Each case carries an UNSIGNED template: consumers sign it with their own test key, because signing fixes the event id and signature and a stored literal would not survive re-serialization. `expect` is the ingest outcome. `reject_rules` lists the validation rules that may fire; an implementation must reject for one of them, so it cannot pass by rejecting for an unrelated reason. This file covers single-event accept/reject only; the client-side fold has its own oracle in NIP-MP.fold-fixtures.json. Spec: docs/nips/NIP-MP.md.", + "version": 1, + "kind": 30621, + "member_cap": 64, + "owners": { + "a": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "b": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "cases": [ + { + "name": "valid_minimal", + "expect": "accept", + "note": "Only the identity tag. A project needs nothing but a `d` tag to be a valid addressable container.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ] + ] + } + }, + { + "name": "valid_full", + "expect": "accept", + "note": "Every specified tag present, with two members owned by two different pubkeys \u2014 the cross-owner grouping that motivates the kind.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "name", + "Platform" + ], + [ + "description", + "Relay, desktop, and mobile for the platform team." + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + [ + "a", + "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz-infra" + ], + [ + "buzz-channel", + "3580ca9b-47b4-4af9-b22a-1068778f26c6" + ], + [ + "buzz-visibility", + "listed" + ] + ] + } + }, + { + "name": "valid_zero_members", + "expect": "accept", + "note": "Zero-member project. Legal at the protocol layer: it is the natural state after removing a final member and carries only bounded metadata.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "name", + "Platform" + ] + ] + } + }, + { + "name": "valid_unlisted", + "expect": "accept", + "note": "Explicitly unlisted container. Accepted at ingest; the fold excludes it from listing eligibility, so its member keeps its implicit card.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "skunkworks" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + [ + "buzz-visibility", + "unlisted" + ] + ] + } + }, + { + "name": "valid_member_cap_boundary", + "expect": "accept", + "note": "Exactly 64 distinct member `a` tags \u2014 the cap is inclusive. Paired with `invalid_member_cap_exceeded` to pin the boundary from both sides.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "wide" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-32" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-33" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-34" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-35" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-36" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-37" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-38" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-39" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-40" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-41" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-42" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-43" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-44" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-45" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-46" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-47" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-48" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-49" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-50" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-51" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-52" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-53" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-54" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-55" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-56" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-57" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-58" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-59" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-60" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-61" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-62" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-63" + ] + ] + } + }, + { + "name": "valid_same_dtag_two_owners", + "expect": "accept", + "note": "Two members share a repo `d` segment under different owners (the NIP-34 fork case). Identity is the whole coordinate, so these are not duplicates.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "forks" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + [ + "a", + "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz" + ] + ] + } + }, + { + "name": "valid_member_dtag_contains_colon", + "expect": "accept", + "note": "Repo `d` segment contains a colon. The coordinate splits into at most three parts, so the third part is the repo `d` value verbatim \u2014 `buzz:infra` here. Splitting on every colon instead would make any repo with a colon in its `d` tag unaddressable by a project.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz:infra" + ] + ] + } + }, + { + "name": "valid_uninterpreted_metadata_values", + "expect": "accept", + "note": "`buzz-channel` is not a UUID and `buzz-visibility` is an unrecognized token. Ingest bounds metadata cardinality and length but does not interpret these values \u2014 matching how kind:30617 carries the same two tags. Client-side fallbacks for both are normative in the spec's Metadata interpretation section: an unresolvable channel renders the project without one, and an unrecognized visibility is treated as `listed`.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "buzz-channel", + "not-a-uuid" + ], + [ + "buzz-visibility", + "chartreuse" + ] + ] + } + }, + { + "name": "valid_unknown_tag_ignored", + "expect": "accept", + "note": "An unrecognized tag is neither rejected nor interpreted \u2014 unknown tags are ignored, not fatal.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "future-metadata", + "preserve-me" + ] + ] + } + }, + { + "name": "valid_member_relay_hint", + "expect": "accept", + "note": "Member `a` tag carries NIP-01's optional third element, a relay hint. Ingest validates the tag's arity and the coordinate in element 1, so the hint's content is neither parsed nor a rejection cause; a client MAY use it to resolve an otherwise unavailable member.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "wss://relay.example.com" + ] + ] + } + }, + { + "name": "valid_non_empty_content", + "expect": "accept", + "note": "`content` holds a value. Writers should emit the empty string, but readers and relays must ignore whatever is there \u2014 a non-empty `content` is not a rejection cause and carries no semantics.", + "template": { + "kind": 30621, + "content": "ignored by every consumer", + "tags": [ + [ + "d", + "platform" + ] + ] + } + }, + { + "name": "invalid_d_missing", + "expect": "reject", + "note": "No `d` tag. Without one the event collapses into the `(pubkey, 30621, \"\")` slot and silently last-write-wins over an unrelated project.", + "reject_rules": [ + "d-cardinality" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "name", + "Platform" + ] + ] + } + }, + { + "name": "invalid_d_multiple", + "expect": "reject", + "note": "Two `d` tags. Which one addresses the event is reader-dependent; reject rather than pick.", + "reject_rules": [ + "d-cardinality" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "d", + "infra" + ] + ] + } + }, + { + "name": "invalid_d_empty", + "expect": "reject", + "note": "Present but empty `d`. Same slot-collapse hazard as a missing `d`.", + "reject_rules": [ + "d-empty" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "" + ] + ] + } + }, + { + "name": "invalid_member_duplicate", + "expect": "reject", + "note": "The same canonical coordinate twice. A signed event cannot be normalized in place, so the relay refuses it rather than store a head every consumer must defensively dedupe.", + "reject_rules": [ + "member-duplicate" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + ] + } + }, + { + "name": "invalid_member_duplicate_differing_relay_hints", + "expect": "reject", + "note": "The same coordinate twice under different relay hints. Duplicate detection compares the coordinate alone, so differing hints do not make these distinct members; a validator that compares whole tag arrays would wrongly accept this.", + "reject_rules": [ + "member-duplicate" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "wss://relay-one.example.com" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "wss://relay-two.example.com" + ] + ] + } + }, + { + "name": "invalid_member_cap_exceeded", + "expect": "reject", + "note": "65 distinct member `a` tags \u2014 one past the inclusive cap of 64.", + "reject_rules": [ + "member-cap" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "wide" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-32" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-33" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-34" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-35" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-36" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-37" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-38" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-39" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-40" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-41" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-42" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-43" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-44" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-45" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-46" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-47" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-48" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-49" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-50" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-51" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-52" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-53" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-54" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-55" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-56" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-57" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-58" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-59" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-60" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-61" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-62" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-63" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-64" + ] + ] + } + }, + { + "name": "invalid_member_cap_exceeded_by_duplicates", + "expect": "reject", + "note": "65 member `a` tags naming 33 distinct coordinates. The spec evaluates the cap before the duplicate set is built, so `member-cap` is the required rule even though the event also carries duplicates; a validator that reports `member-duplicate` here has built a set whose size is bounded only by the frame limit.", + "reject_rules": [ + "member-cap" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "wide" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-32" + ] + ] + } + }, + { + "name": "invalid_member_tag_arity_four_elements", + "expect": "reject", + "note": "Member `a` tag carries a fourth element past the relay hint. Its coordinate is valid, so this rejects on tag shape rather than on the coordinate: NIP-01's `a` grammar is two or three elements, and a validator that reads element 1 and ignores the rest would accept unbounded unvalidated data in a position no consumer reads.", + "reject_rules": [ + "member-tag-arity" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "wss://relay.example.com", + "unexpected" + ] + ] + } + }, + { + "name": "invalid_member_kind_prefix", + "expect": "reject", + "note": "Coordinate names kind 30618 (repo state) rather than 30617 (repo announcement). A project groups repositories, not their ref state.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30618:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + ] + } + }, + { + "name": "invalid_member_owner_not_hex", + "expect": "reject", + "note": "Owner segment is 64 characters but not hex.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz:buzz" + ] + ] + } + }, + { + "name": "invalid_member_owner_uppercase", + "expect": "reject", + "note": "Owner segment is uppercase hex. `#a` tag matching is byte-exact, so an uppercase head is invisible to the lowercase-coordinate queries every reader issues.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:buzz" + ] + ] + } + }, + { + "name": "invalid_member_owner_wrong_length", + "expect": "reject", + "note": "Owner segment is 63 hex characters.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + ] + } + }, + { + "name": "invalid_member_dtag_empty", + "expect": "reject", + "note": "Coordinate has an empty repo `d` segment \u2014 it addresses no announceable repository.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:" + ] + ] + } + }, + { + "name": "invalid_member_missing_segment", + "expect": "reject", + "note": "Coordinate has two segments instead of three.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ] + ] + } + }, + { + "name": "invalid_metadata_duplicate_name", + "expect": "reject", + "note": "Two `name` tags. Reader-dependent display name; reject rather than pick.", + "reject_rules": [ + "metadata-cardinality" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "name", + "Platform" + ], + [ + "name", + "Infra" + ] + ] + } + }, + { + "name": "invalid_metadata_duplicate_channel", + "expect": "reject", + "note": "Two `buzz-channel` tags. Which channel the project links to would be reader-dependent.", + "reject_rules": [ + "metadata-cardinality" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "buzz-channel", + "3580ca9b-47b4-4af9-b22a-1068778f26c6" + ], + [ + "buzz-channel", + "00000000-0000-0000-0000-000000000000" + ] + ] + } + }, + { + "name": "invalid_metadata_name_too_long", + "expect": "reject", + "note": "`name` value exceeds 256 bytes.", + "reject_rules": [ + "metadata-length" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "name", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + ] + } + }, + { + "name": "invalid_metadata_description_too_long", + "expect": "reject", + "note": "`description` value exceeds 2048 bytes.", + "reject_rules": [ + "metadata-length" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "description", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + ] + } + }, + { + "name": "invalid_metadata_channel_too_long", + "expect": "reject", + "note": "`buzz-channel` value exceeds 256 bytes. Paired with `invalid_metadata_visibility_too_long` so each bound is exercised by an event whose other metadata is valid \u2014 neither check can hide behind the other's rejection.", + "reject_rules": [ + "metadata-length" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "buzz-channel", + "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ] + ] + } + }, + { + "name": "invalid_metadata_visibility_too_long", + "expect": "reject", + "note": "`buzz-visibility` value exceeds 256 bytes. Ingest does not interpret the token, but it must still bound its length.", + "reject_rules": [ + "metadata-length" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "buzz-visibility", + "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv" + ] + ] + } + } + ] +} diff --git a/quartz/src/jvmTest/resources/buzz/identity-names.fixtures.json b/quartz/src/jvmTest/resources/buzz/identity-names.fixtures.json new file mode 100644 index 0000000000..7bf01af9e0 --- /dev/null +++ b/quartz/src/jvmTest/resources/buzz/identity-names.fixtures.json @@ -0,0 +1,1787 @@ +{ + "$comment": "Contextual identity names v1: semantic inputs, no signed events. Spec: README.md. Preserve identities order; compare expected as a key-indexed map. null qualifier means no key suffix. Expected outputs are literal, not generated by the policy under test.", + "version": 1, + "cases": [ + { + "name": "unique_foreign_agent", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + } + } + }, + { + "name": "human_before_mine_before_others", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Honey" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": { + "name": "Honey", + "qualifier": null + }, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey (agent)", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Wes’s Honey", + "qualifier": null + } + } + }, + { + "name": "mine_before_other_agent", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Wes’s Honey", + "qualifier": null + } + } + }, + { + "name": "viewer_changes_labels", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "viewer": "2222222222222222222222222222222222222222222222222222222222222222", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Logan’s Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey", + "qualifier": null + } + } + }, + { + "name": "my_same_owner_duplicates", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "other_same_owner_duplicates", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Wes’s Honey · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Wes’s Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "human_and_my_duplicates", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Honey" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": { + "name": "Honey", + "qualifier": null + }, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey (agent) · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey (agent) · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "viewer_wins_human_collision", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Honey" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "1111111111111111111111111111111111111111111111111111111111111111": { + "name": "Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "equal_priority_humans", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "literal_owner_prefix", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Wes’s Honey" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Wes’s Honey · 04hu", + "qualifier": "04hu" + }, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": { + "name": "Wes’s Honey", + "qualifier": null + } + } + }, + { + "name": "literal_agent_marker", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Honey" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey (agent)" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": { + "name": "Honey", + "qualifier": null + }, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey (agent) · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey (agent)", + "qualifier": null + } + } + }, + { + "name": "defer_suffix_when_another_row_qualifies", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Wes’s Honey", + "qualifier": null + } + } + }, + { + "name": "unknown_owners", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "unknown_viewer_is_not_mine", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Wes’s Honey", + "qualifier": null + } + } + }, + { + "name": "missing_owner_name", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "3333333333333333333333333333333333333333333333333333333333333333" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "3333333333333333333333333333333333333333333333333333333333333333" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "duplicate_owner_names", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "3333333333333333333333333333333333333333333333333333333333333333", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "3333333333333333333333333333333333333333333333333333333333333333" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Wes’s Honey · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Wes’s Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "extend_only_colliding_suffixes", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "3ee51d04715939ef0e492f7b87bf1dcaf2c0b4a16b753f19d7a92e96ba01b8db", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "55e2be15c0fb4ba8231701c4ba65d545715b7c79761952fd8a7cc75cf6afb602", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "3ee51d04715939ef0e492f7b87bf1dcaf2c0b4a16b753f19d7a92e96ba01b8db", + "55e2be15c0fb4ba8231701c4ba65d545715b7c79761952fd8a7cc75cf6afb602", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "expected": { + "3ee51d04715939ef0e492f7b87bf1dcaf2c0b4a16b753f19d7a92e96ba01b8db": { + "name": "Honey · suscycq", + "qualifier": "suscycq" + }, + "55e2be15c0fb4ba8231701c4ba65d545715b7c79761952fd8a7cc75cf6afb602": { + "name": "Honey · quscycq", + "qualifier": "quscycq" + }, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · rcaj", + "qualifier": "rcaj" + } + } + }, + { + "name": "literal_suffix_keeps_human_priority", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Honey · rcaj" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · mrcaj", + "qualifier": "mrcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + }, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": { + "name": "Honey · rcaj", + "qualifier": null + } + } + }, + { + "name": "trim_but_no_case_fold", + "identities": [ + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": " Honey " + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "honey" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "honey", + "qualifier": null + } + } + }, + { + "name": "trim_creates_collision", + "identities": [ + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": " Honey " + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "no_unicode_normalization", + "identities": [ + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "é" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "é" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "é", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "é", + "qualifier": null + } + } + }, + { + "name": "normalize_keys_and_deduplicate", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Viewer" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC" + }, + { + "pubkey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "viewer": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "candidates": [ + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Wes’s Honey", + "qualifier": null + } + } + }, + { + "name": "unselected_namesake_does_not_compete", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + } + } + }, + { + "name": "empty_candidate_set", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [], + "expected": {} + }, + { + "name": "absent_candidate_fact", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": {} + }, + { + "name": "omitted_candidates_selects_all", + "identities": [ + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "all_aliases_compete_last_alias_returned", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Juniper", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Juniper", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Juniper · rcaj", + "qualifier": "rcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Juniper · 04hu", + "qualifier": "04hu" + } + } + }, + { + "name": "one_key_aliases_are_not_competitors", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Juniper", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + } + } + }, + { + "name": "nonreturned_literal_alias_still_competes", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Honey · rcaj" + }, + { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "Juniper" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · mrcaj", + "qualifier": "mrcaj" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + }, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": { + "name": "Juniper", + "qualifier": null + } + } + }, + { + "name": "preferred_owner_alias_is_raw", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "James" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey", + "qualifier": null + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "James’s Honey", + "qualifier": null + } + } + }, + { + "name": "full_key_and_counters_occupied", + "identities": [ + { + "pubkey": "1111111111111111111111111111111111111111111111111111111111111111", + "name": "Logan" + }, + { + "pubkey": "2222222222222222222222222222222222222222222222222222222222222222", + "name": "Wes" + }, + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Honey", + "isAgent": true, + "ownerPubkey": "1111111111111111111111111111111111111111111111111111111111111111" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000010", + "name": "Honey · rcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000011", + "name": "Honey · mrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000012", + "name": "Honey · amrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000013", + "name": "Honey · qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000014", + "name": "Honey · 4qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000015", + "name": "Honey · 24qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000016", + "name": "Honey · 424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000017", + "name": "Honey · 2424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000018", + "name": "Honey · 42424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000019", + "name": "Honey · 242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000001a", + "name": "Honey · 4242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000001b", + "name": "Honey · 24242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000001c", + "name": "Honey · 424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000001d", + "name": "Honey · 2424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000001e", + "name": "Honey · 42424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000001f", + "name": "Honey · 242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000020", + "name": "Honey · 4242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000021", + "name": "Honey · 24242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000022", + "name": "Honey · 424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000023", + "name": "Honey · 2424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000024", + "name": "Honey · 42424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000025", + "name": "Honey · 242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000026", + "name": "Honey · 4242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000027", + "name": "Honey · 24242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000028", + "name": "Honey · 424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000029", + "name": "Honey · 2424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000002a", + "name": "Honey · 42424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000002b", + "name": "Honey · 242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000002c", + "name": "Honey · 4242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000002d", + "name": "Honey · 24242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000002e", + "name": "Honey · 424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000002f", + "name": "Honey · 2424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000030", + "name": "Honey · 42424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000031", + "name": "Honey · 242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000032", + "name": "Honey · 4242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000033", + "name": "Honey · 24242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000034", + "name": "Honey · 424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000035", + "name": "Honey · 2424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000036", + "name": "Honey · 42424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000037", + "name": "Honey · 242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000038", + "name": "Honey · 4242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000039", + "name": "Honey · 24242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000003a", + "name": "Honey · 424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000003b", + "name": "Honey · 2424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000003c", + "name": "Honey · 42424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000003d", + "name": "Honey · 242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000003e", + "name": "Honey · 4242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000003f", + "name": "Honey · 24242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000040", + "name": "Honey · 424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000041", + "name": "Honey · 2424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000042", + "name": "Honey · 42424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000043", + "name": "Honey · 242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000044", + "name": "Honey · 4242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000045", + "name": "Honey · 24242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000046", + "name": "Honey · 424242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000047", + "name": "Honey · 1424242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000048", + "name": "Honey · b1424242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "0000000000000000000000000000000000000000000000000000000000000049", + "name": "Honey · ub1424242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000004a", + "name": "Honey · pub1424242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000004b", + "name": "Honey · npub1424242424242424242424242424242424242424242424242424qamrcaj" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000004c", + "name": "Honey · npub1424242424242424242424242424242424242424242424242424qamrcaj · 1" + }, + { + "pubkey": "000000000000000000000000000000000000000000000000000000000000004d", + "name": "Honey · npub1424242424242424242424242424242424242424242424242424qamrcaj · 2" + } + ], + "viewer": "1111111111111111111111111111111111111111111111111111111111111111", + "candidates": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "0000000000000000000000000000000000000000000000000000000000000010", + "0000000000000000000000000000000000000000000000000000000000000011", + "0000000000000000000000000000000000000000000000000000000000000012", + "0000000000000000000000000000000000000000000000000000000000000013", + "0000000000000000000000000000000000000000000000000000000000000014", + "0000000000000000000000000000000000000000000000000000000000000015", + "0000000000000000000000000000000000000000000000000000000000000016", + "0000000000000000000000000000000000000000000000000000000000000017", + "0000000000000000000000000000000000000000000000000000000000000018", + "0000000000000000000000000000000000000000000000000000000000000019", + "000000000000000000000000000000000000000000000000000000000000001a", + "000000000000000000000000000000000000000000000000000000000000001b", + "000000000000000000000000000000000000000000000000000000000000001c", + "000000000000000000000000000000000000000000000000000000000000001d", + "000000000000000000000000000000000000000000000000000000000000001e", + "000000000000000000000000000000000000000000000000000000000000001f", + "0000000000000000000000000000000000000000000000000000000000000020", + "0000000000000000000000000000000000000000000000000000000000000021", + "0000000000000000000000000000000000000000000000000000000000000022", + "0000000000000000000000000000000000000000000000000000000000000023", + "0000000000000000000000000000000000000000000000000000000000000024", + "0000000000000000000000000000000000000000000000000000000000000025", + "0000000000000000000000000000000000000000000000000000000000000026", + "0000000000000000000000000000000000000000000000000000000000000027", + "0000000000000000000000000000000000000000000000000000000000000028", + "0000000000000000000000000000000000000000000000000000000000000029", + "000000000000000000000000000000000000000000000000000000000000002a", + "000000000000000000000000000000000000000000000000000000000000002b", + "000000000000000000000000000000000000000000000000000000000000002c", + "000000000000000000000000000000000000000000000000000000000000002d", + "000000000000000000000000000000000000000000000000000000000000002e", + "000000000000000000000000000000000000000000000000000000000000002f", + "0000000000000000000000000000000000000000000000000000000000000030", + "0000000000000000000000000000000000000000000000000000000000000031", + "0000000000000000000000000000000000000000000000000000000000000032", + "0000000000000000000000000000000000000000000000000000000000000033", + "0000000000000000000000000000000000000000000000000000000000000034", + "0000000000000000000000000000000000000000000000000000000000000035", + "0000000000000000000000000000000000000000000000000000000000000036", + "0000000000000000000000000000000000000000000000000000000000000037", + "0000000000000000000000000000000000000000000000000000000000000038", + "0000000000000000000000000000000000000000000000000000000000000039", + "000000000000000000000000000000000000000000000000000000000000003a", + "000000000000000000000000000000000000000000000000000000000000003b", + "000000000000000000000000000000000000000000000000000000000000003c", + "000000000000000000000000000000000000000000000000000000000000003d", + "000000000000000000000000000000000000000000000000000000000000003e", + "000000000000000000000000000000000000000000000000000000000000003f", + "0000000000000000000000000000000000000000000000000000000000000040", + "0000000000000000000000000000000000000000000000000000000000000041", + "0000000000000000000000000000000000000000000000000000000000000042", + "0000000000000000000000000000000000000000000000000000000000000043", + "0000000000000000000000000000000000000000000000000000000000000044", + "0000000000000000000000000000000000000000000000000000000000000045", + "0000000000000000000000000000000000000000000000000000000000000046", + "0000000000000000000000000000000000000000000000000000000000000047", + "0000000000000000000000000000000000000000000000000000000000000048", + "0000000000000000000000000000000000000000000000000000000000000049", + "000000000000000000000000000000000000000000000000000000000000004a", + "000000000000000000000000000000000000000000000000000000000000004b", + "000000000000000000000000000000000000000000000000000000000000004c", + "000000000000000000000000000000000000000000000000000000000000004d" + ], + "expected": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { + "name": "Honey · npub1424242424242424242424242424242424242424242424242424qamrcaj · 3", + "qualifier": "npub1424242424242424242424242424242424242424242424242424qamrcaj · 3" + }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { + "name": "Honey · 04hu", + "qualifier": "04hu" + }, + "0000000000000000000000000000000000000000000000000000000000000010": { + "name": "Honey · rcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000011": { + "name": "Honey · mrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000012": { + "name": "Honey · amrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000013": { + "name": "Honey · qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000014": { + "name": "Honey · 4qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000015": { + "name": "Honey · 24qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000016": { + "name": "Honey · 424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000017": { + "name": "Honey · 2424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000018": { + "name": "Honey · 42424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000019": { + "name": "Honey · 242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000001a": { + "name": "Honey · 4242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000001b": { + "name": "Honey · 24242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000001c": { + "name": "Honey · 424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000001d": { + "name": "Honey · 2424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000001e": { + "name": "Honey · 42424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000001f": { + "name": "Honey · 242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000020": { + "name": "Honey · 4242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000021": { + "name": "Honey · 24242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000022": { + "name": "Honey · 424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000023": { + "name": "Honey · 2424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000024": { + "name": "Honey · 42424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000025": { + "name": "Honey · 242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000026": { + "name": "Honey · 4242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000027": { + "name": "Honey · 24242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000028": { + "name": "Honey · 424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000029": { + "name": "Honey · 2424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000002a": { + "name": "Honey · 42424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000002b": { + "name": "Honey · 242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000002c": { + "name": "Honey · 4242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000002d": { + "name": "Honey · 24242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000002e": { + "name": "Honey · 424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000002f": { + "name": "Honey · 2424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000030": { + "name": "Honey · 42424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000031": { + "name": "Honey · 242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000032": { + "name": "Honey · 4242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000033": { + "name": "Honey · 24242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000034": { + "name": "Honey · 424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000035": { + "name": "Honey · 2424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000036": { + "name": "Honey · 42424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000037": { + "name": "Honey · 242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000038": { + "name": "Honey · 4242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000039": { + "name": "Honey · 24242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000003a": { + "name": "Honey · 424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000003b": { + "name": "Honey · 2424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000003c": { + "name": "Honey · 42424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000003d": { + "name": "Honey · 242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000003e": { + "name": "Honey · 4242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000003f": { + "name": "Honey · 24242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000040": { + "name": "Honey · 424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000041": { + "name": "Honey · 2424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000042": { + "name": "Honey · 42424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000043": { + "name": "Honey · 242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000044": { + "name": "Honey · 4242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000045": { + "name": "Honey · 24242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000046": { + "name": "Honey · 424242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000047": { + "name": "Honey · 1424242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000048": { + "name": "Honey · b1424242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "0000000000000000000000000000000000000000000000000000000000000049": { + "name": "Honey · ub1424242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000004a": { + "name": "Honey · pub1424242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000004b": { + "name": "Honey · npub1424242424242424242424242424242424242424242424242424qamrcaj", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000004c": { + "name": "Honey · npub1424242424242424242424242424242424242424242424242424qamrcaj · 1", + "qualifier": null + }, + "000000000000000000000000000000000000000000000000000000000000004d": { + "name": "Honey · npub1424242424242424242424242424242424242424242424242424qamrcaj · 2", + "qualifier": null + } + } + } + ] +}