diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 0e857e8564..c68b4cfb12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores import com.vitorpamplona.amethyst.commons.model.preferences.BuzzAttestationStore import com.vitorpamplona.amethyst.commons.model.preferences.BuzzChannelStarStore import com.vitorpamplona.amethyst.commons.model.preferences.BuzzWorkspaceStore +import com.vitorpamplona.amethyst.commons.model.preferences.ConcordDirectInviteDeclineStore import com.vitorpamplona.amethyst.commons.model.preferences.DrawerSectionCollapsePreferences import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore @@ -1070,6 +1071,8 @@ class AppModules( // Eager like the rest, so a held NIP-OA attestation is loaded before this account's // first Buzz-relay AUTH rather than after it. BuzzAttestationStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzAttestation) + // Concord Direct Invites the user declined (CORD-05 §6) stay declined across restarts. + ConcordDirectInviteDeclineStore(sharedSettingsStore, account.scope, account.pubKey, account.concord.directInviteInbox) }, ) 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 90a35e9cc8..2248fe8e99 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 @@ -376,6 +376,20 @@ fun ChatMessageActionSheet( if (relayGroup != null && !note.isDraft()) { RelayGroupPinTile(note, relayGroup, onDismiss, accountViewModel) } + + // Concord (CORD-04 §7): pin/unpin into the channel's Pin List. Only offered to a + // PIN_MESSAGES holder who can write the Control Plane (null otherwise). + val concordPinned = remember(note) { accountViewModel.account.concord.concordPinState(note) } + if (concordPinned != null && !note.isDraft()) { + SectionDivider() + TileRow { + val label = if (concordPinned) Res.string.relay_group_unpin_message else Res.string.relay_group_pin_message + ActionTile(MaterialSymbols.PushPin, stringRes(label)) { + accountViewModel.toggleConcordPin(note) + onDismiss() + } + } + } } } 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 70cd0a46a2..bb2cf0cb60 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 @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.DisplayZapSplits import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderBuzzSystemMessage import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderChatClip +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderConcordTimerNotice import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile import com.vitorpamplona.amethyst.commons.ui.theme.ReactionRowZapraiser import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer @@ -86,6 +87,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent @@ -174,6 +176,9 @@ fun ChatroomMessageCompose( } else if (event is ForumVoteEvent) { // Buzz kind-45002: a forum up/down vote. RenderBuzzForumVote(baseNote, accountViewModel) + } else if (event is ConcordTimerNoticeEvent) { + // Concord kind-1740: "Alice set disappearing messages to 30 days" (CORD-08 §4). + RenderConcordTimerNotice(baseNote, accountViewModel, nav) } else if (isBuzzActivityRow(event)) { // Buzz agent-job (43xxx) and huddle (48xxx) lifecycle narration. Huddles // especially must be caught here — their content is JSON, not chat text. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 9f67f7629f..5a39b183fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -87,6 +87,7 @@ import com.vitorpamplona.amethyst.commons.resources.concord_channel_no_messages import com.vitorpamplona.amethyst.commons.resources.concord_channel_rename import com.vitorpamplona.amethyst.commons.resources.concord_channel_rename_save import com.vitorpamplona.amethyst.commons.resources.concord_channels_empty +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_action import com.vitorpamplona.amethyst.commons.resources.concord_edit_title import com.vitorpamplona.amethyst.commons.resources.concord_invite_action import com.vitorpamplona.amethyst.commons.resources.concord_invite_links_action @@ -96,6 +97,8 @@ import com.vitorpamplona.amethyst.commons.resources.concord_leave_message import com.vitorpamplona.amethyst.commons.resources.concord_leave_owner_warning import com.vitorpamplona.amethyst.commons.resources.concord_leave_title import com.vitorpamplona.amethyst.commons.resources.concord_members_title +import com.vitorpamplona.amethyst.commons.resources.concord_mode_private +import com.vitorpamplona.amethyst.commons.resources.concord_mode_public import com.vitorpamplona.amethyst.commons.resources.concord_typing_many import com.vitorpamplona.amethyst.commons.resources.concord_typing_one import com.vitorpamplona.amethyst.commons.resources.concord_typing_two @@ -108,6 +111,8 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.commons.ui.note.timeAgo import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar +import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordDirectInviteDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreviewLoader import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -179,6 +184,11 @@ fun ConcordChannelListScreen( // Read once here (it is @Composable) so the post-leave navigation can use it from a callback. val canPop = nav.canPop() var showLeave by remember { mutableStateOf(false) } + var showDirectInvite by remember { mutableStateOf(false) } + + if (showDirectInvite) { + ConcordDirectInviteDialog(communityId, accountViewModel, onDismiss = { showDirectInvite = false }) + } if (showLeave) { ConcordLeaveDialog( @@ -265,7 +275,24 @@ fun ConcordChannelListScreen( Scaffold( topBar = { ShorterTopAppBar( - title = { Text(communityName, maxLines = 1) }, + title = { + Column { + Text(communityName, maxLines = 1) + // The Public/Private mode (CORD-05 §5): any live invite link in the folded + // registries makes the community Public; none makes it Private, where a ban + // rotates the keys (CORD-06 §3). Unknown until the Control Plane has folded. + state?.let { folded -> + val links = folded.liveInviteLinks.size + Text( + if (folded.isPublic) pluralStringRes(Res.plurals.concord_mode_public, links, links) else stringRes(Res.string.concord_mode_private), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, navigationIcon = { // Back arrow only when pushed from elsewhere; as a bottom-nav tab the bar takes its place. if (canPop) { @@ -327,6 +354,16 @@ fun ConcordChannelListScreen( SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(Res.string.more_options)) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + // A Direct Invite (CORD-05 §6) hands keys to one known npub. No permission gates + // it — none could, any keyholder can whisper keys — so neither does this item; + // what it carries is bounded by the recipient's roles instead. + DropdownMenuItem( + text = { Text(stringRes(Res.string.concord_direct_invite_action)) }, + onClick = { + menuOpen = false + showDirectInvite = true + }, + ) // Deliberately not gated on CREATE_INVITE, unlike minting: the links listed // there are this account's own, authored by link-signer keys only we hold. // Gating on the bit would mean a demoted admin could no longer retire the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 92a08474f5..f8e2c0031d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.runtime.LaunchedEffect 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.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -70,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.resources.back import com.vitorpamplona.amethyst.commons.resources.concord_dissolved_read_only import com.vitorpamplona.amethyst.commons.resources.concord_private_channel_no_key import com.vitorpamplona.amethyst.commons.resources.concord_send_image_title +import com.vitorpamplona.amethyst.commons.resources.concord_timer_active import com.vitorpamplona.amethyst.commons.resources.concord_typing_many import com.vitorpamplona.amethyst.commons.resources.concord_typing_one import com.vitorpamplona.amethyst.commons.resources.concord_typing_two @@ -83,7 +85,12 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.concordTimerText +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPinDuties +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPinnedButton +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPinnedMessagesSheet import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordChannelPins import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer @@ -179,9 +186,29 @@ fun ConcordChannelScreen( newMessageModel.init(accountViewModel) newMessageModel.load(communityId, channelId) + // CORD-04 §7 Pins: the header's entry point, the sheet it opens, the jump it requests, and the + // delayed duty writes (deletion omission / Edit refresh) a PIN_MESSAGES holder owes. + val pins by rememberConcordChannelPins(communityId, channelId, accountViewModel) + ConcordPinDuties(communityId, channelId, pins, accountViewModel) + var showPins by remember { mutableStateOf(false) } + val jumpToNoteId = remember { mutableStateOf(null) } + pins?.let { current -> + if (showPins) { + ConcordPinnedMessagesSheet( + communityId = communityId, + channelId = channelId, + pins = current, + accountViewModel = accountViewModel, + onJumpToMessage = { jumpToNoteId.value = it }, + onDismiss = { showPins = false }, + ) + } + } + Scaffold( topBar = { TopAppBar( + actions = { ConcordPinnedButton(pins) { showPins = true } }, title = { Column { Text(channel.toBestDisplayName(), maxLines = 1) @@ -218,6 +245,8 @@ fun ConcordChannelScreen( onWantsToReply = { newMessageModel.reply(it) }, onWantsToEditDraft = {}, onWantsToEditChatMessage = { newMessageModel.editConcordMessage(it) }, + jumpToNoteId = jumpToNoteId, + onJumpHandled = { jumpToNoteId.value = null }, // A status card at the oldest end: shows what it's reaching for while it pages and // crossfades to "All caught up" when every relay runs dry. olderBoundary = { @@ -255,6 +284,7 @@ fun ConcordChannelScreen( ConcordTypingIndicator(communityId, channelId, accountViewModel) if (channel.canPost()) { + ConcordTimerIndicator(communityId, accountViewModel) Spacer(modifier = DoubleVertSpacer) ConcordMessageComposer( newMessageModel = newMessageModel, @@ -293,6 +323,27 @@ private fun ConcordReadOnlyNotice(message: StringResource) { ) } +/** + * CORD-08: a slim "Messages disappear after 30 days" line above the composer while the community's + * timer is on, so a member knows before sending that the message will not last. + */ +@Composable +private fun ConcordTimerIndicator( + communityId: String, + accountViewModel: AccountViewModel, +) { + val session = remember(communityId) { accountViewModel.account.concordSessions.sessionFor(communityId) } ?: return + val state by session.state.collectAsStateWithLifecycle() + val secs = state?.metadata?.messageExpirationSecs() ?: return + Text( + text = stringRes(Res.string.concord_timer_active, concordTimerText(secs)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 2.dp), + ) +} + /** The number of messages a freshly-opened channel eagerly backfills to before paging goes demand-driven. */ private const val CONCORD_HISTORY_TARGET = 50 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt index ddf048bd2a..87d4f4a264 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -54,7 +54,10 @@ import com.vitorpamplona.amethyst.commons.resources.concord_create_relays import com.vitorpamplona.amethyst.commons.resources.concord_edit_relays_desc import com.vitorpamplona.amethyst.commons.resources.concord_edit_save import com.vitorpamplona.amethyst.commons.resources.concord_edit_title +import com.vitorpamplona.amethyst.commons.resources.concord_timer_desc +import com.vitorpamplona.amethyst.commons.resources.concord_timer_title import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordTimerPicker import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel @@ -93,6 +96,9 @@ fun ConcordEditScreen( val icon = remember { mutableStateOf(null) } val banner = remember { mutableStateOf(null) } val relays = remember { mutableStateListOf() } + // CORD-08 timer, seconds (0 = off): the folded value, and the one picked here. + var foldedTimer by remember { mutableStateOf(0L) } + var timer by remember { mutableStateOf(0L) } var prefilled by remember { mutableStateOf(false) } var working by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -109,6 +115,8 @@ fun ConcordEditScreen( val seededRelays = (md.relays.takeIf { it.isNotEmpty() } ?: session?.entry?.relays.orEmpty()) relays.clear() relays.addAll(seededRelays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }) + foldedTimer = md.messageExpirationSecs() ?: 0L + timer = foldedTimer prefilled = true } } @@ -161,6 +169,14 @@ fun ConcordEditScreen( nav = nav, ) + // CORD-08: this screen is only reachable with MANAGE_METADATA and the Control write key, + // the same predicate as every other field here. + ConcordSectionHeader( + title = stringRes(Res.string.concord_timer_title), + description = stringRes(Res.string.concord_timer_desc), + ) + ConcordTimerPicker(selected = timer, onSelect = { timer = it }, enabled = !working) + Button( onClick = { if (name.value.isBlank() || working) return@Button @@ -175,7 +191,10 @@ fun ConcordEditScreen( icon = icon.value, banner = banner.value, relays = relays.map { it.url }, - ) + ) && + // A timer change is its own edition, chained on the one above, and + // posts the CORD-08 §4 notice into each channel. + (timer == foldedTimer || account.concord.setConcordMessageExpiration(communityId, timer.takeIf { it > 0 })) } finally { // Always re-enable — a thrown save would otherwise strand the button. working = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 62045385f4..d7ba2251db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.timeAgo import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPendingDirectInvites import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel @@ -158,13 +159,18 @@ fun ConcordHomeScreen( }, ) { padding -> if (communities.isEmpty()) { - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { - Text( - stringRes(Res.string.concord_home_empty), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 32.dp), - ) + // Direct Invites (CORD-05 §6) are how a first community usually arrives, so they show + // above the empty state rather than being hidden by it. + Column(Modifier.fillMaxSize().padding(padding)) { + ConcordPendingDirectInvites(accountViewModel, nav) + Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { + Text( + stringRes(Res.string.concord_home_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp), + ) + } } return@Scaffold } @@ -187,6 +193,9 @@ fun ConcordHomeScreen( } LazyColumn(Modifier.fillMaxSize().padding(padding)) { + // Pending Direct Invites (CORD-05 §6), parked until the user accepts or declines. + item(key = "concord-direct-invites") { ConcordPendingDirectInvites(accountViewModel, nav) } + sorted.forEach { entry -> val state = account.concordSessions diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt index 9b03cc9e9f..76190050de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt @@ -56,7 +56,9 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.ConcordRevokeResult import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.back import com.vitorpamplona.amethyst.commons.resources.cancel @@ -67,9 +69,12 @@ import com.vitorpamplona.amethyst.commons.resources.concord_invite_links_unreada import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoke_action import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoke_confirm import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoke_explainer +import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoke_privatize_warning import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoke_title import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoked_failed import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoked_ok +import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoked_privatize_pending +import com.vitorpamplona.amethyst.commons.resources.concord_invite_revoked_privatized import com.vitorpamplona.amethyst.commons.resources.copy_to_clipboard import com.vitorpamplona.amethyst.commons.resources.more_options import com.vitorpamplona.amethyst.commons.ui.components.util.setText @@ -77,6 +82,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import java.text.DateFormat import java.util.Date @@ -125,6 +131,12 @@ fun ConcordInviteLinksScreen( var confirming by remember { mutableStateOf(null) } var revoking by remember { mutableStateOf(false) } + // The folded Control Plane, for the Public/Private mode (CORD-05 §5): revoking the community's + // last live link flips it Private, which is a Refounding, so the dialog says so before it happens. + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + val session = remember(account, communityId, revision) { account.concordSessions.sessionFor(communityId) } + val communityState by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle() + LaunchedEffect(communityId, reloads) { state = LinksState.Loading state = account.concord.listConcordInviteLinks(communityId)?.let { LinksState.Loaded(it) } ?: LinksState.Unreadable @@ -185,10 +197,22 @@ fun ConcordInviteLinksScreen( } confirming?.let { link -> + val privatizes = + remember(link, communityState) { + val signer = runCatching { link.signerPubKeyHex() }.getOrNull() + signer != null && communityState?.retiringWouldPrivatize(listOf(signer)) == true + } AlertDialog( onDismissRequest = { if (!revoking) confirming = null }, title = { Text(stringRes(Res.string.concord_invite_revoke_title)) }, - text = { Text(stringRes(Res.string.concord_invite_revoke_explainer)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringRes(Res.string.concord_invite_revoke_explainer)) + if (privatizes) { + Text(stringRes(Res.string.concord_invite_revoke_privatize_warning), color = MaterialTheme.colorScheme.error) + } + } + }, confirmButton = { TextButton( enabled = !revoking, @@ -196,10 +220,15 @@ fun ConcordInviteLinksScreen( revoking = true scope.launch { try { - val ok = account.concord.revokeConcordInvite(communityId, link.token) + val result = account.concord.revokeConcordInvite(communityId, link.token) accountViewModel.toastManager.toast( Res.string.concord_invite_links_title, - if (ok) Res.string.concord_invite_revoked_ok else Res.string.concord_invite_revoked_failed, + when (result) { + ConcordRevokeResult.FAILED -> Res.string.concord_invite_revoked_failed + ConcordRevokeResult.REVOKED -> Res.string.concord_invite_revoked_ok + ConcordRevokeResult.PRIVATIZED -> Res.string.concord_invite_revoked_privatized + ConcordRevokeResult.PRIVATIZED_REFOUND_PENDING -> Res.string.concord_invite_revoked_privatize_pending + }, ) // Re-read either way: on success the link is gone from the list, and on // failure the list is the only thing that can say whether it changed. diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 8343082ebf..6726725410 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -226,7 +226,7 @@ पठनीय विषय आवृत करें पुनःखोलें - ब्लोस्सम॰ सेवासंगणक + ब्लोस्सम सेवासंगणक सेवासंगणक जितना चाहें जोडें। किस संगणक का उपयोग करना है उसका चयन कर सकते हैं चित्र का आरोहण करते समय अवरोहण @@ -276,7 +276,7 @@ अमेथिस्ट सूचनाएँ सक्रिय निरस्त प्रगति दर्शाता है आपके अभिलेखों की अनुकृति करते समय आपके ब्लोस्सम सेवासंगणकों में। - ब्लोस्सम॰ समचरणीकरण + ब्लोस्सम समचरणीकरण आह्वान चालू आह्वान सूचना %1$s के साथ आह्वान diff --git a/cli/README.md b/cli/README.md index fb4c9af8d5..6c6453ae35 100644 --- a/cli/README.md +++ b/cli/README.md @@ -679,17 +679,24 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `amy concord channels COMMUNITY` | List a community's channels; `readable` is false for a private channel whose key this account does not hold (CORD-03 §1). | | `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). A private channel posts on its own key's plane; without a held key it fails with `no_channel_key` instead of falling back to the community-wide plane. | | `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane (public channels; a private channel reads its held key's plane). Banned members' messages are left out and counted in `hidden_banned`. | -| `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link (at most 3 bootstrap relays ride in the fragment, CORD-05 §3; the bundle names this account as creator). | -| `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. | +| `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link (at most 3 bootstrap relays ride in the fragment, CORD-05 §3; the bundle names this account as creator), then publish this account's Invite Registry (`vsk 8`, CORD-05 §5) listing its live link signers — expired links pruned. Output adds `registry_published`, `public` and `live_invite_links`. | +| `amy concord invite COMMUNITY --to USER [--expires-in SECS]` | Send a Direct Invite (CORD-05 §6): the bundle giftwrapped as standard NIP-59 (kind-3313 rumor, `k=3313` wrap tag, NIP-40 expiration when `--expires-in` is set) to USER (npub, hex, nprofile or NIP-05) on their kind-10050 relays, else NIP-65 read relays, else the stock set. Carries only the private-channel keys USER's roles grant; refused for a banned recipient. No registry entry, never flips the community Public, cannot be revoked. | +| `amy concord invites` | List Direct Invites waiting for this account (sender, community name/icon, expired, catch-up). Read-only: nothing joins or contacts the community's relays. Communities you already hold are hidden unless the invite carries new channel keys on the same base (a catch-up). | +| `amy concord accept WRAP-ID` | Accept a Direct Invite (full wrap id or a unique prefix): the same join path as a link (ban-gated, Guestbook Join attributed to the seal-verified sender); refused past `expires_at`. For a community you hold, only adopts newly granted private-channel keys on the same root/epoch/control_pk, never moving the base. | +| `amy concord decline WRAP-ID` | Discard a Direct Invite; its wrap id is remembered in `concord-invites.json` so it never resurfaces. | +| `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, records it in your Invite List, then republishes your Invite Registry without it. When it was the community's last live link the output carries `privatized: true` / `refound_required: true`: the community is Private now, and `concord refound COMMUNITY --privatize` rotates its keys (CORD-05 §2). | | `amy concord join URL` | Redeem an invite link, save the community, and publish a Guestbook Join echoing the link's attribution (CORD-05 §1/§6). | | `amy concord rekey [COMMUNITY]` | Follow a Refounding we were re-keyed for. Honors only a BAN-holding rotator whose `vac` cites a Grant our fold has synced (the owner cites none); racing rotations converge on the lowest root (CORD-06 §3). | | `amy concord recover [COMMUNITY] [--rejoin]` | Report whether a Refounding left us behind (our joined-through link resolves to a higher epoch). A bundle never moves the base on its own (CORD-06 §2); `--rejoin` explicitly re-accepts the link. Ban-gated, fails closed. | -| `amy concord refound COMMUNITY --remove U[,U…]` | CORD-06 Refounding. Aborts unless the whole Control Plane folds; publishes the rekey chunks first (each confirmed), the compacted plane after; reserves its keys so a re-run resumes with the same root; refused for a dissolved community. | -| `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04). | +| `amy concord refound COMMUNITY --remove U[,U…]` / `--privatize` | CORD-06 Refounding. Aborts unless the whole Control Plane folds; publishes the rekey chunks first (each confirmed), the compacted plane after; reserves its keys so a re-run resumes with the same root; refused for a dissolved community. `--privatize` removes nobody: it converts a Public community to Private (owed once its last live invite link is revoked). | +| `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04), plus the community's mode from the folded Invite Registries (CORD-05 §5): `public` (true while any live invite link exists), `live_invite_links`, and `invite_registries` (links per creator). | | `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`; also `MANAGE_ROLES`, `MANAGE_CHANNELS`, `MANAGE_METADATA`, `MANAGE_MESSAGES`, `CREATE_INVITE`, `VIEW_AUDIT_LOG`, `MENTION_EVERYONE`, `PIN_MESSAGES`). | | `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. | -| `amy concord ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. | +| `amy concord ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. A ban reports `public` and `refound_required`: a Public ban is the Banlist alone, while a ban from a Private community owes a Refounding (`concord refound COMMUNITY --remove USER`, CORD-06 §3). | +| `amy concord pins COMMUNITY CHANNEL` | The channel's Pin List (CORD-04 §7), every entry verified from its proof bundle; entries the author deleted are listed under `deleted`, `edited`/`stale_edit` flag revisions, and `sealed_unavailable` means the list is sealed under a key this account never held (unreadable, not empty). | +| `amy concord pin COMMUNITY CHANNEL RUMOR_ID` / `unpin COMMUNITY CHANNEL RUMOR_ID` | Pin / unpin a message (PIN_MESSAGES or owner, plus the control write key). Pinning reopens the message's wrap to prove it with its original seal; a private channel's list is sealed under its current key. Refused (`list_unavailable`, `too_many_pins`, `too_large`, …) rather than published when the list is unreadable or a cap would break. | | `amy concord dissolve COMMUNITY --yes` | Owner only, irreversible: publish the `eid`-bound dissolution tombstone that seals the community read-only (CORD-02 §9). | +| `amy concord timer COMMUNITY [off\|SECONDS\|1d\|1w\|30d\|90d\|1y]` | CORD-08 disappearing messages. No value: print the folded timer (`0` = off). With one: publish the metadata edition (MANAGE_METADATA) and a kind-1740 notice into every channel whose key we hold. While a timer is set, `send` signs a NIP-40 `expiration` into the rumor and repeats it on the wrap; `read` drops expired messages. | ### cordn (MLS over an MCP coordinator) @@ -987,6 +994,7 @@ matches that: │ ├── aliases.json # local name → npub map │ ├── cashu.json # NIP-60 NUT-13 counters │ ├── concord.json # Concord community secrets +│ ├── concord-invites.json # declined Concord Direct Invite wrap ids │ └── marmot/ # MLS state per group └── bob/ └── … diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index efc4c20f40..95dd68e291 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -224,6 +224,7 @@ class DataDir( val aliasesFile = File(root, "aliases.json") val cashuFile = File(root, "cashu.json") val concordFile = File(root, "concord.json") + val concordInvitesFile = File(root, "concord-invites.json") val marmotDir = File(root, "marmot") val groupsDir = File(marmotDir, "groups") val keyPackageBundleFile = File(marmotDir, "keypackages.bundle") 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 89f9929c45..87caabad5f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -884,6 +884,9 @@ private fun printUsage() { | concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id) | concord read COMMUNITY CHANNEL [--limit N] read a channel's messages | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link + | concord invite COMMUNITY --to USER send a Direct Invite (giftwrapped bundle) + | concord invites list Direct Invites waiting for you + | concord accept|decline WRAP-ID join from / discard a Direct Invite | concord revoke COMMUNITY TOKEN|URL retire a link you minted (vsk=9 tombstone) | concord join URL redeem an invite link and save the community | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index 073a326bb4..864509647d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -97,7 +97,8 @@ object ConcordChannelCommands { ConcordActions.currentChannelPlane(ConcordCommands.entryFor(sc), state, channelId) ?: return Output.error("no_channel_key", "channel '$channelRef' is not folded, or is private and this account holds no key for it (CORD-03 §1)") val channel = plane.key - val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, plane.epoch, text, TimeUtils.now()) + // CORD-08 §2: the folded timer rides inside the signed rumor, and on the wrap for relays. + val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, plane.epoch, text, TimeUtils.now(), timerSecs = state.metadata?.messageExpirationSecs()) val relays = ConcordCommands.relaysFor(ctx, sc) // A relay that gates writes behind NIP-42 wants the wrap's author (the stream key) authenticated. ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey)) @@ -176,7 +177,7 @@ object ConcordChannelCommands { } /** Drain the control plane and fold it into the current community state. */ - private suspend fun foldState( + suspend fun foldState( ctx: Context, sc: StoredCommunity, ): ConcordCommunityState { @@ -202,7 +203,7 @@ object ConcordChannelCommands { } /** Resolve a channel handle: the `general` shortcut, a full hex id, or a folded name/id-prefix match. */ - private suspend fun resolve( + internal suspend fun resolve( ctx: Context, sc: StoredCommunity, ref: String, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index dd7a23c1f4..8d7dcf5b34 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -24,12 +24,18 @@ 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.amethyst.cli.stores.ConcordInviteInboxStore import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot import com.vitorpamplona.amethyst.cli.stores.StoredPrivateChannel import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordReceive +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteSendResult +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteView +import com.vitorpamplona.amethyst.commons.model.concord.DirectInviteAcceptPlan import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent @@ -41,6 +47,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry @@ -49,6 +56,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -75,6 +83,14 @@ object ConcordCommands { | concord read COMMUNITY CHANNEL [--limit N] read a channel's messages (default 50); | [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link + | concord invite COMMUNITY --to USER send a Direct Invite (CORD-05 §6): the bundle + | [--expires-in SECS] giftwrapped to USER (npub|hex|nprofile|nip05), + | to their 10050 / NIP-65 read / stock relays, + | with only the private channels their roles grant + | concord invites list Direct Invites waiting for you (never joins) + | concord accept WRAP-ID accept a Direct Invite: join (or, for a community + | you hold, adopt newly granted channel keys) + | concord decline WRAP-ID discard a Direct Invite; it never resurfaces | concord revoke COMMUNITY TOKEN|URL retire a link you minted: publishes a vsk=9 | tombstone at its coordinate, then tombstones | it in your invite list so it stays retired @@ -86,16 +102,28 @@ object ConcordCommands { | --rejoin re-accepts that link (a bundle never | moves the base on its own, CORD-06 §2); | refuses if that epoch banned us - | concord roles COMMUNITY list live roles + current banlist (CORD-04) + | concord roles COMMUNITY list live roles + current banlist (CORD-04), + | and public: true/false + live invite links + | from the folded registries (CORD-05 §5) | concord role COMMUNITY NAME POSITION PERM… define a role (perms by name, e.g. BAN KICK) | concord grant COMMUNITY USER ROLE-ID grant a role to a member | concord ban COMMUNITY USER ban a member + | concord pins COMMUNITY CHANNEL the channel's verified Pin List (CORD-04 §7) + | concord pin COMMUNITY CHANNEL RUMOR_ID pin a message (PIN_MESSAGES); proves it with + | its original seal, capped at 25 / 32 KiB + | concord unpin COMMUNITY CHANNEL RUMOR_ID unpin a message (the next edition without it) | concord unban COMMUNITY USER unban a member | concord refound COMMUNITY --remove U[,U] CORD-06 Refounding: rotate the root (and the | control_root) so removed members lose every | key — the hard removal a ban cannot give + | concord refound COMMUNITY --privatize a Refounding that removes nobody: converts a + | Public community to Private (owed after the + | last live invite link is revoked, CORD-05 §2) | concord dissolve COMMUNITY --yes CORD-02 §9: owner-only, IRREVERSIBLE tombstone | that seals the community read-only for everyone + | concord timer COMMUNITY [off|SECONDS|1d|1w|30d|90d|1y] + | CORD-08 disappearing messages: print the timer, + | or set it (MANAGE_METADATA) + post channel notices """.trimMargin() suspend fun dispatch( @@ -105,7 +133,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -116,6 +144,9 @@ object ConcordCommands { "send" to { rest -> ConcordChannelCommands.send(dataDir, rest) }, "read" to { rest -> ConcordChannelCommands.read(dataDir, rest) }, "invite" to { rest -> invite(dataDir, rest) }, + "invites" to { rest -> invites(dataDir, rest) }, + "accept" to { rest -> accept(dataDir, rest) }, + "decline" to { rest -> decline(dataDir, rest) }, "revoke" to { rest -> revoke(dataDir, rest) }, "join" to { rest -> join(dataDir, rest) }, "recover" to { rest -> recover(dataDir, rest) }, @@ -125,8 +156,12 @@ object ConcordCommands { "grant" to { rest -> ConcordModCommands.grant(dataDir, rest) }, "ban" to { rest -> ConcordModCommands.ban(dataDir, rest) }, "unban" to { rest -> ConcordModCommands.unban(dataDir, rest) }, + "pins" to { rest -> ConcordPinCommands.pins(dataDir, rest) }, + "pin" to { rest -> ConcordPinCommands.pin(dataDir, rest) }, + "unpin" to { rest -> ConcordPinCommands.unpin(dataDir, rest) }, "refound" to { rest -> ConcordModCommands.refound(dataDir, rest) }, "dissolve" to { rest -> ConcordModCommands.dissolve(dataDir, rest) }, + "timer" to { rest -> ConcordModCommands.timer(dataDir, rest) }, ), ) @@ -282,9 +317,13 @@ object ConcordCommands { val args = Args(rest) val handle = args.positional(0, "community") val base = args.flag("base", "https://vector.chat")!! + val to = args.flag("to") + val expiresInSecs = args.flag("expires-in")?.let { it.toLongOrNull()?.takeIf { secs -> secs > 0 } ?: throw IllegalArgumentException("--expires-in expects a positive number of seconds, got '$it'") } args.rejectUnknown() val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return notFound(handle) + if (to != null) return directInvite(dataDir, sc, to, expiresInSecs) + if (expiresInSecs != null) return Output.error("bad_args", "--expires-in applies to a Direct Invite (--to)").let { 2 } Context.open(dataDir).use { ctx -> ctx.prepare() // The joiner cannot derive the Control Plane address, so the invite carries it @@ -313,7 +352,7 @@ object ConcordCommands { ), ), ) - if (!recorded) { + if (recorded == null) { return Output.error( "invite_unrecordable", "could not record the link signer in your invite list (kind 13303), so this link could never be refreshed after a Refounding — not minting it", @@ -323,12 +362,15 @@ object ConcordCommands { val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it } + // "A Registry edit accompanies every mint" (CORD-05 §5): the link now makes the community Public. + val registry = ConcordModCommands.publishInviteRegistry(ctx, sc, dataDir, recorded, minted = listOf(minted.linkSignerPubKey)) + Output.emit( mapOf( "url" to minted.url, "bundle_event_id" to minted.bundleEvent.id, "link_signer" to minted.linkSignerPubKey, - ) + RawEventSupport.ackFields(ack), + ) + registry + RawEventSupport.ackFields(ack), ) return 0 } @@ -397,21 +439,29 @@ object ConcordCommands { ctx, ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = sc.communityId))), ) - if (!recorded) { + if (recorded == null) { System.err.println( "[concord] the link is revoked on the wire but the tombstone could not be recorded in your invite list (kind 13303); re-run this command once your outbox relays are reachable", ) } + // "...and every retire" (CORD-05 §5). Retiring the last live link flips the community + // Private, which is a Refounding (CORD-05 §2): reported, and run with `refound --privatize`. + val signer = entry.signerPubKeyHex().lowercase() + val registry = ConcordModCommands.publishInviteRegistry(ctx, sc, dataDir, recorded ?: list, retired = listOf(signer)) + if (registry["privatized"] == true) { + System.err.println("[concord] that was the community's last live invite link, so it is Private now: run `amy concord refound ${sc.communityId} --privatize` to rotate its keys (CORD-06 §3)") + } + Output.emit( mapOf( "revoked" to true, "token" to token, "community_id" to sc.communityId, - "link_signer" to entry.signerPubKeyHex(), + "link_signer" to signer, "tombstone_event_id" to tombstone.id, - "tombstoned_in_list" to recorded, - ) + RawEventSupport.ackFields(ack), + "tombstoned_in_list" to (recorded != null), + ) + registry + RawEventSupport.ackFields(ack), ) return 0 } @@ -447,62 +497,264 @@ object ConcordCommands { InviteBundleStatus.Absent -> return Output.error("not_found", "no bundle for this link on any of its relays") } - // Refuse a link that readmits us after we were removed. A Refounding re-mints every - // outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its - // unlock token forever — so without this check the rotation that was supposed to expel - // them hands them the new keys instead. `recover` has always been ban-gated; `join` is - // the other door into the same room. - // - // Fails CLOSED on an unreadable plane: no verdict, no join. The banlist is only knowable - // after the bundle yields the root, which is why the check lives here rather than before. - val joinKeys = - ConcordActions.controlPlaneKeys( - communityRoot = bundle.communityRoot.hexToByteArray(), - communityId = bundle.communityId.hexToByteArray(), - rootEpoch = bundle.rootEpoch, - controlPk = bundle.controlPk, - ) - val joinRelays = normalize(bundle.relays).ifEmpty { relays } - val joinEditions = - ConcordActions.controlEditions( - ctx.drain(joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, pendingOnAuthRequired = true).map { it.second }, - joinKeys, - ) - if (joinEditions.isEmpty()) { - return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to join") - } - if (AuthorityResolver.resolve(joinEditions, bundle.communityId.hexToByteArray(), bundle.owner).isBanned(ctx.signer.pubKey)) { - return Output.error("banned", "this community has banned this account; the link works but the roster does not admit you (CORD-04)") - } + return joinBundle( + ctx = ctx, + dataDir = dataDir, + bundle = bundle, + fallbackRelays = relays, + // The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving + // this link is the only way back (CORD-05/06). Stored bare, domain-agnostic. + inviteRef = ConcordActions.bareInviteRef(url) ?: "", + inviteCreator = bundle.creatorNpub, + inviteLabel = bundle.label, + ) + } + } - val stored = - StoredCommunity( - name = bundle.name, - communityId = bundle.communityId, - owner = bundle.owner, - ownerSalt = bundle.ownerSalt, - root = bundle.communityRoot, - rootEpoch = bundle.rootEpoch, - // Read access to the Control Plane, never write (CORD-05 §1). Absent = the - // community is still pre-split and folds at the legacy address. - controlPk = bundle.controlPk ?: "", - relays = bundle.relays, - // The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving - // this link is the only way back (CORD-05/06). Stored bare, domain-agnostic. - inviteRef = ConcordActions.bareInviteRef(url) ?: "", - privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) }, - ) - ConcordStore(dataDir.concordFile).upsert(stored) + /** + * The join half shared by `join` (a link) and `accept` (a Direct Invite): [bundle] is already + * opened, bounded, owner-proof validated and not expired. Ban-gates against the community's own + * Control Plane (read over the bundle's relays, else [fallbackRelays]), stores the membership and + * announces the Guestbook Join with [inviteCreator]/[inviteLabel] attribution. + */ + private suspend fun joinBundle( + ctx: Context, + dataDir: DataDir, + bundle: CommunityInvite, + fallbackRelays: Set, + inviteRef: String, + inviteCreator: String?, + inviteLabel: String?, + ): Int { + // Refuse a link that readmits us after we were removed. A Refounding re-mints every + // outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its + // unlock token forever — so without this check the rotation that was supposed to expel + // them hands them the new keys instead. `recover` has always been ban-gated; `join` is + // the other door into the same room. + // + // Fails CLOSED on an unreadable plane: no verdict, no join. The banlist is only knowable + // after the bundle yields the root, which is why the check lives here rather than before. + val joinKeys = + ConcordActions.controlPlaneKeys( + communityRoot = bundle.communityRoot.hexToByteArray(), + communityId = bundle.communityId.hexToByteArray(), + rootEpoch = bundle.rootEpoch, + controlPk = bundle.controlPk, + ) + val joinRelays = normalize(bundle.relays).ifEmpty { fallbackRelays } + val joinEditions = + ConcordActions.controlEditions( + ctx.drain(joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) }, pendingOnAuthRequired = true).map { it.second }, + joinKeys, + ) + if (joinEditions.isEmpty()) { + return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to join") + } + if (AuthorityResolver.resolve(joinEditions, bundle.communityId.hexToByteArray(), bundle.owner).isBanned(ctx.signer.pubKey)) { + return Output.error("banned", "this community has banned this account; the invite opens but the roster does not admit you (CORD-04)") + } - // Announce the membership (CORD-05 §6 / CORD-02 §5): a Guestbook Join is how a later - // Refounding finds this member to re-key, and it echoes the link's attribution so link - // holders can count per-link joins. Best-effort, like every Guestbook motion. - val announced = announceGuestbookJoin(ctx, stored, bundle.creatorNpub, bundle.label) - Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays, "guestbook_join" to announced)) + val stored = + StoredCommunity( + name = bundle.name, + communityId = bundle.communityId, + owner = bundle.owner, + ownerSalt = bundle.ownerSalt, + root = bundle.communityRoot, + rootEpoch = bundle.rootEpoch, + // Read access to the Control Plane, never write (CORD-05 §1). Absent = the + // community is still pre-split and folds at the legacy address. + controlPk = bundle.controlPk ?: "", + relays = bundle.relays, + // The stranded-recovery anchor; blank for a Direct Invite, which has no link. + inviteRef = inviteRef, + privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) }, + ) + ConcordStore(dataDir.concordFile).upsert(stored) + + // Announce the membership (CORD-05 §6 / CORD-02 §5): a Guestbook Join is how a later + // Refounding finds this member to re-key, and it echoes the link's attribution so link + // holders can count per-link joins. Best-effort, like every Guestbook motion. + val announced = announceGuestbookJoin(ctx, stored, inviteCreator, inviteLabel) + Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays, "guestbook_join" to announced)) + return 0 + } + + // ---- Direct Invites (CORD-05 §6) ------------------------------------------- + + /** + * `concord invite COMMUNITY --to USER` — hands the community's keys straight to USER as a + * Direct Invite: the §1 bundle giftwrapped (standard NIP-59, `k=3313`) to their inbox relays. + * Which Private Channel keys ride along, and who is refused, is [ConcordActions.draftDirectInvite]. + */ + private suspend fun directInvite( + dataDir: DataDir, + sc: StoredCommunity, + to: String, + expiresInSecs: Long?, + ): Int { + Context.open(dataDir).use { ctx -> + ctx.prepare() + val recipient = ctx.requireUserHex(to) + // The fold decides which Private Channel keys the recipient's Roles entitle them to and + // whether either side is banned; no fold, no verdict, no send. + val state = ConcordChannelCommands.foldState(ctx, sc) + if (state.metadata == null) { + return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so which keys the recipient may receive is unknown — not sending") + } + val expiresAtMs = expiresInSecs?.let { TimeUtils.nowMillis() + it * 1000 } + val invite = + when (val draft = ConcordActions.draftDirectInvite(entryFor(sc), state, ctx.signer.pubKey, recipient, expiresAtMs)) { + is ConcordDirectInviteDraft.Ready -> draft.invite + is ConcordDirectInviteDraft.Refused -> + return when (draft.reason) { + ConcordDirectInviteSendResult.RECIPIENT_BANNED -> Output.error("recipient_banned", "this community has banned $recipient; their join would be refused") + ConcordDirectInviteSendResult.INVALID_RECIPIENT -> Output.error("bad_args", "'$to' is not a 32-byte pubkey").let { 2 } + else -> Output.error("not_member", "this account is banned from, or no longer holds, this community") + } + } + val wrap = ConcordActions.buildDirectInvite(ctx.signer, recipient, invite) + // Their kind-10050 DM relays, else NIP-65 read relays, else the stock set (CORD-05 §6). + val lists = ctx.cachedRelayListsOf(recipient) ?: RecipientRelayFetcher.fetchRelayLists(ctx.client, recipient, ctx.bootstrapRelays()) + val relays = ConcordActions.directInviteDeliveryRelays(lists) + val ack = ctx.publish(wrap, relays) + RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } + Output.emit( + mapOf( + "sent" to true, + "wrap_id" to wrap.id, + "recipient" to recipient, + "community_id" to sc.communityId, + "channels" to invite.channels.map { mapOf("id" to it.id, "name" to it.name, "epoch" to it.epoch) }, + "expires_at" to invite.expiresAt, + ) + RawEventSupport.ackFields(ack), + ) return 0 } } + /** + * Collects this account's Direct Invite wraps (`{"kinds":[1059],"#p":[me],"#k":["3313"]}`) from + * where senders deliver them — our 10050 / NIP-65 read / stock relays, plus the DM inbox — into + * the shared headless inbox, with the declines this account already made restored. + */ + private suspend fun sweepDirectInvites( + ctx: Context, + dataDir: DataDir, + ): ConcordDirectInviteInbox { + val inbox = ConcordDirectInviteInbox(ctx.signer) + inbox.restoreDeclined(ConcordInviteInboxStore(dataDir.concordInvitesFile).declined()) + val me = ctx.signer.pubKey + val relays = ConcordActions.directInviteDeliveryRelays(ctx.cachedRelayListsOf(me)) + ctx.inboxRelays() + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.directInvitesFilter(me)) }).map { it.second } + wraps.distinctBy { it.id }.forEach { inbox.offer(it) } + return inbox + } + + private fun directInviteJson(view: ConcordDirectInviteView): Map = + mapOf( + "wrap_id" to view.wrapId, + "sender" to view.sender, + "community_id" to view.communityId, + "name" to view.name, + "icon" to view.icon?.url, + "relays" to view.invite.relays, + "channels" to + view.invite.channels + .filter { it.key.isNotBlank() } + .map { mapOf("id" to it.id, "name" to it.name, "epoch" to it.epoch) }, + "sent_at" to view.opened.sentAt, + "expires_at" to view.invite.expiresAt, + "expired" to view.expired, + "catch_up" to view.catchUp, + ) + + /** `concord invites` — the Direct Invites waiting for this account. Read-only: nothing joins. */ + private suspend fun invites( + dataDir: DataDir, + rest: Array, + ): Int { + Args(rest).rejectUnknown() + Context.open(dataDir).use { ctx -> + ctx.prepare() + val inbox = sweepDirectInvites(ctx, dataDir) + val joined = ConcordStore(dataDir.concordFile).load().map { entryFor(it) } + val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, joined) + Output.emit(mapOf("invites" to views.map { directInviteJson(it) })) { + if (views.isEmpty()) { + "no pending direct invites" + } else { + views.joinToString(System.lineSeparator()) { v -> + val flags = listOfNotNull("expired".takeIf { v.expired }, "catch-up".takeIf { v.catchUp }).joinToString(" ") { "[$it]" } + "${v.wrapId} ${v.name.ifBlank { v.communityId.take(12) }} from ${v.sender}" + if (flags.isNotEmpty()) " $flags" else "" + } + } + } + return 0 + } + } + + /** + * `concord accept WRAP-ID` — accepts a Direct Invite through the same join path as a link: + * refused past `expires_at` or when the roster bans us; for a community already held, only a + * catch-up adopting newly granted Private Channel keys on the same base (never a base move). + */ + private suspend fun accept( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val ref = args.positional(0, "wrap-id").lowercase() + args.rejectUnknown() + Context.open(dataDir).use { ctx -> + ctx.prepare() + val pending = sweepDirectInvites(ctx, dataDir).pending.value.values + val opened = + pending.firstOrNull { it.wrapId == ref } + ?: pending.singleOrNull { it.wrapId.startsWith(ref) } + ?: return Output.error("not_found", "no pending direct invite with wrap id '$ref' (see `amy concord invites`)") + + val store = ConcordStore(dataDir.concordFile) + val heldSc = store.load().firstOrNull { it.communityId.equals(opened.invite.communityId, ignoreCase = true) } + // An unreadable held plane is no verdict (metadata is written at genesis), so it waits. + val heldState = heldSc?.let { ConcordChannelCommands.foldState(ctx, it) }?.takeIf { it.metadata != null } + + fun done(extra: Map) = mapOf("wrap_id" to opened.wrapId, "community_id" to opened.invite.communityId, "name" to opened.invite.name) + extra + return when (val plan = ConcordDirectInviteInbox.acceptPlan(opened, heldSc?.let { entryFor(it) }, heldState, ctx.signer.pubKey)) { + DirectInviteAcceptPlan.Expired -> Output.error("expired", "this direct invite has expired and can no longer be joined") + DirectInviteAcceptPlan.Banned -> Output.error("banned", "this community has banned this account (CORD-04)") + DirectInviteAcceptPlan.RosterNotLoaded -> Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to adopt") + DirectInviteAcceptPlan.NothingNew -> { + Output.emit(done(mapOf("joined" to true, "already_member" to true, "catch_up" to false))) + 0 + } + is DirectInviteAcceptPlan.CatchUp -> { + val held = heldSc!! + store.upsert(storedFrom(held, plan.entry)) + val added = plan.entry.privateChannels.filter { pc -> held.privateChannels.none { it.channelId.equals(pc.channelId, ignoreCase = true) && it.epoch == pc.epoch } } + Output.emit(done(mapOf("joined" to true, "catch_up" to true, "channels" to added.map { mapOf("id" to it.channelId, "name" to it.name, "epoch" to it.epoch) }))) + 0 + } + // The Join is attributed to the seal-verified sender, never the bundle's claim. + DirectInviteAcceptPlan.Join -> joinBundle(ctx, dataDir, opened.invite, emptySet(), inviteRef = "", inviteCreator = opened.sender, inviteLabel = opened.invite.label) + } + } + } + + /** `concord decline WRAP-ID` — discards a Direct Invite locally; it is never listed again. */ + private fun decline( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val wrapId = args.positional(0, "wrap-id").lowercase() + args.rejectUnknown() + if (!HEX64.matches(wrapId)) return Output.error("bad_args", "expected the invite's full 64-hex wrap id, got '$wrapId'").let { 2 } + ConcordInviteInboxStore(dataDir.concordInvitesFile).decline(wrapId) + Output.emit(mapOf("declined" to wrapId)) + return 0 + } + // ---- shared helpers (used by ConcordChannelCommands too) ------------------ private val HEX64 = Regex("^[0-9a-f]{64}$") @@ -826,7 +1078,8 @@ object ConcordCommands { } /** - * Merges [patch] into the published list and republishes it, returning whether it landed. + * Merges [patch] into the published list and republishes it, returning the merged document when + * it landed and null when it did not. * * Read-merge-write, and **aborts rather than overwriting** when the read fails: kind 13303 is * replaceable, so writing a patch-only document over a list we could not read deletes every @@ -839,12 +1092,13 @@ object ConcordCommands { suspend fun publishInviteList( ctx: Context, patch: ConcordInviteListDocument, - ): Boolean { + ): ConcordInviteListDocument? { val relays = ctx.outboxRelays() - if (relays.isEmpty()) return false - val base = readInviteList(ctx) ?: return false - val event = ConcordInviteListEvent.create(ctx.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()) - return ctx.publish(event, relays).values.any { it.accepted } + if (relays.isEmpty()) return null + val base = readInviteList(ctx) ?: return null + val merged = ConcordInviteList.merge(base, patch) + val event = ConcordInviteListEvent.create(ctx.signer, merged, TimeUtils.now()) + return if (ctx.publish(event, relays).values.any { it.accepted }) merged else null } fun notFound(handle: String): Int { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 8e5102440c..c8d9f65c3b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -32,10 +32,14 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordModeration import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException @@ -80,12 +84,57 @@ object ConcordModCommands { ) }, "banned" to ConcordModeration.currentBanned(editions, sc.communityId.hexToByteArray(), sc.owner).toList(), + // CORD-05 §5: the folded Invite Registries are the Public/Private source of truth. + "public" to state.isPublic, + "live_invite_links" to state.liveInviteLinks.size, + "invite_registries" to state.inviteRegistries.mapValues { it.value.size }, ), ) return 0 } } + /** + * Publishes this account's Invite Registry (CORD-05 §5, `vsk 8`) after a mint or a retire of + * [sc]'s links, and reports the Public/Private mode around it. Best-effort, like Amethyst's: a + * link works without its registry, so a missing permission or `control_root` only skips the edit. + * + * [list] is the Invite List as just written (null when unreadable); the next registry is + * [ConcordInviteRegistry.nextLinks] over this account's honored head, so expired and tombstoned + * links drop out and links minted before any registry existed are re-listed. + */ + internal suspend fun publishInviteRegistry( + ctx: Context, + sc: StoredCommunity, + dataDir: DataDir, + list: ConcordInviteListDocument?, + minted: List = emptyList(), + retired: List = emptyList(), + ): Map { + val (cp, editions) = load(ctx, sc, dataDir) + val cid = sc.communityId.hexToByteArray() + val before = ConcordCommunityState.fold(editions, cid, sc.owner) + val me = ctx.signer.pubKey + val privatizes = before.retiringWouldPrivatize(retired) + val authorized = before.authority.isOwner(me) || before.authority.hasPermission(me, ConcordPermissions.CREATE_INVITE) + val next = ConcordInviteRegistry.nextLinks(before.registryOf(me), list, sc.communityId, TimeUtils.now(), minted, retired) + val wrap = + if (authorized && cp.canWrite) { + ConcordModeration.setInviteRegistry(ctx.signer, cp, cid, next, editions, TimeUtils.now(), owner = sc.owner) + } else { + System.err.println("[concord] invite registry not published: this account ${if (!authorized) "does not hold CREATE_INVITE" else "holds no control_root"} (CORD-05 §5)") + null + } + val published = wrap != null && ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)).values.any { it.accepted } + // The mode as it reads once the edition lands: the same fold, with it. + val after = if (published && wrap != null) ConcordCommunityState.fold(editions + ConcordActions.controlEditions(listOf(wrap), cp), cid, sc.owner) else before + return mapOf( + "registry_published" to published, + "public" to after.isPublic, + "live_invite_links" to after.liveInviteLinks.size, + ) + (if (privatizes) mapOf("privatized" to true, "refound_required" to true) else emptyMap()) + } + /** Defines a new role: `role PERM...` (perms by name, e.g. BAN KICK). */ suspend fun defineRole( dataDir: DataDir, @@ -198,6 +247,77 @@ object ConcordModCommands { } } + /** + * `timer COMMUNITY [off|SECONDS|1d|1w|30d|90d|1y]` — CORD-08 disappearing messages. Without a + * value, prints the folded timer (seconds, `0` = off). With one, publishes the metadata edition + * (MANAGE_METADATA, laid over the folded metadata) and then one kind-1740 timer notice into every + * channel this account holds a key for (§4). + */ + suspend fun timer( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val raw = args.positionalOrNull(1) + args.rejectUnknown() + val secs = + raw?.let { + parseTimer(it) ?: return Output.error("bad_args", "timer must be off, a number of seconds, or Nd/Nw/Ny (e.g. 1d, 1w, 30d, 90d, 1y)").let { 2 } + } + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val loaded = load(ctx, sc, dataDir) + val cid = sc.communityId.hexToByteArray() + val state = ConcordCommunityState.fold(loaded.editions, cid, sc.owner) + val current = state.metadata?.messageExpirationSecs() ?: 0L + if (secs == null) { + Output.emit(mapOf("community" to sc.communityId, "message_expiration" to current, "enabled" to (current > 0))) + return 0 + } + writeGuard(loaded.keys)?.let { return it } + if (!state.authority.hasPermission(ctx.signer.pubKey, ConcordPermissions.MANAGE_METADATA)) { + return Output.error("forbidden", "setting the timer takes MANAGE_METADATA in '$handle' (CORD-08 §1)") + } + val timer = secs.takeIf { it >= 1 } + val relays = ConcordCommands.relaysFor(ctx, sc) + val wrap = ConcordModeration.setMessageExpiration(ctx.signer, loaded.keys, cid, state.metadata ?: MetadataEntity(), timer, loaded.editions, TimeUtils.now(), owner = sc.owner) + val ack = ctx.publish(wrap, relays) + RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } + + // CORD-08 §4: one notice per channel whose key we hold; the fold stays the authority. + val entry = ConcordCommands.entryFor(loaded.community) + val now = TimeUtils.now() + var notices = 0 + for (channelIdHex in state.channels.keys) { + val plane = ConcordActions.currentChannelPlane(entry, state, channelIdHex) ?: continue + ctx.registerConcordStreamKeys(relays, listOf(plane.key.secretKey)) + val notice = ConcordActions.buildChannelTimerNotice(ctx.signer, plane.key, channelIdHex, plane.epoch, timer ?: 0L, now) + // Best effort, like the reference client: a notice that no relay took is only counted out. + if (ctx.publish(notice, relays).values.any { it.accepted }) notices++ + } + Output.emit(mapOf("community" to sc.communityId, "message_expiration" to (timer ?: 0L), "previous" to current, "notices" to notices) + RawEventSupport.ackFields(ack)) + return 0 + } + } + + /** `off`/`0`, plain seconds, or a count of days/weeks/years (`1d`, `1w`, `30d`, `1y`); null if unparseable. */ + private fun parseTimer(raw: String): Long? { + val v = raw.trim().lowercase() + if (v == "off") return 0L + v.toLongOrNull()?.let { return it.takeIf { it >= 0 } } + val n = v.dropLast(1).toLongOrNull()?.takeIf { it >= 1 } ?: return null + val day = ConcordDisappearing.MIN_OFFERED_SECS + return when (v.last()) { + 'd' -> n * day + 'w' -> n * 7 * day + 'y' -> n * 365 * day + else -> null + } + } + /** Unbans a member: `unban `. */ suspend fun unban( dataDir: DataDir, @@ -229,7 +349,19 @@ object ConcordModCommands { } val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } - Output.emit(mapOf("member" to member, "banned" to ban) + RawEventSupport.ackFields(ack)) + // CORD-06 §3 / CORD-05 §5: a Public ban is the Banlist alone; a ban from a Private + // community owes a Refounding (`concord refound COMMUNITY --remove USER`). Judged with + // the target's own invite registry left out, since the ban stops honoring it. + val mode = + if (ban) { + val state = ConcordCommunityState.fold(editions, cid, sc.owner) + val refound = state.banRequiresRefounding(listOf(member)) + if (refound) System.err.println("[concord] the community is Private: run `amy concord refound ${sc.communityId} --remove $member` to sever the banned member's keys (CORD-06 §3)") + mapOf("public" to !refound, "refound_required" to refound) + } else { + emptyMap() + } + Output.emit(mapOf("member" to member, "banned" to ban) + mode + RawEventSupport.ackFields(ack)) return 0 } } @@ -240,7 +372,7 @@ object ConcordModCommands { * rewrites the stored record — a caller that kept the pre-load copy would then fail to pass the * secret on in its own Grant (CORD-04 §3). */ - private class LoadedControl( + internal class LoadedControl( val community: StoredCommunity, val keys: ControlPlaneKeys, val editions: List, @@ -275,7 +407,11 @@ object ConcordModCommands { ): Int { val args = Args(rest) val handle = args.positional(0, "community") - val removeArg = args.flag("remove") ?: return Output.error("bad_args", "refound --remove USER[,USER…]").let { 2 } + val removeArg = args.flag("remove") + // CORD-06 §3 "converting a Public Community to Private": a Refounding that removes nobody, + // owed when the last live invite link is retired (CORD-05 §2/§5). + val privatize = args.bool("privatize") + if (removeArg == null && !privatize) return Output.error("bad_args", "refound --remove USER[,USER…] | --privatize").let { 2 } args.rejectUnknown() val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) @@ -283,12 +419,13 @@ object ConcordModCommands { ctx.prepare() val removed = removeArg - .split(',') - .map { it.trim() } - .filter { it.isNotEmpty() } - .map { ctx.requireUserHex(it).lowercase() } - .toSet() - if (removed.isEmpty()) return Output.error("bad_args", "--remove needs at least one user") + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.map { ctx.requireUserHex(it).lowercase() } + ?.toSet() + .orEmpty() + if (removed.isEmpty() && !privatize) return Output.error("bad_args", "--remove needs at least one user") // Death wins every race (CORD-02 §9): no epoch advance past a tombstone is honored. if (ConcordCommands.isDissolved(ctx, sc)) { @@ -551,7 +688,7 @@ object ConcordModCommands { } /** Drain the control plane and return its keys + current editions to chain onto. */ - private suspend fun load( + internal suspend fun load( ctx: Context, sc: StoredCommunity, dataDir: DataDir? = null, @@ -584,7 +721,7 @@ object ConcordModCommands { * a spam gate, never authority — holding the key still does not make the action * honored, which the Roster decides at fold (CORD-04 §5). */ - private fun writeGuard(cp: ControlPlaneKeys): Int? { + internal fun writeGuard(cp: ControlPlaneKeys): Int? { if (cp.canWrite) return null Output.error("forbidden", "this account holds no control_root for the community, so it cannot publish Control Plane editions (CORD-02 §2) — ask a staff member to grant you a Control-writing role") return 1 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPinCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPinCommands.kt new file mode 100644 index 0000000000..bbbf4e946c --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPinCommands.kt @@ -0,0 +1,235 @@ +/* + * 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.cli.commands + +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.amethyst.cli.stores.ConcordStore +import com.vitorpamplona.amethyst.cli.stores.StoredCommunity +import com.vitorpamplona.amethyst.commons.actions.ChannelPlane +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPins +import com.vitorpamplona.amethyst.commons.actions.ConcordPinContext +import com.vitorpamplona.amethyst.commons.actions.ConcordPinEvidence +import com.vitorpamplona.amethyst.commons.actions.ConcordPinOutcome +import com.vitorpamplona.amethyst.commons.actions.ConcordPinWrite +import com.vitorpamplona.amethyst.commons.actions.ConcordPinning +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * `amy concord pins|pin|unpin` — a Channel's Pin List (CORD-04 §7). Thin assembly: the drain is + * here, the reading, verification, gating, caps and edition building are [ConcordPinning]'s. + */ +object ConcordPinCommands { + private val HEX64 = Regex("^[0-9a-f]{64}$") + + /** One channel's drained view: its planes, the wraps on them, and the evidence they carry. */ + private class ChannelView( + val planes: List, + val wraps: List, + val evidence: ConcordPinEvidence, + ) + + /** Drains every plane of [channelIdHex] this account holds (current + held prior epochs). */ + private suspend fun drainChannel( + ctx: Context, + sc: StoredCommunity, + state: ConcordCommunityState, + channelIdHex: String, + ): ChannelView { + val entry = ConcordCommands.entryFor(sc) + val isPrivate = state.channels[channelIdHex]?.definition?.private == true + val planes = listOfNotNull(ConcordActions.currentChannelPlane(entry, channelIdHex, isPrivate)) + ConcordActions.historicalChannelPlanes(entry, channelIdHex, isPrivate) + if (planes.isEmpty()) return ChannelView(planes, emptyList(), ConcordPinEvidence(emptyList())) + val relays = ConcordCommands.relaysFor(ctx, sc) + ctx.registerConcordStreamKeys(relays, planes.map { it.key.secretKey }) + val filter = ConcordActions.planeFilterFor(planes.map { it.key.publicKeyHex }) + val wraps = ctx.drain(relays.associateWith { listOf(filter) }, pendingOnAuthRequired = true).map { it.second } + val byAddress = planes.associateBy { it.key.publicKeyHex } + val rumors = wraps.mapNotNull { wrap -> byAddress[wrap.pubKey]?.let { ConcordActions.openChannelRumor(wrap, it.key, channelIdHex, it.epoch) } } + return ChannelView(planes, wraps, ConcordPinEvidence(rumors)) + } + + private fun read( + sc: StoredCommunity, + editions: List, + channelIdHex: String, + view: ChannelView, + ): ConcordChannelPins { + val head = ConcordPinning.headFor(editions, sc.communityId, sc.owner, channelIdHex) + return ConcordPinning.read( + head, + channelIdHex, + unsealKey = { epoch -> + view.planes + .firstOrNull { it.epoch == epoch } + ?.key + ?.conversationKey + }, + isKilled = view.evidence::isKilled, + newestEdit = view.evidence::newestEdit, + ) + } + + private fun render(pins: ConcordChannelPins): Map = + mapOf( + "channel" to pins.channelIdHex, + "version" to pins.head?.version, + "count" to pins.count, + // Unreadable is not empty: the list is sealed under an epoch key this account never held. + "sealed_unavailable" to pins.sealedUnavailable, + "sealed" to pins.sealedForm, + "violating" to pins.violating, + "invalid_entries" to pins.invalidEntries, + "deleted" to pins.killed.map { it.rumorId }, + "pins" to + pins.pins.map { + mapOf( + "rumor_id" to it.rumorId, + "author" to it.author, + "kind" to it.pin.kind, + "content" to it.content, + "created_at" to it.pin.createdAt, + "edited" to it.edited, + // A newer Edit this account holds but the entry cannot prove yet. + "stale_edit" to (it.newerEdit != null), + "epoch" to it.pin.epoch, + "wrap" to it.pin.wrapHint, + ) + }, + ) + + /** `concord pins COMMUNITY CHANNEL` — the verified Pin List. */ + suspend fun pins( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val channelRef = args.positional(1, "channel") + args.rejectUnknown() + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val loaded = ConcordModCommands.load(ctx, sc) + val state = ConcordCommunityState.fold(loaded.editions, sc.communityId.hexToByteArray(), sc.owner) + val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") + if (channelId !in state.channels) return Output.error("not_found", "channel '$channelRef' is not folded") + val view = drainChannel(ctx, sc, state, channelId) + Output.emit(render(read(sc, loaded.editions, channelId, view))) + return 0 + } + } + + /** `concord pin COMMUNITY CHANNEL RUMOR_ID` */ + suspend fun pin( + dataDir: DataDir, + rest: Array, + ): Int = write(dataDir, rest, pin = true) + + /** `concord unpin COMMUNITY CHANNEL RUMOR_ID` */ + suspend fun unpin( + dataDir: DataDir, + rest: Array, + ): Int = write(dataDir, rest, pin = false) + + private suspend fun write( + dataDir: DataDir, + rest: Array, + pin: Boolean, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val channelRef = args.positional(1, "channel") + val rumorId = args.positional(2, "rumor_id").lowercase() + args.rejectUnknown() + if (!HEX64.matches(rumorId)) return Output.error("bad_args", "RUMOR_ID must be a 64-char hex rumor id") + val stored = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + // CORD-02 §9: after Dissolution no edition can land. + if (ConcordCommands.isDissolved(ctx, stored)) return Output.error("dissolved", "community '$handle' has been dissolved and is read-only (CORD-02 §9)") + val loaded = ConcordModCommands.load(ctx, stored, dataDir) + val sc = loaded.community + ConcordModCommands.writeGuard(loaded.keys)?.let { return it } + val communityId = sc.communityId.hexToByteArray() + val state = ConcordCommunityState.fold(loaded.editions, communityId, sc.owner) + val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") + val definition = state.channels[channelId]?.definition ?: return Output.error("not_found", "channel '$channelRef' is not folded") + val view = drainChannel(ctx, sc, state, channelId) + val me = ctx.signer.pubKey + val pinCtx = + ConcordPinContext( + actor = ctx.signer, + controlPlane = loaded.keys, + communityId = communityId, + owner = sc.owner, + current = loaded.editions, + channelIdHex = channelId, + channelIsPrivate = definition.private, + currentPlane = ConcordActions.currentChannelPlane(ConcordCommands.entryFor(sc), state, channelId), + pins = read(sc, loaded.editions, channelId, view), + authorized = sc.owner.equals(me, ignoreCase = true) || state.authority.hasPermission(me, ConcordPermissions.PIN_MESSAGES), + ) + val result = + if (pin) { + val refused = ConcordPinning.refusal(pinCtx) + val source = if (refused == null) ConcordPinning.sourceFrom(view.wraps, view.planes, rumorId) else null + when { + refused != null -> ConcordPinWrite(refused) + source == null -> ConcordPinWrite(ConcordPinOutcome.MESSAGE_UNAVAILABLE) + else -> ConcordPinning.pin(pinCtx, source, TimeUtils.now()) + } + } else { + ConcordPinning.unpin(pinCtx, rumorId, TimeUtils.now()) + } + val wrap = result.wrap ?: return Output.error(result.outcome.name.lowercase(), refusalDetail(result.outcome)) + val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)) + RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } + Output.emit(mapOf("channel" to channelId, "rumor_id" to rumorId, "pinned" to pin, "entries" to result.entries.size, "event_id" to wrap.id) + RawEventSupport.ackFields(ack)) + return 0 + } + } + + private fun refusalDetail(outcome: ConcordPinOutcome): String = + when (outcome) { + ConcordPinOutcome.ALREADY_PINNED -> "that message is already pinned" + ConcordPinOutcome.NOT_PINNED -> "that message is not pinned" + ConcordPinOutcome.NOT_AUTHORIZED -> "pinning takes PIN_MESSAGES (or ownership) (CORD-04 §3)" + ConcordPinOutcome.NO_WRITE_KEY -> "no control_root held for this epoch (CORD-02 §2)" + ConcordPinOutcome.NO_CHANNEL_KEY -> "private channel and this account holds no key for it, so the list cannot be sealed" + ConcordPinOutcome.LIST_UNAVAILABLE -> "the Pin List is sealed under a key this account never held; writing would drop pins it cannot see (CORD-04 §7)" + ConcordPinOutcome.MESSAGE_UNAVAILABLE -> "no held wrap carries that rumor, so its seal cannot be proven" + ConcordPinOutcome.UNVERIFIABLE -> "the message would not verify as a pin (only kind 9 / 1111 messages can be pinned)" + ConcordPinOutcome.TOO_MANY_PINS -> "the list already has 25 pins; unpin one first" + ConcordPinOutcome.TOO_LARGE -> "the list would exceed 32,768 bytes; unpin one first" + else -> outcome.name.lowercase() + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt new file mode 100644 index 0000000000..b9ec3a7116 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.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.cli.stores + +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.SecureFileIO +import java.io.File + +/** amy's bookkeeping for Concord Direct Invites (CORD-05 §6): the wrap ids the user declined. */ +data class StoredInviteInbox( + val declined: List = emptyList(), +) + +/** + * `~/.amy//concord-invites.json` — the declined Direct Invite wrap ids, so a declined + * invite (whose wrap relays keep serving until its NIP-40 expiration) never resurfaces in + * `amy concord invites`. + */ +class ConcordInviteInboxStore( + private val file: File, +) { + fun load(): StoredInviteInbox = + if (file.exists()) { + runCatching { Output.mapper.readValue(file.readText()) }.getOrDefault(StoredInviteInbox()) + } else { + StoredInviteInbox() + } + + fun declined(): Set = load().declined.toSet() + + fun decline(wrapId: String) { + val current = load() + if (wrapId in current.declined) return + SecureFileIO.writeTextAtomic(file, Output.mapper.writeValueAsString(current.copy(declined = current.declined + wrapId))) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 40ff004467..265fdf5e87 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.actions +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteSendResult import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState @@ -32,15 +34,21 @@ import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend import com.vitorpamplona.quartz.concord.cord05Invites.ConcordStrandedRecovery import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus +import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.OpenedDirectInvite import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding @@ -50,11 +58,17 @@ import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -316,8 +330,15 @@ object ConcordActions { */ fun bundlesFilter(linkSignerPubKeyHexes: List): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = linkSignerPubKeyHexes) - /** Pending direct invites addressed to the given member (indexed by k=3313). */ - fun directInvitesFilter(memberPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordDirectInvite.KIND.toString()))) + /** + * Pending direct invites addressed to the given member (indexed by k=3313, CORD-05 §6). [since] + * should come from [ConcordDirectInvite.inboxSince]: wraps are backdated up to two days, so a + * cursor at the newest wrap seen would miss invites published after it. + */ + fun directInvitesFilter( + memberPubKeyHex: HexKey, + since: Long? = null, + ): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordDirectInvite.KIND.toString())), since = since) // ---- community lifecycle -------------------------------------------------- @@ -354,6 +375,41 @@ object ConcordActions { // ---- channel chat --------------------------------------------------------- + /** + * [extraTags] plus the CORD-08 §2 `expiration` a rumor of [kind] created at [createdAt] must carry + * while the community's timer is [timerSecs] — none when the timer is off or the kind is exempt + * (deletes, timer notices, ephemeral kinds). Inside the signed rumor, so it is authoritative. + */ + private fun withTimer( + extraTags: Array>, + kind: Int, + createdAt: Long, + timerSecs: Long?, + ): Array> = ConcordDisappearing.withExpiration(extraTags, ConcordDisappearing.expirationFor(kind, createdAt, timerSecs)) + + /** + * Seals [rumor] (encrypted 20013) and wraps it on the [channel] plane. The wrap repeats the + * rumor's own `expiration`, if any, so NIP-40 relays delete the ciphertext (CORD-08 §2). + */ + private suspend fun wrapChat( + rumor: Event, + channel: GroupKey, + authorSigner: NostrSigner, + ): Event = ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true, outerTags = ConcordDisappearing.wrapTagsFor(rumor)) + + /** + * Builds a CORD-08 §4 timer-notice wrap (kind 1740) announcing [timerSecs] (`0` = off) on the + * [channel] plane. A notice never expires, whatever the timer. + */ + suspend fun buildChannelTimerNotice( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + timerSecs: Long, + createdAt: Long, + ): Event = wrapChat(ConcordDisappearing.timerNotice(authorSigner.pubKey, channelId, epoch, timerSecs, createdAt), channel, authorSigner) + /** Builds an encrypted-seal channel message wrap to publish on the [channel] plane. */ suspend fun buildChannelMessage( authorSigner: NostrSigner, @@ -363,9 +419,10 @@ object ConcordActions { text: String, createdAt: Long, extraTags: Array> = emptyArray(), + timerSecs: Long? = null, ): Event { - val rumor = ChannelChat.message(authorSigner.pubKey, channelId, epoch, text, createdAt, extraTags) - return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + val rumor = ChannelChat.message(authorSigner.pubKey, channelId, epoch, text, createdAt, withTimer(extraTags, ChatEvent.KIND, createdAt, timerSecs)) + return wrapChat(rumor, channel, authorSigner) } /** @@ -381,9 +438,10 @@ object ConcordActions { imetas: List, createdAt: Long, extraTags: Array> = emptyArray(), + timerSecs: Long? = null, ): Event { - val rumor = ChannelChat.imageMessage(authorSigner.pubKey, channelId, epoch, text, imetas, createdAt, extraTags) - return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + val rumor = ChannelChat.imageMessage(authorSigner.pubKey, channelId, epoch, text, imetas, createdAt, withTimer(extraTags, ChatEvent.KIND, createdAt, timerSecs)) + return wrapChat(rumor, channel, authorSigner) } /** Builds an encrypted-seal inline quote-reply wrap (kind-9 message quoting [parent] via `q`) on the [channel] plane. */ @@ -396,9 +454,10 @@ object ConcordActions { text: String, createdAt: Long, extraTags: Array> = emptyArray(), + timerSecs: Long? = null, ): Event { - val rumor = ChannelChat.inlineReply(authorSigner.pubKey, channelId, epoch, text, parent.id, parent.pubKey, createdAt, extraTags) - return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + val rumor = ChannelChat.inlineReply(authorSigner.pubKey, channelId, epoch, text, parent.id, parent.pubKey, createdAt, withTimer(extraTags, ChatEvent.KIND, createdAt, timerSecs)) + return wrapChat(rumor, channel, authorSigner) } /** Builds an encrypted-seal thread-reply wrap (kind-1111 NIP-22 comment on [parent]) on the [channel] plane. */ @@ -411,9 +470,10 @@ object ConcordActions { text: String, createdAt: Long, extraTags: Array> = emptyArray(), + timerSecs: Long? = null, ): Event { - val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent, createdAt, extraTags) - return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent, createdAt, withTimer(extraTags, CommentEvent.KIND, createdAt, timerSecs)) + return wrapChat(rumor, channel, authorSigner) } /** @@ -430,9 +490,10 @@ object ConcordActions { imetas: List, createdAt: Long, extraTags: Array> = emptyArray(), + timerSecs: Long? = null, ): Event { - val rumor = ChannelChat.imageReply(authorSigner.pubKey, channelId, epoch, text, imetas, parent, createdAt, extraTags) - return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + val rumor = ChannelChat.imageReply(authorSigner.pubKey, channelId, epoch, text, imetas, parent, createdAt, withTimer(extraTags, CommentEvent.KIND, createdAt, timerSecs)) + return wrapChat(rumor, channel, authorSigner) } /** @@ -449,9 +510,10 @@ object ConcordActions { newText: String, createdAt: Long, extraTags: Array> = emptyArray(), + timerSecs: Long? = null, ): Event { - val rumor = ChannelChat.edit(authorSigner.pubKey, channelId, epoch, target.id, newText, createdAt, extraTags) - return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + val rumor = ChannelChat.edit(authorSigner.pubKey, channelId, epoch, target.id, newText, createdAt, withTimer(extraTags, ConcordChatEditEvent.KIND, createdAt, timerSecs)) + return wrapChat(rumor, channel, authorSigner) } /** @@ -469,7 +531,7 @@ object ConcordActions { createdAt: Long, ): Event { val rumor = ChannelChat.delete(authorSigner.pubKey, channelId, epoch, targets, createdAt) - return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + return wrapChat(rumor, channel, authorSigner) } /** Builds an encrypted-seal reaction wrap (kind 7 against [target]) on the [channel] plane. */ @@ -482,9 +544,10 @@ object ConcordActions { reaction: String, createdAt: Long, extraTags: Array> = emptyArray(), + timerSecs: Long? = null, ): Event { - val rumor = ChannelChat.reaction(authorSigner.pubKey, channelId, epoch, target.id, target.pubKey, target.kind, reaction, createdAt, extraTags) - return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + val rumor = ChannelChat.reaction(authorSigner.pubKey, channelId, epoch, target.id, target.pubKey, target.kind, reaction, createdAt, withTimer(extraTags, ReactionEvent.KIND, createdAt, timerSecs)) + return wrapChat(rumor, channel, authorSigner) } /** @@ -538,14 +601,28 @@ object ConcordActions { /** * Opens one channel [wrap] and returns its rumor only when it passes the Chat ingest gate * ([ChannelChat.acceptOpened]): an encrypted 20013 seal, a Chat kind (never another plane's - * kind), a strict `channel`/`epoch` binding, and a well-formed `ms`. Anything else is dropped - * here, before it can reach the store. + * kind), a strict `channel`/`epoch` binding, and a well-formed `ms`. A rumor whose own + * `expiration` is at or before [now] is refused too (CORD-08 §3: never stored). Anything else is + * dropped here, before it can reach the store. */ fun openChannelRumor( wrap: Event, channel: GroupKey, channelId: HexKey, epoch: Long, + now: Long = TimeUtils.now(), + ): Event? = openChannelRumorAnyExpiry(wrap, channel, channelId, epoch)?.takeUnless { ConcordDisappearing.isExpired(it, now) } + + /** + * [openChannelRumor] without the CORD-08 expiry refusal, for a caller that must tell an expired + * rumor apart from garbage — the session, which purges an expired rumor's wrap instead of merely + * skipping it. Such a caller owns the refusal. + */ + fun openChannelRumorAnyExpiry( + wrap: Event, + channel: GroupKey, + channelId: HexKey, + epoch: Long, ): Event? = ConcordStreamEnvelope.openOrNull(wrap, channel)?.let { ChannelChat.acceptOpened(it, channelId, epoch) } // ---- invites -------------------------------------------------------------- @@ -583,6 +660,95 @@ object ConcordActions { label = label, ) + /** + * The §1 bundle a Direct Invite hands [recipient] for the community [entry] holds (CORD-05 §6): + * the current base, epoch and `control_pk`, the relays, a name/icon preview, the optional + * [expiresAtMs] (unix ms) and [creator] attribution — and exactly the Private Channel keys the + * recipient's Roles entitle them to in [authority] ([ConcordInviteVend.vendableChannels], Armada's + * `VendAudience` "member" rule). A key the recipient isn't entitled to is never whispered, even + * though nothing on the wire could stop it. + */ + fun directInviteFor( + entry: ConcordCommunityListEntry, + authority: AuthorityResolver, + recipient: HexKey, + creator: HexKey, + expiresAtMs: Long? = null, + name: String = entry.name, + icon: ImagePointer? = null, + ): CommunityInvite = + CommunityInvite( + communityId = entry.id, + owner = entry.owner, + ownerSalt = entry.ownerSalt, + communityRoot = entry.root, + rootEpoch = entry.rootEpoch, + controlPk = entry.controlPk, + channels = ConcordInviteVend.toInviteChannels(ConcordInviteVend.vendableChannels(entry.privateChannels, authority, recipient)), + relays = entry.relays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), + name = name.ifBlank { entry.name }, + icon = icon, + expiresAt = expiresAtMs, + creatorNpub = creator, + ) + + /** + * The Direct Invite [sender] may hand [recipient] for the held [entry] whose Control Plane folds + * to [state] (CORD-05 §6), or why not. No community permission gates a Direct Invite — none + * could — but a dissolved community, a [sender] its roster bans (like minting a link), and a + * banned [recipient] (whose join would be refused anyway) are refused; the bundle's name/icon + * preview comes from the folded metadata. + */ + fun draftDirectInvite( + entry: ConcordCommunityListEntry, + state: ConcordCommunityState, + sender: HexKey, + recipient: HexKey, + expiresAtMs: Long? = null, + ): ConcordDirectInviteDraft { + val to = recipient.lowercase() + if (!HEX64.matches(to)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.INVALID_RECIPIENT) + if (state.dissolved || state.authority.isBanned(sender)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.NOT_MEMBER) + if (state.authority.isBanned(to)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.RECIPIENT_BANNED) + return ConcordDirectInviteDraft.Ready( + directInviteFor( + entry = entry, + authority = state.authority, + recipient = to, + creator = sender.lowercase(), + expiresAtMs = expiresAtMs, + name = state.metadata?.name ?: entry.name, + icon = state.metadata?.icon, + ), + ) + } + + /** Giftwraps [invite] to [recipient] as a Direct Invite (see [ConcordDirectInvite.build]). */ + suspend fun buildDirectInvite( + senderSigner: NostrSigner, + recipient: HexKey, + invite: CommunityInvite, + createdAt: Long = TimeUtils.now(), + ): GiftWrapEvent = ConcordDirectInvite.build(senderSigner, recipient, invite, createdAt) + + /** Opens + validates a Direct Invite wrap addressed to [recipientSigner] (see [ConcordDirectInvite.open]). */ + suspend fun openDirectInvite( + wrap: Event, + recipientSigner: NostrSigner, + ): OpenedDirectInvite? = ConcordDirectInvite.open(wrap, recipientSigner) + + /** + * Where a Direct Invite reaches a member, and where that member scans for one (CORD-05 §6): + * their kind-10050 DM relays, else their NIP-65 read relays, else the stock Concord set every + * client ships (Armada `inviteDeliveryRelays`). Send and scan share this so both sides meet. The + * stock set is fallback-only: a curated private inbox is never also fanned out to public relays. + */ + fun directInviteDeliveryRelays(lists: RecipientRelayFetcher.Lists?): Set { + val inbox = lists?.dmInboxOrFallback().orEmpty() + if (inbox.isNotEmpty()) return inbox.toSet() + return InviteRelayDictionary.STOCK.mapNotNullTo(LinkedHashSet()) { RelayUrlNormalizer.normalizeOrNull(it) } + } + /** Mints a shareable public invite link + bundle event (see [ConcordInviteBundle.mintLink]). */ fun mintInviteLink( base: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt index 8881ee593b..f7112d9b7e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt @@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPins +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope @@ -301,6 +303,34 @@ object ConcordModeration { return roleIds.any { roles[it]?.permissionBits()?.hasAny(ConcordPermissions.STAFF_BITS) == true } } + /** + * Writes [content] — an already-serialized Pin List ([ConcordPins.serializePublic] / + * [ConcordPins.serializeSealed]) — as the next edition of [channelId]'s Pin List (CORD-04 §7, + * vsk 11, at `pins_locator(community_id, channel_id)`), chained onto [head]. + * + * Unlike the other editors this takes the head explicitly rather than re-folding [current]: a + * Pin List is replaced entire, so the edition MUST chain onto exactly the list the caller read + * its entries from (§7 — never build from a list you could not read). [ConcordPinning] is the + * caller that enforces that, the PIN_MESSAGES gate and the caps; this only mints the wrap. + */ + suspend fun setPinList( + actor: NostrSigner, + controlPlane: ControlPlaneKeys, + communityId: ByteArray, + channelId: ByteArray, + head: ControlEdition?, + content: String, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + owner: HexKey, + ): Event { + require(content.encodeToByteArray().size <= ConcordPins.MAX_CONTENT_BYTES) { "pin list exceeds ${ConcordPins.MAX_CONTENT_BYTES} bytes" } + val entityId = ConcordKeyDerivation.pinsCoordinate(communityId, channelId) + require(head == null || head.entityIdHex == entityId.toHexKey()) { "head is not this channel's Pin List" } + return wrap(actor, controlPlane, communityId, ControlEntityKind.PIN_LIST, entityId, head, content, current, createdAt, citation, owner) + } + /** * Adds [member] to the banlist, written over the current folded head. Another admin's * concurrent edition at the same version may win the fold (CORD-04 §4); calling this again @@ -344,6 +374,28 @@ object ConcordModeration { owner: HexKey, ): Set = AuthorityResolver.resolve(current, communityId, owner).bannedMembers() + /** + * Publishes [actor]'s Invite Registry (CORD-05 §5, `vsk 8`) listing [linkSigners] — the + * link-signer pubkeys of their live public links, locators only. The entity sits at + * `invite_links_locator(community_id, actor)`, so it chains onto [actor]'s own registry head and + * can never touch another creator's; it is honored at fold only while [actor] holds + * CREATE_INVITE (or is the owner). Compute [linkSigners] with [ConcordInviteRegistry.nextLinks]. + */ + suspend fun setInviteRegistry( + actor: NostrSigner, + controlPlane: ControlPlaneKeys, + communityId: ByteArray, + linkSigners: Collection, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + owner: HexKey, + ): Event { + val entityId = ConcordInviteRegistry.coordinate(communityId, actor.pubKey) + val head = headOf(current, communityId, entityId, owner) + return wrap(actor, controlPlane, communityId, ControlEntityKind.INVITE_REGISTRY, entityId, head, ConcordInviteRegistry.encode(linkSigners), current, createdAt, citation, owner) + } + private suspend fun setBanlist( actor: NostrSigner, controlPlane: ControlPlaneKeys, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinning.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinning.kt new file mode 100644 index 0000000000..9f9bf8b85d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinning.kt @@ -0,0 +1,490 @@ +/* + * 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 com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor +import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPinLists +import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPins +import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPins.VerifiedPin +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.serialization.json.JsonObject +import kotlin.random.Random + +/** An Edit (kind 3302) this client holds for a pinned message — the author's newest, by send time. */ +class ConcordLocalEdit( + val rumorId: HexKey, + val author: HexKey, + val content: String, + /** `created_at * 1000 + ms`, comparable with [VerifiedPin.editOrderMs]. */ + val orderMs: Long, +) + +/** One verified pin as a reader shows it (CORD-04 §7). */ +class ConcordPinnedMessage( + val pin: VerifiedPin, + /** The newest words this client can show: a held newer Edit's, else the proof's. */ + val content: String, + /** + * True when the message was revised: the entry carries a proven Edit, or this client holds a + * newer one (§7: a client holding a newer Edit MUST mark the pin edited, never render the + * superseded words as current). + */ + val edited: Boolean, + /** A held Edit newer than the entry's proof — what a curator's refresh would attach. */ + val newerEdit: ConcordLocalEdit?, +) { + val rumorId: HexKey get() = pin.rumorId + val author: HexKey get() = pin.author +} + +/** + * A Channel's Pin List as this client reads it (CORD-04 §7). + * + * [sealedUnavailable] is deliberately distinct from an empty list: the list exists but is sealed + * under an epoch key this client never held. It renders as "unavailable", and no edition may be + * built from it — publishing would silently drop every entry this client cannot see. + */ +class ConcordChannelPins( + val channelIdHex: HexKey, + /** The authorized head edition the list was read from, or null when the Channel has none. */ + val head: ControlEdition?, + /** Verified, un-deleted entries in wire order — the base a write replaces entire. */ + val alive: List, + /** Verified entries their author erased (a held kind 5): hidden now, owed an omitting edition. */ + val killed: List, + /** [alive] for display, newest message first, with held Edits applied. */ + val pins: List, + val sealedUnavailable: Boolean, + /** True when the head's content broke a cap or the format, so it reads as empty. */ + val violating: Boolean, + /** True when the head is the sealed form (`{"epoch","sealed"}`). */ + val sealedForm: Boolean, + /** Entries that failed verification and were dropped alone. */ + val invalidEntries: Int, +) { + val count: Int get() = pins.size + + fun isPinned(rumorId: HexKey): Boolean = alive.any { it.rumorId == rumorId } + + /** True when the head owes keyless readers a republish: an erased entry, or a newer Edit to attach. */ + val owesRepublish: Boolean get() = killed.isNotEmpty() || pins.any { it.newerEdit != null } + + companion object { + fun none(channelIdHex: HexKey) = ConcordChannelPins(channelIdHex, null, emptyList(), emptyList(), emptyList(), sealedUnavailable = false, violating = false, sealedForm = false, invalidEntries = 0) + } +} + +/** + * Pin-entry verification memoized by entry identity (§7 Weight: a re-folding client "SHOULD cache + * entry verification by entry identity" — otherwise every fold redoes each signature, MAC and + * decryption). The key is a hash of the channel and the entry's exact bytes, so a cached verdict can + * never be served for a different entry that merely names the same seal. Bounded; failures are + * cached too. + */ +class ConcordPinVerifier( + private val maxEntries: Int = 512, +) { + private val lock = KmpLock() + private val verdicts = LinkedHashMap() + + /** Verification runs actually performed (cache misses) — for tests. */ + var misses: Int = 0 + private set + + fun verify( + entry: JsonObject, + channelIdHex: HexKey, + ): VerifiedPin? { + val key = sha256((channelIdHex + entry.toString()).encodeToByteArray()).toHexKey() + lock.withLock { if (verdicts.containsKey(key)) return verdicts[key] } + val verdict = ConcordPins.verify(entry, channelIdHex) + lock.withLock { + misses++ + verdicts[key] = verdict + while (verdicts.size > maxEntries) verdicts.remove(verdicts.keys.first()) + } + return verdict + } +} + +/** The proof material for pinning one opened message: its original seal and the plane key of its epoch. */ +class ConcordPinSource( + val opened: OpenedStreamEvent, + /** The carrying wrap, as the entry's (unverifiable) locator hint. */ + val wrapId: HexKey?, + /** The Channel's conversation key at the message's epoch — what the disclosure derives from. */ + val conversationKey: ByteArray, +) { + val rumorId: HexKey get() = opened.rumor.id +} + +/** + * What the reader holds about pinned messages from its own Chat Plane view: the kind-5 deletes and + * kind-3302 Edits among [rumors]. The app builds the same lookups off its event store; `amy` and the + * tests build them from the rumors they drained. + */ +class ConcordPinEvidence( + rumors: Collection, +) { + private val deletesByTarget = HashMap>() + private val editsByTarget = HashMap>() + + init { + for (rumor in rumors) { + when (rumor.kind) { + 5 -> rumor.tags.forEach { if (it.size >= 2 && it[0] == "e") deletesByTarget.getOrPut(it[1]) { ArrayList() }.add(rumor) } + ConcordPins.KIND_EDIT -> rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.let { editsByTarget.getOrPut(it[1]) { ArrayList() }.add(rumor) } + } + } + } + + /** True when a held delete by the pin's proven author names it (§7 Interaction with deletion). */ + fun isKilled(pin: VerifiedPin): Boolean = deletesByTarget[pin.rumorId]?.any { ConcordPins.killedBy(pin, it.pubKey, it.tags) } == true + + /** The author's newest held Edit of the pinned message, or null. */ + fun newestEdit(pin: VerifiedPin): ConcordLocalEdit? = + editsByTarget[pin.rumorId] + ?.filter { it.pubKey == pin.author } + ?.maxWithOrNull(compareBy({ orderMsOf(it) }, { it.id })) + ?.let { ConcordLocalEdit(it.id, it.pubKey, it.content, orderMsOf(it)) } + + private fun orderMsOf(rumor: Event): Long = ChannelChat.orderingMs(rumor) ?: (rumor.createdAt * 1000) +} + +/** Why a pin write did or did not publish. Everything but [PUBLISHED] publishes nothing. */ +enum class ConcordPinOutcome { + PUBLISHED, + ALREADY_PINNED, + NOT_PINNED, + + /** The head already says what the write would say (a duty already done by someone else). */ + NOTHING_TO_DO, + NOT_WRITEABLE, + + /** Neither the owner nor a PIN_MESSAGES holder (a banned holder included). */ + NOT_AUTHORIZED, + + /** No Control Plane write key (`control_root`) at this epoch (CORD-02 §2). */ + NO_WRITE_KEY, + + /** The community or channel has not folded yet: there is no list to build on. */ + NOT_FOLDED, + + /** A Private Channel whose current key this account does not hold: the list cannot be sealed. */ + NO_CHANNEL_KEY, + + /** The current list is sealed under a key this client never held — MUST withhold the write. */ + LIST_UNAVAILABLE, + + /** The message's original wrap (and so its seal) is not held, so it cannot be proven. */ + MESSAGE_UNAVAILABLE, + + /** The entry would not verify (not a message or reply, or not openable at its epoch). */ + UNVERIFIABLE, + TOO_MANY_PINS, + TOO_LARGE, +} + +class ConcordPinWrite( + val outcome: ConcordPinOutcome, + /** The Control Plane wrap to publish when [outcome] is [ConcordPinOutcome.PUBLISHED]. */ + val wrap: Event? = null, + /** The entries the new edition carries. */ + val entries: List = emptyList(), +) { + val published: Boolean get() = outcome == ConcordPinOutcome.PUBLISHED +} + +/** Everything a Pin List write needs, resolved by the caller (the app's session, or `amy`'s drain). */ +class ConcordPinContext( + val actor: NostrSigner, + val controlPlane: ControlPlaneKeys, + val communityId: ByteArray, + val owner: HexKey, + /** The community's current Control Plane editions (for the `vac` citation). */ + val current: List, + val channelIdHex: HexKey, + /** The Channel's folded `private` flag: it alone picks the form a writer uses (§7). */ + val channelIsPrivate: Boolean, + /** The Channel's current plane — a private list is sealed under its key at its epoch. */ + val currentPlane: ChannelPlane?, + /** The list as read from its head: the base every write replaces entire. */ + val pins: ConcordChannelPins, + /** The owner or a PIN_MESSAGES holder, per the fold (hasPermission, so a banned holder is not). */ + val authorized: Boolean, +) + +/** + * CORD-04 §7 Pins at the commons layer: read a Channel's Pin List into verified, deletion-aware, + * edit-aware pins, and write the next edition for pin, unpin, the deletion omission and the Edit + * refresh. Pure — the caller publishes the returned wrap (and, in the app, echoes it into the + * session so the next write chains onto it). + */ +object ConcordPinning { + /** The window a non-pinner witness waits before a duty republish, so simultaneous curators collapse to one. */ + const val DUTY_MIN_DELAY_MS = 3_000L + const val DUTY_MAX_DELAY_MS = 15_000L + + fun dutyDelayMs(random: Random = Random.Default): Long = random.nextLong(DUTY_MIN_DELAY_MS, DUTY_MAX_DELAY_MS + 1) + + /** The authorized head of [channelIdHex]'s Pin List among [editions], or null (the `amy` / one-shot path). */ + fun headFor( + editions: Collection, + communityIdHex: HexKey, + owner: HexKey, + channelIdHex: HexKey, + floors: Map = emptyMap(), + ): ControlEdition? { + val authority = AuthorityResolver.resolve(editions, communityIdHex.hexToByteArray(), owner) + return ConcordPinLists.heads(editions, authority, communityIdHex, listOf(channelIdHex), floors)[channelIdHex] + } + + /** + * Reads [head] as [channelIdHex]'s Pin List: the sealed form opens with [unsealKey] (the + * Channel's conversation key at the named epoch), each entry is verified through [verifier] + * (dropped alone on failure), an entry its author erased ([isKilled]) is hidden at once, and an + * entry behind a held newer Edit ([newestEdit]) is marked edited and shows the newer words. + */ + fun read( + head: ControlEdition?, + channelIdHex: HexKey, + unsealKey: (epoch: Long) -> ByteArray?, + verifier: ConcordPinVerifier = ConcordPinVerifier(), + isKilled: (VerifiedPin) -> Boolean = { false }, + newestEdit: (VerifiedPin) -> ConcordLocalEdit? = { null }, + ): ConcordChannelPins { + if (head == null) return ConcordChannelPins.none(channelIdHex) + val read = ConcordPins.read(head.content, unsealKey) + val alive = ArrayList() + val killed = ArrayList() + var invalid = 0 + val seen = HashSet() + for (entry in read.entries) { + val pin = verifier.verify(entry, channelIdHex) + if (pin == null) { + invalid++ + continue + } + // The recomputed rumor id is the entry's identity, for deduplication too. + if (!seen.add(pin.rumorId)) continue + if (isKilled(pin)) killed.add(pin) else alive.add(pin) + } + val shown = + alive + .map { pin -> + val local = newestEdit(pin)?.takeIf { it.author == pin.author && isNewer(it, pin) } + ConcordPinnedMessage(pin, local?.content ?: pin.content, edited = pin.edited || local != null, newerEdit = local) + }.sortedWith(compareByDescending { it.pin.orderMs }.thenBy { it.rumorId }) + return ConcordChannelPins( + channelIdHex = channelIdHex, + head = head, + alive = alive, + killed = killed, + pins = shown, + sealedUnavailable = read.sealedUnavailable, + violating = read.violating, + sealedForm = ConcordPins.isSealedForm(head.content), + invalidEntries = invalid, + ) + } + + /** True when [edit] is newer than whatever Edit [pin]'s proof already carries. */ + private fun isNewer( + edit: ConcordLocalEdit, + pin: VerifiedPin, + ): Boolean { + if (edit.rumorId == pin.editRumorId) return false + val proven = pin.editOrderMs ?: return true + return edit.orderMs > proven || (edit.orderMs == proven && edit.rumorId > (pin.editRumorId ?: "")) + } + + /** + * Reopens [wrap] on [plane] as the proof source for [rumorId], or null when it does not carry + * exactly that message under the Chat ingest gate. + */ + fun sourceOf( + wrap: Event, + plane: ChannelPlane, + rumorId: HexKey, + ): ConcordPinSource? { + val opened = ConcordStreamEnvelope.openOrNull(wrap, plane.key) ?: return null + val rumor = ChannelChat.acceptOpened(opened, plane.channelIdHex, plane.epoch) ?: return null + if (rumor.id != rumorId) return null + return ConcordPinSource(opened, wrap.id, plane.key.conversationKey) + } + + /** Finds [rumorId] among [wraps] on any of [planes] (the one-shot path, e.g. `amy`). */ + fun sourceFrom( + wraps: Collection, + planes: Collection, + rumorId: HexKey, + ): ConcordPinSource? { + val byAddress = planes.associateBy { it.key.publicKeyHex } + for (wrap in wraps) { + val plane = byAddress[wrap.pubKey] ?: continue + sourceOf(wrap, plane, rumorId)?.let { return it } + } + return null + } + + /** The first reason [ctx] may not write at all, or null. */ + fun refusal(ctx: ConcordPinContext): ConcordPinOutcome? = + when { + !ctx.authorized -> ConcordPinOutcome.NOT_AUTHORIZED + !ctx.controlPlane.canWrite -> ConcordPinOutcome.NO_WRITE_KEY + // §7: a writer MUST NOT build an edition from a list it could not read. + ctx.pins.sealedUnavailable -> ConcordPinOutcome.LIST_UNAVAILABLE + ctx.channelIsPrivate && ctx.currentPlane == null -> ConcordPinOutcome.NO_CHANNEL_KEY + else -> null + } + + /** + * The entries a write starts from. A list sealed in a Channel's private era is never + * mechanically re-formed into the now-public form (§7): its entries are not carried, so a + * post-switch edition can only disclose what a curator pins deliberately. + */ + private fun base(ctx: ConcordPinContext): List = if (!ctx.channelIsPrivate && ctx.pins.sealedForm) emptyList() else ctx.pins.alive + + private suspend fun publish( + ctx: ConcordPinContext, + entries: List, + createdAt: Long, + ): ConcordPinWrite { + if (entries.size > ConcordPins.MAX_ENTRIES) return ConcordPinWrite(ConcordPinOutcome.TOO_MANY_PINS) + val content = + try { + val plane = ctx.currentPlane + if (ctx.channelIsPrivate && plane != null) { + ConcordPins.serializeSealed(entries, plane.key.conversationKey, plane.epoch) + } else { + ConcordPins.serializePublic(entries) + } + } catch (_: ConcordPins.PinListTooLargeException) { + // Every reader would treat an over-cap edition as an empty list: refuse, never publish it. + return ConcordPinWrite(ConcordPinOutcome.TOO_LARGE) + } + val wrap = + ConcordModeration.setPinList( + ctx.actor, + ctx.controlPlane, + ctx.communityId, + ctx.channelIdHex.hexToByteArray(), + ctx.pins.head, + content, + ctx.current, + createdAt, + owner = ctx.owner, + ) + return ConcordPinWrite(ConcordPinOutcome.PUBLISHED, wrap, entries) + } + + /** Pins [source]'s message: its proof entry prepended to the current list, as the next edition. */ + suspend fun pin( + ctx: ConcordPinContext, + source: ConcordPinSource, + createdAt: Long, + ): ConcordPinWrite { + refusal(ctx)?.let { return ConcordPinWrite(it) } + val base = base(ctx) + if (base.any { it.rumorId == source.rumorId }) return ConcordPinWrite(ConcordPinOutcome.ALREADY_PINNED) + if (base.size >= ConcordPins.MAX_ENTRIES) return ConcordPinWrite(ConcordPinOutcome.TOO_MANY_PINS) + val entry = + ConcordPins.buildEntry(source.opened, source.conversationKey, ctx.channelIdHex, source.wrapId) + ?: return ConcordPinWrite(ConcordPinOutcome.UNVERIFIABLE) + return publish(ctx, listOf(entry) + base.map { it.entry }, createdAt) + } + + /** Unpins [rumorId]: the next edition without it (there is no deletion event, §7). */ + suspend fun unpin( + ctx: ConcordPinContext, + rumorId: HexKey, + createdAt: Long, + ): ConcordPinWrite { + refusal(ctx)?.let { return ConcordPinWrite(it) } + val base = base(ctx) + val carried = base.any { it.rumorId == rumorId } || ctx.pins.killed.any { it.rumorId == rumorId } + if (!carried) return ConcordPinWrite(ConcordPinOutcome.NOT_PINNED) + return publish(ctx, base.filter { it.rumorId != rumorId }.map { it.entry }, createdAt) + } + + /** + * The deletion omission (§7): the list without [rumorIds] and without every entry already known + * erased. The pinner publishes it at once when deleting their own pinned message; the result is + * [ConcordPinOutcome.NOTHING_TO_DO] when the head no longer carries any of them. + */ + suspend fun omit( + ctx: ConcordPinContext, + rumorIds: Set, + createdAt: Long, + ): ConcordPinWrite { + refusal(ctx)?.let { return ConcordPinWrite(it) } + val base = base(ctx) + val keep = base.filter { it.rumorId !in rumorIds } + if (keep.size == base.size && ctx.pins.killed.isEmpty()) return ConcordPinWrite(ConcordPinOutcome.NOTHING_TO_DO) + return publish(ctx, keep.map { it.entry }, createdAt) + } + + /** + * Settles what the head owes keyless readers, in one replace-entire write (§7 Edits + deletion): + * drops every erased entry and attaches the newest provable Edit to each entry behind one. + * [editSource] reopens a held Edit's wrap for its proof; an Edit it cannot prove is skipped, and + * only an Edit newer than the entry's is ever attached, so a refresh never reverts one. + * [ConcordPinOutcome.NOTHING_TO_DO] when nothing is owed — the check a delayed witness re-runs + * after its random wait, so simultaneous curators collapse to one publisher. + */ + suspend fun settle( + ctx: ConcordPinContext, + editSource: (ConcordPinnedMessage) -> ConcordPinSource?, + createdAt: Long, + ): ConcordPinWrite { + refusal(ctx)?.let { return ConcordPinWrite(it) } + val base = base(ctx) + var changed = ctx.pins.killed.isNotEmpty() + val shownById = ctx.pins.pins.associateBy { it.rumorId } + val next = + base.map { pin -> + val shown = shownById[pin.rumorId] + val source = if (shown?.newerEdit != null) editSource(shown) else null + if (source == null) { + pin.entry + } else { + val withEdit = ConcordPins.withEdit(pin.entry, source.opened, source.conversationKey, ctx.channelIdHex) + if (withEdit != pin.entry) changed = true + withEdit + } + } + if (!changed) return ConcordPinWrite(ConcordPinOutcome.NOTHING_TO_DO) + return publish(ctx, next, createdAt) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt index 3bfa9f2b5a..dccbabcfc2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.cashu.ops -import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -28,7 +27,6 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent @@ -1094,19 +1092,11 @@ class CashuWalletOps( * pointing at the address coordinate (kind:pubkey:dTag) drops all * versions on compliant relays. We also include the original event id * via DeletionRequestEvent.build so relays that only track by event id still - * remove it. MintRecommendationEvent doesn't extend AddressableEvent - * today, so we compute and add the `a` tag ourselves. + * remove it. MintRecommendationEvent is an AddressableEvent, so + * DeletionRequestEvent.build writes that `a` tag itself. */ suspend fun deleteRecommendation(event: MintRecommendationEvent) { - // Add the `a` tag when we have a d-tag — kind:38000 is parameterized- - // replaceable, so the address coordinate lets compliant relays drop - // all versions, not just the specific id. Recommendations without a - // d-tag still get a NIP-09 `e`-only delete (the default build path). - val dTag = event.dTag() - val template = - DeletionRequestEvent.build(listOf(event)) { - if (dTag != null) aTag(Address(event.kind, event.pubKey, dTag)) - } + val template = DeletionRequestEvent.build(listOf(event)) val delEvent = signer.sign(template) publish(delEvent) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/publicChannels/concord/ConcordUnread.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/publicChannels/concord/ConcordUnread.kt index e1b694ccf7..4e4f6bb5f0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/publicChannels/concord/ConcordUnread.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/publicChannels/concord/ConcordUnread.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concordChannelLastReadRoute import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -109,12 +110,15 @@ fun concordCommunityHasUnreadFlow( * Messages hub row — reuses this so none of them can disagree with the open channel's feed: * a trailing comment can't stick the badge at a count the user can never clear, nor show up as a * "last message" that isn't in the timeline. Unacceptable (muted/blocked) authors are hidden for - * the same reason. + * the same reason — and so are expired disappearing messages (CORD-08 §3). + * + * A CORD-08 timer notice renders in the feed as a system line but is not a *message*: it neither + * counts as unread nor stands in as the channel's last message (its content is empty). */ fun isConcordTimelineMessage( note: Note, account: Account, -): Boolean = note.event.let { it != null && it !is CommentEvent } && account.isAcceptable(note) +): Boolean = note.event.let { it != null && it !is CommentEvent && it !is ConcordTimerNoticeEvent } && account.isAcceptable(note) /** * The newest timeline message in this channel (see [isConcordTimelineMessage]), or null if none — 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 20ec82acb0..7c17764ec3 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 @@ -190,6 +190,8 @@ import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent @@ -363,6 +365,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.sample @@ -739,10 +742,57 @@ class Account( ?.value ?.authority if (authority?.isBanned(rumor.pubKey) == true) return + // CORD-08 §3: an already-expired rumor is never stored. The session refuses it first; this + // backs it up for any other caller of the sink. + if (ConcordDisappearing.isExpired(rumor)) return registerConcordEncryptedImages(rumor) cache.consumeConcordRumor(communityId, channelIdHex, rumor, seenOnRelays) } + /** + * The CORD-08 §3 purge: drops every Concord rumor whose `expiration` has passed — its note, the + * note of the wrap that carried it, and the wrap in its session's buffer (so no re-projection can + * resurrect it). The rumor's own children (a reply, a reaction) are independent events and stay, + * as on a delete; they carry their own expiration when the timer was on. Scheduled on + * [ConcordSessionManager.nextExpiry], so it only ever runs when something is due. + */ + fun sweepExpiredConcordMessages(now: Long = TimeUtils.now()) { + val expired = concordSessions.sweepExpired(now) + for (rumors in expired.values) { + for (gone in rumors) { + cache.getNoteIfExists(gone.rumorId)?.let { note -> + note.detachFromChildren() + cache.pruner.unlinkAndRemove(note) + } + cache.getNoteIfExists(gone.wrapId)?.let { cache.pruner.unlinkAndRemove(it) } + } + } + } + + /** True for a Concord rumor whose own `expiration` has passed: never displayed (CORD-08 §3). */ + private fun isConcordExpired(note: Note): Boolean { + val event = note.event ?: return false + if (note.inGatherers?.any { it is ConcordChannel } != true) return false + return ConcordDisappearing.isExpired(event) + } + + /** + * True for a Concord timer notice (CORD-08 §4) that must not be shown: malformed, or authored by + * someone who does not hold MANAGE_METADATA in the community's current fold — anyone can spell + * the tag, only staff are believed about policy. + */ + private fun isUnbelievedConcordTimerNotice(note: Note): Boolean { + val event = note.event as? ConcordTimerNoticeEvent ?: return false + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return true + val authority = + concordSessions + .sessionFor(channel.channelId.communityId) + ?.state + ?.value + ?.authority ?: return true + return !ConcordDisappearing.isBelievedNotice(event, authority) + } + /** * Register any encrypted image attachments on a Concord message ([ChannelChat.encryptedImagesOf]) * so the shared media pipeline can display them: the ciphertext blob's AES-256-GCM key/nonce go @@ -3752,6 +3802,7 @@ class Account( override fun isAcceptable(note: Note): Boolean { if (isConcordBanned(note)) return false + if (isConcordExpired(note) || isUnbelievedConcordTimerNotice(note)) return false val mutedThreads = hiddenUsers.flow.value.mutedThreads if (mutedThreads.isNotEmpty() && mutedThreads.contains(resolveThreadRoot(note))) return false return note.author?.let { isAcceptable(it) } ?: true && @@ -4140,6 +4191,18 @@ class Account( } } + // CORD-08 §3: purge disappearing Concord messages when they expire. Sleeps until the earliest + // deadline any joined community holds and never wakes while nothing carries one, so a + // community without a timer costs nothing. A new earlier deadline restarts the wait. + scope.launch(Dispatchers.IO) { + concordSessions.nextExpiry.collectLatest { at -> + if (at == null) return@collectLatest + val waitMs = (at - TimeUtils.now()) * 1000 + if (waitMs > 0) delay(waitMs) + runCatching { sweepExpiredConcordMessages() }.onFailure { Log.w("Concord", "expired-message sweep failed", it) } + } + } + scope.launch { cache.antiSpam.flowSpam.collect { it.cache.spamMessages.snapshot().values.forEach { spammer -> diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index e1b0b1b309..1c9dad080e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -21,15 +21,25 @@ package com.vitorpamplona.amethyst.commons.model import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPins +import com.vitorpamplona.amethyst.commons.actions.ConcordLocalEdit import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.amethyst.commons.actions.ConcordPinContext +import com.vitorpamplona.amethyst.commons.actions.ConcordPinOutcome +import com.vitorpamplona.amethyst.commons.actions.ConcordPinWrite +import com.vitorpamplona.amethyst.commons.actions.ConcordPinning import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner +import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays import com.vitorpamplona.amethyst.commons.model.ConcordInviteResult import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filter import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteView +import com.vitorpamplona.amethyst.commons.model.concord.DirectInviteAcceptPlan import com.vitorpamplona.amethyst.commons.model.concordChannelLastReadRoute import com.vitorpamplona.amethyst.commons.util.ConcurrentSet import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode @@ -43,6 +53,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordListTooLargeExcep import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord03Channels.concordEpoch import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity @@ -56,6 +67,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding @@ -65,6 +77,7 @@ import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -90,9 +103,12 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -336,7 +352,8 @@ class AccountConcordActions( } /** - * Merges [patch] into the published Invite List and republishes it, returning whether it landed. + * Merges [patch] into the published Invite List and republishes it, returning the merged document + * when it landed and null when it did not. * * Read-merge-write, and **aborts rather than overwriting** when the read fails: the list is * replaceable, so publishing a patch-only document over an unread list deletes every other @@ -344,20 +361,62 @@ class AccountConcordActions( * rotation. A momentarily unreachable relay or a bunker signer that declines one decrypt is * enough to trigger that, which is exactly how the kind-13302 community list was once emptied. */ - private suspend fun publishConcordInviteList(patch: ConcordInviteListDocument): Boolean { + private suspend fun publishConcordInviteList(patch: ConcordInviteListDocument): ConcordInviteListDocument? { val publishTo = account.outboxRelays.flow.value - if (publishTo.isEmpty()) return false + if (publishTo.isEmpty()) return null val base = readConcordInviteList() ?: run { Log.w("Concord") { "Refusing to write the invite list: could not read the current one (would drop other links' signer_sk)" } - return false + return null } + val merged = ConcordInviteList.merge(base, patch) // publishAndConfirm, never publish: `INostrClient.publish` returns Unit — it queues the event // and never reports acceptance — so a `runCatching { publish(); true }` is true whenever // local signing worked, and every caller's "did the record land?" gate becomes decorative. - return runCatching { - account.client.publishAndConfirm(ConcordInviteListEvent.create(account.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()), publishTo) - }.onFailure { Log.w("Concord", "invite list publish failed", it) }.getOrDefault(false) + val landed = + runCatching { + account.client.publishAndConfirm(ConcordInviteListEvent.create(account.signer, merged, TimeUtils.now()), publishTo) + }.onFailure { Log.w("Concord", "invite list publish failed", it) }.getOrDefault(false) + return if (landed) merged else null + } + + /** + * Publishes this account's Invite Registry for [entry]'s community (CORD-05 §5, `vsk 8`): "a + * Registry edit accompanies every mint and every retire". The list is this account's honored + * registry plus the live links its Invite List [list] holds plus [minted], minus [retired] and + * minus every tombstoned or expired link ([ConcordInviteRegistry.nextLinks]), so an elapsed link + * stops keeping the community Public. Returns whether an edition was published. + * + * Best-effort, like the reference client's: the registry never gates a link working. It is + * skipped when there is no session to chain onto, when this account no longer holds + * CREATE_INVITE (every reader would drop the edition), when the `control_root` is not held + * (CORD-02 §2), and when the next list equals the one already honored. + */ + private suspend fun publishConcordInviteRegistry( + entry: ConcordCommunityListEntry, + list: ConcordInviteListDocument?, + minted: List = emptyList(), + retired: List = emptyList(), + ): Boolean { + val session = account.concordSessions.sessionFor(entry.id) ?: return false + if (!isAuthorizedFor(session, ConcordPermissions.CREATE_INVITE)) return false + val cp = controlKeysForWrite(session) ?: return false + val me = account.signer.pubKey + val state = session.state.value + val published = state?.registryOf(me).orEmpty() + val next = ConcordInviteRegistry.nextLinks(published, list, entry.id, TimeUtils.now(), minted, retired) + val hasHead = state?.inviteRegistries?.containsKey(me.lowercase()) == true + if (next == published.sorted() && (hasHead || next.isEmpty())) return false + // The writer chains off the same authorized head the fold honors (ConcordModeration.headOf). + val wrap = + try { + ConcordModeration.setInviteRegistry(account.signer, cp, entry.id.hexToByteArray(), next, session.controlEditions(), TimeUtils.now(), owner = entry.owner) + } catch (e: Exception) { + Log.w("Concord", "invite registry build failed for ${entry.id}", e) + return false + } + publishConcordWrap(entry, wrap) + return true } /** @@ -475,7 +534,8 @@ class AccountConcordActions( // was never stored can never be refreshed, so the next Refounding orphans it and everyone // holding it is stranded — with nothing to have warned them. Failing the mint is the honest // outcome; a stored entry for a link nobody received is harmless by comparison. - if (!publishConcordInviteList( + val recorded = + publishConcordInviteList( ConcordInviteListDocument( entries = listOf( @@ -489,12 +549,15 @@ class AccountConcordActions( ), ), ) - ) { + if (recorded == null) { Log.w("Concord") { "Invite not minted for ${entry.id}: its link signer could not be recorded, so the link could never be refreshed" } return null } if (publishTo.isNotEmpty()) account.client.publish(minted.bundleEvent, publishTo) + // The member-facing shadow of the list we just wrote (CORD-05 §5): the link now makes the + // community Public. Best-effort — the link works without it. + publishConcordInviteRegistry(entry, recorded, minted = listOf(minted.linkSignerPubKey)) return minted.url } @@ -533,24 +596,29 @@ class AccountConcordActions( * leave the link live with its signer gone and no way left to retire it. A failed list write is * recoverable — the link is already dead on the wire, and the refresh path re-mints only a * coordinate that still resolves Live. + * + * Every retire also edits this account's Invite Registry (CORD-05 §5). When the link was the + * community's last live one, retiring it flips the community Private — "a Refounding (CORD-06)" + * (CORD-05 §2) — so this then Refounds with nobody removed, provided this account may (it takes + * BAN). The result says which of those happened. */ suspend fun revokeConcordInvite( communityId: String, token: String, - ): Boolean { - if (!account.isWriteable()) return false + ): ConcordRevokeResult { + if (!account.isWriteable()) return ConcordRevokeResult.FAILED val entry = account.concordChannelList.liveCommunities.value - .firstOrNull { it.id == communityId } ?: return false + .firstOrNull { it.id == communityId } ?: return ConcordRevokeResult.FAILED val link = readConcordInviteList()?.entries?.firstOrNull { it.token == token && it.communityId == communityId } ?: run { Log.w("Concord") { "Cannot revoke $token: it is not in this account's invite list, so its link signer is unknown" } - return false + return ConcordRevokeResult.FAILED } val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } - if (relays.isEmpty()) return false + if (relays.isEmpty()) return ConcordRevokeResult.FAILED // Confirmed, not fire-and-forget. A `publish` that returns Unit would report success for a // tombstone no relay stored — and the list write below would then drop this entry on merge, // destroying the only `signer_sk` that could ever retire the link while the link stays live. @@ -558,14 +626,31 @@ class AccountConcordActions( runCatching { account.client.publishAndConfirm(ConcordActions.revokeBundleAt(link.signerSk.hexToByteArray(), TimeUtils.now()), relays) }.onFailure { Log.w("Concord", "invite revocation failed for $communityId", it) }.getOrDefault(false) - if (!published) return false + if (!published) return ConcordRevokeResult.FAILED - if (!publishConcordInviteList(ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = communityId))))) { + val signer = link.signerPubKeyHex().lowercase() + // Judged on the fold BEFORE our registry edit lands: afterwards the link is gone from it. + val privatizes = + account.concordSessions + .sessionFor(communityId) + ?.state + ?.value + ?.retiringWouldPrivatize(listOf(signer)) == true + + val recorded = publishConcordInviteList(ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = communityId)))) + if (recorded == null) { // The link is already dead on the wire, so this is bookkeeping we can retry rather than a // failed revocation. Reported as success for exactly that reason. Log.w("Concord") { "Revoked $token on the wire but could not tombstone it in the invite list; a later revoke will record it" } } - return true + publishConcordInviteRegistry(entry, recorded, retired = listOf(signer)) + + if (!privatizes) return ConcordRevokeResult.REVOKED + // The last live link is gone: the community is Private now, and whoever already fetched a + // link holds the current root. CORD-05 §2/§5: this is a Refounding (CORD-06 §3, "converting a + // Public Community to Private"), which re-keys the members and leaves the lurkers behind. + Log.i("Concord") { "Retired the last live invite link of $communityId: the community is Private, Refounding" } + return if (privatizeConcordCommunity(communityId)) ConcordRevokeResult.PRIVATIZED else ConcordRevokeResult.PRIVATIZED_REFOUND_PENDING } /** Leave a joined Concord community: drop it from the Community List and tombstone it (CORD-02 §8). */ @@ -625,6 +710,38 @@ class AccountConcordActions( InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable } + return joinValidatedConcordInvite( + bundle = bundle, + servedBy = relays, + // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a + // Refounding that leaves us out of the recipient set is recoverable later. See + // recoverStrandedConcordCommunities(). + inviteRef = ConcordActions.bareInviteRef(url), + // Invite attribution (CORD-05 §1): the joiner echoes the link's creator + label in their + // Guestbook Join, which is what makes per-link usage counters possible. + inviteCreator = bundle.creatorNpub, + inviteLabel = bundle.label, + ) + } + + /** + * The join half shared by every redeem path (link [joinConcordViaInvite], Direct Invite + * [acceptConcordDirectInvite]): [bundle] is already opened, bounded and owner-proof validated, + * and not expired. An already-held community only moves forward through a stranded rejoin (a + * Refounding left us behind and the user re-accepted); otherwise it refuses a community whose + * roster bans us (fails closed on an unreadable Control Plane, fetched over [servedBy] ∪ the + * bundle's relays), then stores the secret-bearing entry and announces the Guestbook Join with + * [inviteCreator]/[inviteLabel] attribution. + */ + private suspend fun joinValidatedConcordInvite( + bundle: CommunityInvite, + servedBy: Set, + inviteRef: String?, + inviteCreator: HexKey?, + inviteLabel: String?, + ): ConcordInviteResult { + val relays = servedBy + // Already a member? Just take the user to the community. Re-following and re-announcing a // Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an // old invite is reopened, so short-circuit to Joined — the screen forwards to the community @@ -686,15 +803,14 @@ class AccountConcordActions( return ConcordInviteResult.Banned } - // Invite attribution (CORD-05 §1): the joiner echoes the link's creator + label in their - // Guestbook Join, which is what makes per-link usage counters possible. - val inviteCreator = bundle.creatorNpub?.lowercase()?.takeIf { HEX64.matches(it) } - val inviteLabel = bundle.label?.takeIf { inviteCreator != null && it.isNotBlank() } + // Invite attribution (CORD-05 §1), echoed in the Guestbook Join; a label only rides with a creator. + val creator = inviteCreator?.lowercase()?.takeIf { HEX64.matches(it) } + val label = inviteLabel?.takeIf { creator != null && it.isNotBlank() } if (rejoined != null) { if (!adoptedConcordRotations.add("${rejoined.id}:${rejoined.rootEpoch}")) return ConcordInviteResult.Joined(bundle.communityId) Log.i("Concord") { "Stranded rejoin by explicit invite: ${rejoined.id} -> epoch ${rejoined.rootEpoch}" } - if (!joinConcordCommunity(rejoined, inviteCreator, inviteLabel, planeWraps)) return ConcordInviteResult.NotSaved + if (!joinConcordCommunity(rejoined, creator, label, planeWraps)) return ConcordInviteResult.NotSaved _strandedConcordCommunities.value -= rejoined.id return ConcordInviteResult.Joined(bundle.communityId) } @@ -715,15 +831,166 @@ class AccountConcordActions( relays = bundle.relays, name = bundle.name, addedAt = TimeUtils.nowMillis(), - // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a - // Refounding that leaves us out of the recipient set is recoverable later. See - // recoverStrandedConcordCommunities(). - inviteRef = ConcordActions.bareInviteRef(url), + // Anchor for stranded recovery (null for a Direct Invite, which has no link). + inviteRef = inviteRef, ) - if (!joinConcordCommunity(entry, inviteCreator, inviteLabel, planeWraps)) return ConcordInviteResult.NotSaved + if (!joinConcordCommunity(entry, creator, label, planeWraps)) return ConcordInviteResult.NotSaved return ConcordInviteResult.Joined(bundle.communityId) } + // ---- CORD-05 §6 Direct Invites --------------------------------------------- + + /** + * The Direct Invite inbox: wraps from the dedicated sweep ([refreshConcordDirectInvites]) and + * from the NIP-17 giftwrap pipeline land here, parked until the user accepts or declines. + */ + val directInviteInbox = ConcordDirectInviteInbox(account.signer) + + /** + * The parked Direct Invites a UI should show, newest first: invites for communities we don't + * hold, plus catch-ups for ones we do ([ConcordDirectInviteInbox.visible]). + */ + val pendingConcordDirectInvites: StateFlow> = + combine(directInviteInbox.pending, account.concordChannelList.liveCommunities) { pending, joined -> + ConcordDirectInviteInbox.visible(pending.values, joined) + }.stateIn(account.scope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** + * Where this account scans for Direct Invites — where senders deliver them (CORD-05 §6): our DM + * inbox relays (kind 10050, plus the NIP-65 read and private/local relays the DM feed already + * reads), else the stock Concord set. + */ + private fun concordDirectInviteScanRelays(): Set = + account.dmRelays.flow.value.ifEmpty { + ConcordActions.directInviteDeliveryRelays(null) + } + + /** + * Sweeps our inbox relays for Direct Invite wraps + * (`{"kinds":[1059],"#p":[me],"#k":["3313"]}` since the inbox cursor, rewound by NIP-59's backdate + * window) and offers each to the inbox. Returns how many new invites were parked. Read-only: it + * decrypts, it never joins or contacts a community's relays. + */ + suspend fun refreshConcordDirectInvites(): Int { + val relays = concordDirectInviteScanRelays() + if (relays.isEmpty()) return 0 + val before = directInviteInbox.pending.value.keys + val filter = ConcordActions.directInvitesFilter(account.signer.pubKey, directInviteInbox.since()) + val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }) + wraps.distinctBy { it.id }.forEach { directInviteInbox.offer(it) } + return (directInviteInbox.pending.value.keys - before).size + } + + /** + * The recipient's giftwrap inbox (CORD-05 §6): their kind-10050 DM relays, else NIP-65 read + * relays — from the cache when we have their lists, fetched otherwise — else the stock set. + */ + private suspend fun concordDirectInviteDeliveryRelays(recipient: HexKey): Set { + val user = account.cache.getOrCreateUser(recipient) + val dmInbox = user.dmInboxRelayList()?.relays().orEmpty() + val cached = + if (dmInbox.isNotEmpty() || user.authorRelayList() != null) { + RecipientRelayFetcher.Lists(dmInbox = dmInbox, keyPackage = emptyList(), nip65 = user.authorRelayList()) + } else { + null + } + val lists = + cached ?: run { + val seed = DefaultDmIndexerRelays.RELAYS.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + account.outboxRelays.flow.value + RecipientRelayFetcher.fetchRelayLists(account.client, recipient, seed) + } + return ConcordActions.directInviteDeliveryRelays(lists) + } + + /** + * Hands the keys of [communityId] straight to [recipientPubKey] as a Direct Invite (CORD-05 §6): + * the §1 bundle — carrying only the Private Channel keys the recipient's Roles entitle them to — + * sealed by our real key inside an ephemeral, `k`-tagged giftwrap, published to the recipient's + * inbox relays. It appears in no Registry and never flips the community Public; it cannot be + * revoked once it lands. [expiresAtMs] (unix ms) bounds its shelf life. + * + * No community permission gates it — none could (CORD-05 §6) — but a banned member is refused, + * like minting, and so is a banned recipient, whom the join would refuse anyway. + */ + suspend fun sendConcordDirectInvite( + communityId: String, + recipientPubKey: HexKey, + expiresAtMs: Long? = null, + ): ConcordDirectInviteSendResult { + if (!account.isWriteable()) return ConcordDirectInviteSendResult.NOT_WRITEABLE + val recipient = recipientPubKey.lowercase() + val entry = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } ?: return ConcordDirectInviteSendResult.NOT_MEMBER + // The fold decides which Private Channel keys the recipient may receive; no fold, no send. + val state = + account.concordSessions + .sessionFor(communityId) + ?.state + ?.value ?: return ConcordDirectInviteSendResult.ROSTER_NOT_LOADED + val invite = + when (val draft = ConcordActions.draftDirectInvite(entry, state, account.signer.pubKey, recipient, expiresAtMs)) { + is ConcordDirectInviteDraft.Refused -> return draft.reason + is ConcordDirectInviteDraft.Ready -> draft.invite + } + val wrap = ConcordActions.buildDirectInvite(account.signer, recipient, invite) + val relays = concordDirectInviteDeliveryRelays(recipient) + if (relays.isEmpty()) return ConcordDirectInviteSendResult.NOT_DELIVERED + val delivered = + runCatching { account.client.publishAndConfirm(wrap, relays) } + .onFailure { Log.w("Concord", "direct invite publish failed for $communityId", it) } + .getOrDefault(false) + return if (delivered) ConcordDirectInviteSendResult.SENT else ConcordDirectInviteSendResult.NOT_DELIVERED + } + + /** + * Accepts the parked Direct Invite [wrapId] (CORD-05 §6) through the same join path as a link: + * refused once `expires_at` has passed, refused when the roster bans us, and — for a community + * we already hold — only a catch-up adopting newly granted Private Channel keys on the same base. + * The Guestbook Join is attributed to the seal-verified sender. **Only from an explicit user + * action**: this is the first moment anything contacts the community's relays. + */ + suspend fun acceptConcordDirectInvite(wrapId: HexKey): ConcordInviteResult { + if (!account.isWriteable()) return ConcordInviteResult.InvalidLink + val opened = directInviteInbox.get(wrapId) ?: return ConcordInviteResult.InvalidLink + val bundle = opened.invite + val held = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id.equals(bundle.communityId, ignoreCase = true) } + val heldState = + held?.let { + account.concordSessions + .sessionFor(it.id) + ?.state + ?.value + } + val result = + when (val plan = ConcordDirectInviteInbox.acceptPlan(opened, held, heldState, account.signer.pubKey)) { + DirectInviteAcceptPlan.Expired -> ConcordInviteResult.Expired + DirectInviteAcceptPlan.Banned -> ConcordInviteResult.Banned + // No folded roster yet: whether it bans us is unknown, so the invite waits. + DirectInviteAcceptPlan.RosterNotLoaded -> ConcordInviteResult.NotReachable + DirectInviteAcceptPlan.NothingNew -> ConcordInviteResult.Joined(bundle.communityId) + // Keys only, on the held base: no second Guestbook Join. + is DirectInviteAcceptPlan.CatchUp -> + if (persistConcordEntry(plan.entry)) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable + DirectInviteAcceptPlan.Join -> + joinValidatedConcordInvite( + bundle = bundle, + servedBy = emptySet(), + inviteRef = null, + // Attributed to the seal-verified sender (Armada), never the bundle's claim. + inviteCreator = opened.sender, + inviteLabel = bundle.label, + ) + } + if (result is ConcordInviteResult.Joined) directInviteInbox.resolve(opened.wrapId) + return result + } + + /** Declines the parked Direct Invite [wrapId]: its keys are discarded and it never resurfaces. */ + fun declineConcordDirectInvite(wrapId: HexKey): Boolean = directInviteInbox.decline(wrapId) + /** * Post [text] to a Concord channel: derive the channel plane key, build an * encrypted-seal kind-1059 wrap authored by that plane key (not our identity), @@ -757,19 +1024,21 @@ class AccountConcordActions( .toTypedArray() val parent = replyTo?.event + // CORD-08 §2: the community timer as folded right now rides inside the signed rumor. + val timer = session.messageExpirationSecs() val wrap = when { // A minichat reply is a kind-1111 thread comment (carrying encrypted image imetas when // the user attached media); an inline reply is a kind-9 message quoting the parent; a // fresh post is a plain kind-9 message. parent != null && replyMode == ReplyMode.MINICHAT && imetas.isNotEmpty() -> - ConcordActions.buildChannelImageReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, imetas, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelImageReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, imetas, TimeUtils.now(), emojiTags, timer) parent != null && replyMode == ReplyMode.MINICHAT -> - ConcordActions.buildChannelReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags, timer) parent != null -> - ConcordActions.buildChannelInlineReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelInlineReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags, timer) else -> - ConcordActions.buildChannelMessage(account.signer, channelKey, channelIdHex, plane.epoch, text, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelMessage(account.signer, channelKey, channelIdHex, plane.epoch, text, TimeUtils.now(), emojiTags, timer) } sendConcordChannelWrap(entry, channelKey, wrap) return true @@ -799,7 +1068,7 @@ class AccountConcordActions( .findEmojiTags(text) .map { it.toTagArray() } .toTypedArray() - val wrap = ConcordActions.buildChannelImageMessage(account.signer, channelKey, channelIdHex, plane.epoch, text, imetas, TimeUtils.now(), emojiTags) + val wrap = ConcordActions.buildChannelImageMessage(account.signer, channelKey, channelIdHex, plane.epoch, text, imetas, TimeUtils.now(), emojiTags, session.messageExpirationSecs()) sendConcordChannelWrap(entry, channelKey, wrap) return true } @@ -833,7 +1102,7 @@ class AccountConcordActions( .findEmojiTags(reaction) .map { it.toTagArray() } .toTypedArray() - val wrap = ConcordActions.buildChannelReaction(account.signer, channelKey, channelIdHex, plane.epoch, target, reaction, TimeUtils.now(), emojiTags) + val wrap = ConcordActions.buildChannelReaction(account.signer, channelKey, channelIdHex, plane.epoch, target, reaction, TimeUtils.now(), emojiTags, session.messageExpirationSecs()) publishConcordWrap(entry, wrap) return true } @@ -871,7 +1140,7 @@ class AccountConcordActions( .findEmojiTags(newText) .map { it.toTagArray() } .toTypedArray() - val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, plane.epoch, target, newText, TimeUtils.now(), emojiTags) + val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, plane.epoch, target, newText, TimeUtils.now(), emojiTags, session.messageExpirationSecs()) publishConcordWrap(entry, wrap) return true } @@ -919,6 +1188,10 @@ class AccountConcordActions( publishConcordWrap(session.entry, wrap) sent = true } + // Self-erasure outranks curation (CORD-04 §7): the delete hides a pinned entry for tracking + // members at once, but a future member learns of it only through an omitting edition. The + // author knows their own pins, so when they may write pins they publish it immediately. + if (sent) omitDeletedConcordPins(channel.channelId.communityId, channelIdHex, mine.mapTo(HashSet()) { it.id }) return sent } @@ -1267,7 +1540,17 @@ class AccountConcordActions( return if (canBan) communityId to author else null } - /** Add [member] to the community banlist. */ + /** + * Ban [member] (CORD-04 §5 composition): the Banlist edition first, then — only when the + * community is **Private** — the Refounding (CORD-06 §3). A Public ban is the Banlist alone + * (CORD-05 §5): anyone holding a live link can fetch a rotated root straight back out of its + * bundle, so rotating would cost every member a rekey and sever nobody. The mode is judged with + * the target's own links left out, since the ban stops honoring their registry + * ([com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState.banRequiresRefounding]). + * + * Returns whether the ban landed; a Refounding that fails is logged and can be retried with + * [refoundConcordCommunity]. + */ suspend fun banConcordMember( communityId: String, member: HexKey, @@ -1277,6 +1560,13 @@ class AccountConcordActions( val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) + // Judged on the fold that now carries the ban (publishConcordWrap ingests it first). + val state = session.state.value + if (state != null && state.banRequiresRefounding(listOf(member))) { + if (!refoundConcordCommunity(communityId, setOf(member))) { + Log.w("Concord") { "Banned $member from the Private community $communityId, but its Refounding did not complete" } + } + } return true } @@ -1293,6 +1583,168 @@ class AccountConcordActions( return true } + // ── Concord pins (CORD-04 §7) ───────────────────────────────────────────── + // A Channel's Pin List rides the Control Plane as one replace-entire edition (vsk 11) of + // self-proving entries. The read side verifies every entry and applies what this client holds + // (deletes hide, newer Edits mark "edited"); the write side is ConcordPinning, gated here on + // PIN_MESSAGES + the control write key and serialized so two quick writes never drop each other. + + /** Serializes pin writes: each replaces the list entire, so two in flight would lose one. */ + private val concordPinMutex = Mutex() + + /** Owner, or a PIN_MESSAGES holder per the fold (hasPermission, so a banned holder is not). Silent: UI gating asks this often. */ + private fun holdsConcordPinBit(session: ConcordCommunitySession): Boolean { + val me = account.signer.pubKey + if (session.entry.owner.equals(me, ignoreCase = true)) return true + return session.state.value + ?.authority + ?.hasPermission(me, ConcordPermissions.PIN_MESSAGES) == true + } + + /** True when this account may write [communityId]'s Pin Lists now: the bit, the control write key, a signer. */ + fun canPinConcord(communityId: String): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + return account.isWriteable() && holdsConcordPinBit(session) && session.controlPlaneKeys().canWrite + } + + /** + * [channelIdHex]'s verified pins, read from the current head: sealed lists open with the held + * key of their epoch (else [ConcordChannelPins.sealedUnavailable]), an entry its author deleted + * is hidden by the delete this account holds for the recomputed rumor id, and an entry behind a + * newer held Edit is marked edited. Null until the community has folded the channel. + */ + fun concordChannelPins( + communityId: String, + channelIdHex: String, + ): ConcordChannelPins? { + val session = account.concordSessions.sessionFor(communityId) ?: return null + return session.readPins( + channelIdHex, + isKilled = { account.cache.deletionIndex.hasBeenDeleted(it.rumorId, it.author) }, + newestEdit = { heldConcordEdit(it.rumorId, it.author) }, + ) + } + + /** The author's newest Concord Edit this account holds for [rumorId], or null. */ + private fun heldConcordEdit( + rumorId: HexKey, + author: HexKey, + ): ConcordLocalEdit? { + val edit = + account.cache + .getNoteIfExists(rumorId) + ?.latestConcordEdit() + ?.event as? ConcordChatEditEvent ?: return null + if (edit.pubKey != author) return null + return ConcordLocalEdit(edit.id, edit.pubKey, edit.content, edit.orderingMs()) + } + + /** + * For the message action sheet: null when [note] is not a pinnable Concord message or this + * account cannot write pins there; else whether it is pinned now. + */ + fun concordPinState(note: Note): Boolean? { + val event = note.event ?: return null + if (event !is ChatEvent && event !is CommentEvent) return null + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null + if (!canPinConcord(channel.channelId.communityId)) return null + val pins = concordChannelPins(channel.channelId.communityId, channel.channelId.channelId) ?: return null + return pins.isPinned(note.idHex) + } + + /** + * Runs one pin write: re-reads the list inside the lock (the previous write was echoed into the + * session, so this chains onto it), resolves the context, and publishes the edition [op] builds. + */ + private suspend fun writeConcordPins( + communityId: String, + channelIdHex: String, + op: suspend (ConcordCommunitySession, ConcordPinContext) -> ConcordPinWrite, + ): ConcordPinOutcome = + concordPinMutex.withLock { + if (!account.isWriteable()) return@withLock ConcordPinOutcome.NOT_WRITEABLE + val session = account.concordSessions.sessionFor(communityId) ?: return@withLock ConcordPinOutcome.NOT_FOLDED + val definition = + session.state.value + ?.channels + ?.get(channelIdHex) + ?.definition ?: return@withLock ConcordPinOutcome.NOT_FOLDED + val pins = concordChannelPins(communityId, channelIdHex) ?: return@withLock ConcordPinOutcome.NOT_FOLDED + val ctx = + ConcordPinContext( + actor = account.signer, + controlPlane = session.controlPlaneKeys(), + communityId = communityId.hexToByteArray(), + owner = session.entry.owner, + current = session.controlEditions(), + channelIdHex = channelIdHex, + channelIsPrivate = definition.private, + currentPlane = session.currentChannelPlane(channelIdHex), + pins = pins, + authorized = holdsConcordPinBit(session), + ) + val write = op(session, ctx) + write.wrap?.let { publishConcordWrap(session.entry, it) } + write.outcome + } + + /** Pin Concord message [note] into its channel's Pin List, proving it with its original seal. */ + suspend fun pinConcordMessage(note: Note): ConcordPinOutcome { + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return ConcordPinOutcome.NOT_FOLDED + val channelIdHex = channel.channelId.channelId + return writeConcordPins(channel.channelId.communityId, channelIdHex) { session, ctx -> + val refused = ConcordPinning.refusal(ctx) + val source = if (refused == null) session.pinSource(channelIdHex, note.idHex) else null + when { + refused != null -> ConcordPinWrite(refused) + source == null -> ConcordPinWrite(ConcordPinOutcome.MESSAGE_UNAVAILABLE) + else -> ConcordPinning.pin(ctx, source, TimeUtils.now()) + } + } + } + + /** Unpin Concord message [note]. */ + suspend fun unpinConcordMessage(note: Note): ConcordPinOutcome { + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return ConcordPinOutcome.NOT_FOLDED + return unpinConcordRumor(channel.channelId.communityId, channel.channelId.channelId, note.idHex) + } + + /** Unpin the entry whose recomputed rumor id is [rumorId] — works for a pin whose message this account never held. */ + suspend fun unpinConcordRumor( + communityId: String, + channelIdHex: String, + rumorId: HexKey, + ): ConcordPinOutcome = writeConcordPins(communityId, channelIdHex) { _, ctx -> ConcordPinning.unpin(ctx, rumorId, TimeUtils.now()) } + + /** The pinner-style deletion omission: the list without [rumorIds], published now when this account may write pins. */ + private suspend fun omitDeletedConcordPins( + communityId: String, + channelIdHex: String, + rumorIds: Set, + ) { + if (!canPinConcord(communityId)) return + val pins = concordChannelPins(communityId, channelIdHex) ?: return + if (pins.alive.none { it.rumorId in rumorIds } && pins.killed.none { it.rumorId in rumorIds }) return + writeConcordPins(communityId, channelIdHex) { _, ctx -> ConcordPinning.omit(ctx, rumorIds, TimeUtils.now()) } + } + + /** + * Settle what [channelIdHex]'s head owes keyless readers (CORD-04 §7): drop entries their author + * erased and attach the newest provable Edit to entries behind one. The caller waits + * [ConcordPinning.dutyDelayMs] first; this re-reads the head and publishes only if it is still + * owed, so simultaneous curators collapse to one publisher and a burst of edits costs one write. + */ + suspend fun settleConcordPins( + communityId: String, + channelIdHex: String, + ): ConcordPinOutcome { + if (!canPinConcord(communityId)) return ConcordPinOutcome.NOT_AUTHORIZED + if (concordChannelPins(communityId, channelIdHex)?.owesRepublish != true) return ConcordPinOutcome.NOTHING_TO_DO + return writeConcordPins(communityId, channelIdHex) { session, ctx -> + ConcordPinning.settle(ctx, { pinned -> pinned.newerEdit?.let { session.pinSource(channelIdHex, it.rumorId) } }, TimeUtils.now()) + } + } + // ── Concord refounding / rekey (CORD-06) ────────────────────────────────── // A ban is a soft removal — the banned member still holds the room key and can // still decrypt traffic; every client just declines to *show* their posts. A @@ -1313,6 +1765,22 @@ class AccountConcordActions( suspend fun refoundConcordCommunity( communityId: String, removed: Set, + ): Boolean { + if (removed.isEmpty()) return false + return refound(communityId, removed) + } + + /** + * Converts the community to Private (CORD-06 §3): a Refounding with nobody removed, run when its + * last live invite link is retired (CORD-05 §2/§5). Every member is re-keyed; whoever only ever + * fetched a link — and so holds the current root without being a member — is left behind. + * Takes BAN (or ownership), like any Refounding. + */ + suspend fun privatizeConcordCommunity(communityId: String): Boolean = refound(communityId, emptySet()) + + private suspend fun refound( + communityId: String, + removed: Set, ): Boolean { if (!account.isWriteable()) return false val session = account.concordSessions.sessionFor(communityId) ?: return false @@ -1331,7 +1799,7 @@ class AccountConcordActions( val iCanBan = authority.isOwner(account.signer.pubKey) || authority.hasPermission(account.signer.pubKey, ConcordPermissions.BAN) if (!iCanBan) return false val removedLower = removed.mapTo(HashSet()) { it.lowercase() } - if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false + if (removedLower.any { authority.isOwner(it) }) return false // Removal is the hardest form of a ban, so it takes the same rank rule (CORD-04 §3): an admin // cannot Refound a peer admin out of the community any more than they could ban one. The owner // short-circuits, as everywhere else, because canActOn starts at hasPermission. @@ -1361,7 +1829,10 @@ class AccountConcordActions( // 1. Ban the removed members on the current Control Plane so the compacted snapshot — // and thus the new epoch — carries the ban. publishConcordWrap folds it in locally // first, so each subsequent edition chains onto the updated banlist head. + // A target the fold already bans (a ban that is composing its Refounding) needs no second edition. + val alreadyBanned = authority.bannedMembers().mapTo(HashSet()) { it.lowercase() } for (target in removedLower) { + if (target in alreadyBanned) continue val banWrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, banWrap) } @@ -1804,6 +2275,45 @@ class AccountConcordActions( return true } + /** + * Set [communityId]'s disappearing-messages timer to [secs] seconds, or turn it off when null or + * below 1 (CORD-08 §1): a metadata edition under MANAGE_METADATA, laid over the folded metadata so + * nothing else changes. Then, as §4 asks, one kind-1740 timer notice goes into every channel whose + * key this account holds (a Private Channel without one simply gets none). Returns false when + * nothing was published (not authorized, no Control write key, or the timer is already [secs]). + */ + suspend fun setConcordMessageExpiration( + communityId: String, + secs: Long?, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_METADATA) ?: return false + val timer = secs?.takeIf { it >= 1 } + val standing = session.state.value?.metadata ?: MetadataEntity() + if (standing.messageExpirationSecs() == timer) return false + val wrap = ConcordModeration.setMessageExpiration(account.signer, cp, communityId.hexToByteArray(), standing, timer, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + publishConcordWrap(session.entry, wrap) + postConcordTimerNotices(session, timer ?: 0) + return true + } + + /** One CORD-08 §4 timer notice per channel of [session] this account can write. */ + private suspend fun postConcordTimerNotices( + session: ConcordCommunitySession, + timerSecs: Long, + ) { + val channels = + session.state.value + ?.channels + ?.keys ?: return + val now = TimeUtils.now() + for (channelIdHex in channels) { + val plane = session.currentChannelPlane(channelIdHex) ?: continue + publishConcordWrap(session.entry, ConcordActions.buildChannelTimerNotice(account.signer, plane.key, channelIdHex, plane.epoch, timerSecs, now)) + } + } + /** * Dissolve [communityId] for good (CORD-02 §9): publish the owner-signed, `eid`-bound tombstone * at the community's dissolved address. Owner-only — every verifier ignores anyone else's — and diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt new file mode 100644 index 0000000000..81f93237a7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordDirectInviteSendResult.kt @@ -0,0 +1,58 @@ +/* + * 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 com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite + +/** A Direct Invite bundle ready to wrap, or why this account may not send one (see `ConcordActions.draftDirectInvite`). */ +sealed interface ConcordDirectInviteDraft { + class Ready( + val invite: CommunityInvite, + ) : ConcordDirectInviteDraft + + class Refused( + val reason: ConcordDirectInviteSendResult, + ) : ConcordDirectInviteDraft +} + +/** The outcome of sending a Concord Direct Invite (CORD-05 §6), so the UI can say why it failed. */ +enum class ConcordDirectInviteSendResult { + /** At least one of the recipient's inbox relays accepted the wrap. */ + SENT, + + /** This account can't sign (read-only key). */ + NOT_WRITEABLE, + + /** The recipient isn't a valid 32-byte pubkey. */ + INVALID_RECIPIENT, + + /** We don't hold this community, it was dissolved, or its roster bans us. */ + NOT_MEMBER, + + /** The community's Control Plane hasn't folded yet, so which keys the recipient may receive is unknown. */ + ROSTER_NOT_LOADED, + + /** The community's roster bans the recipient; their join would be refused anyway. */ + RECIPIENT_BANNED, + + /** No inbox relay accepted the wrap. */ + NOT_DELIVERED, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordRevokeResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordRevokeResult.kt new file mode 100644 index 0000000000..64311ef215 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ConcordRevokeResult.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.amethyst.commons.model + +/** + * The outcome of retiring an invite link (CORD-05 §2). Retiring the **last** live link flips the + * community Private, which is a Refounding (CORD-05 §5, CORD-06 §3); the two `PRIVATIZED` outcomes + * say whether that Refounding happened. + */ +enum class ConcordRevokeResult { + /** The link could not be retired (nothing changed on the wire). */ + FAILED, + + /** The link is retired; other live links keep the community Public (or it was already Private). */ + REVOKED, + + /** The last live link is retired and the community was Refounded, so it is Private now. */ + PRIVATIZED, + + /** + * The last live link is retired, so the community reads Private, but the Refounding did not run: + * this account cannot Refound (it takes BAN) or the rotation failed. Someone holding BAN must + * rotate the keys, or whoever already fetched a link keeps the current root. + */ + PRIVATIZED_REFOUND_PENDING, + + ; + + val revoked: Boolean get() = this != FAILED +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt index ccea35dc44..5ca3c6f8f7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.chatMessageMarksRoomAsRead import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor @@ -536,6 +537,17 @@ class SealEventHandler( ) { val innerRumor = event.unsealOrNull(account.signer) ?: return + // A Concord Direct Invite (CORD-05 §6) is a standard NIP-59 giftwrap, so the DM inbox sees + // it too — tagged `k=3313` or not. It is not a DM: its rumor carries a community's keys. Hand + // the seal to the Concord invite inbox, which re-opens it with the NIP-59 anti-spoofing check + // the generic unseal skips and parks it for the user, and keep the rumor out of the cache and + // every chat feed. Must run before the seal's content is stripped below. + if (innerRumor.kind == ConcordDirectInvite.KIND) { + account.concord.directInviteInbox.offerSeal(publicNote.event ?: event, event) + eventNote.event = event.copyNoContent() + return + } + eventNote.event = event.copyNoContent() cache.justConsume(innerRumor, null, true) 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 8fb3e1e39f..ee093b66e5 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 @@ -141,6 +141,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFrag import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmToolsListEvent import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent @@ -981,7 +982,9 @@ open class EventCache : // so the note already carries its ConcordChannel gatherer when it flows through // the Messages-list incremental filter (which routes rows by that gatherer). val messageRow = - if (rumor is ChatEvent || rumor is CommentEvent) { + // A CORD-08 timer notice is a channel row too: the inline "… set disappearing messages" line + // (the feed shows it only when its author holds MANAGE_METADATA, see Account.isAcceptable). + if (rumor is ChatEvent || rumor is CommentEvent || rumor is ConcordTimerNoticeEvent) { val ch = getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)) val note = getOrCreateNote(rumor.id) // Skip attaching a row for a message we already know is deleted (its kind-5 delete @@ -3930,6 +3933,10 @@ open class EventCache : is PublicationContentEvent, is RelayReviewEvent, is EntityRatingEvent, + // NIP-87 mint announcements and recommendations (kind 38172 / 38173 / 38000). + is CashuMintEvent, + is FedimintEvent, + is MintRecommendationEvent, -> consumeBaseReplaceable(event, relay, wasVerified) // ============================================================ @@ -3946,16 +3953,6 @@ open class EventCache : is CashuTokenEvent, is CashuSpendingHistoryEvent, is CashuMintQuoteEvent, - // NIP-87 Cashu mint discovery + recommendations: all three are kind 3xxxx - // (parameterized-replaceable per the spec) but neither CashuMintEvent / - // FedimintEvent / MintRecommendationEvent extends AddressableEvent in Quartz - // today, so consumeBaseReplaceable's `check(event is AddressableEvent)` would - // crash. Route them as regular events — downstream consumers - // (CashuMintDirectoryState, CashuWalletState) already dedupe by (pubKey, dTag) - // and keep the newest. - is CashuMintEvent, - is FedimintEvent, - is MintRecommendationEvent, is ChatMessageEncryptedFileHeaderEvent, is ChatMessageEvent, is BirdDetectionEvent, @@ -4029,6 +4026,7 @@ open class EventCache : is WakeUpEvent, is WelcomeEvent, is WorkoutRecordEvent, + is ConcordTimerNoticeEvent, -> consumeRegularEvent(event, relay, wasVerified) else -> { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 864f4e808d..b736c47363 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -22,6 +22,11 @@ package com.vitorpamplona.amethyst.commons.model.concord import com.vitorpamplona.amethyst.commons.actions.ChannelPlane import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPins +import com.vitorpamplona.amethyst.commons.actions.ConcordLocalEdit +import com.vitorpamplona.amethyst.commons.actions.ConcordPinSource +import com.vitorpamplona.amethyst.commons.actions.ConcordPinVerifier +import com.vitorpamplona.amethyst.commons.actions.ConcordPinning import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry @@ -29,9 +34,12 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor +import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPinLists +import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPins import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey @@ -40,6 +48,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -54,6 +63,18 @@ import kotlin.concurrent.Volatile */ typealias ConcordRumorSink = (communityId: HexKey, channelIdHex: HexKey, rumor: Event, seenOnRelays: Set) -> Unit +/** + * A disappearing Chat rumor (CORD-08) a session tracks: the [rumorId] carried by wrap [wrapId] on + * [channelIdHex], gone at [expiresAt] (unix seconds). Returned by a sweep once expired, so the + * store drops both notes. + */ +data class ExpiredConcordRumor( + val channelIdHex: HexKey, + val wrapId: HexKey, + val rumorId: HexKey, + val expiresAt: Long, +) + /** * The result of feeding one wrap to a session's [ConcordCommunitySession.ingest]. It separates * "was it ours" from "did it change structure", so only structure-changing wraps bump the session @@ -250,6 +271,14 @@ class ConcordCommunitySession( private val historicalControlWraps = HashMap>() private val channelWrapsById = HashMap>() // channelIdHex -> (wrapId -> wrap) + + // Chat rumor id -> the id of the wrap that carried it, filled as each wrap is emitted. A pin + // proves its message with the ORIGINAL kind-20013 seal (CORD-04 §7), which only the wrap holds, + // so pinning reopens that wrap rather than re-deriving anything from the stored rumor. + private val wrapIdByRumorId = HashMap() + + /** Pin-entry verdicts memoized by entry identity (CORD-04 §7 Weight). */ + private val pinVerifier = ConcordPinVerifier() private val guestbookWraps = LinkedHashMap() private val baseRekeyWraps = LinkedHashMap() private val siblingRekeyWraps = LinkedHashMap() @@ -272,6 +301,15 @@ class ConcordCommunitySession( private val _state = MutableStateFlow(null) val state: StateFlow = _state + private val _pinHeads = MutableStateFlow>(emptyMap()) + + /** + * The authorized head of each folded Channel's Pin List (CORD-04 §7), by channel id, re-derived + * on every control fold. Kept apart from [state] because a pin edition changes no field of the + * folded community, so [state] would not re-emit for it. + */ + val pinHeads: StateFlow> = _pinHeads + private val _members = MutableStateFlow>(emptySet()) /** The live Guestbook membership set (self-signed joins minus later leaves). */ @@ -660,13 +698,9 @@ class ConcordCommunitySession( val newChannels = lock.withLock { val wraps = controlWraps.values.toList() - val folded = - ConcordCommunityState.fold( - editionsLocked(wraps, controlKeys), - communityIdBytes, - entry.owner, - controlFloorsLocked(), - ) + val editions = editionsLocked(wraps, controlKeys) + val floors = controlFloorsLocked() + val folded = ConcordCommunityState.fold(editions, communityIdBytes, entry.owner, floors) val prevAddresses = channelKeysByAddress.keys.toHashSet() val next = HashMap() @@ -687,6 +721,7 @@ class ConcordCommunitySession( derivedPrivateKeys = privateKeySet(entry) _state.value = folded.withDissolved(dissolved) + _pinHeads.value = ConcordPinLists.heads(editions, folded.authority, entry.id, folded.channels.keys, floors) next.filterKeys { it !in prevAddresses }.values.map { it.channelIdHex } } @@ -776,7 +811,19 @@ class ConcordCommunitySession( seenOnRelays: Set = emptySet(), ) { val authors = HashSet() - ConcordActions.channelRumors(wraps, key, channelIdHex, epoch).forEach { rumor -> + val now = TimeUtils.now() + for (wrap in wraps) { + val rumor = ConcordActions.openChannelRumorAnyExpiry(wrap, key, channelIdHex, epoch) ?: continue + // Pins reopen the carrying wrap to disclose this one message's keys (CORD-04 §7). + lock.withLock { wrapIdByRumorId[rumor.id] = wrap.id } + // CORD-08 §3: only the rumor's own tag counts. A rumor carrying one is remembered so the + // sweep purges it (and its wrap) when it expires; one already expired is refused here — + // never handed to the store — and queued for the next sweep so its wrap goes too. + val expiresAt = ConcordDisappearing.expirationOf(rumor) + if (expiresAt != null) { + trackExpiring(wrap.id, channelIdHex, rumor.id, expiresAt) + if (expiresAt <= now) continue + } authors.add(rumor.pubKey.lowercase()) onRumor(entry.id, channelIdHex, rumor, seenOnRelays) } @@ -787,6 +834,122 @@ class ConcordCommunitySession( } } + // ── Disappearing messages (CORD-08) ────────────────────────────────────── + + /** Wrap id -> the expiring rumor it carries, for every rumor with an `expiration` we emitted or refused. */ + private val expiringByWrapId = HashMap() + + private val _nextExpiry = MutableStateFlow(null) + + /** + * The earliest `expiration` (unix seconds) among the rumors this session holds, or null when none + * expires — what the account's sweep schedules itself on, so a community with no timer costs + * nothing. At or before now when an expired rumor was just refused and its wrap awaits the sweep. + */ + val nextExpiry: StateFlow = _nextExpiry + + /** + * The disappearing-messages timer (seconds) a compliant sender attaches to its next durable Chat + * rumor, read from the current fold at send time (CORD-08 §2), or null when off or not folded. + */ + fun messageExpirationSecs(): Long? = _state.value?.metadata?.messageExpirationSecs() + + private fun trackExpiring( + wrapId: HexKey, + channelIdHex: HexKey, + rumorId: HexKey, + expiresAt: Long, + ) { + lock.withLock { + expiringByWrapId[wrapId] = ExpiredConcordRumor(channelIdHex, wrapId, rumorId, expiresAt) + _nextExpiry.update { if (it == null || expiresAt < it) expiresAt else it } + } + } + + /** + * Forgets every rumor whose `expiration` is at or before [now] (CORD-08 §3): its wrap leaves the + * channel buffer, so no re-projection can resurrect it, and it is returned so the caller purges + * the rumor's note and the wrap's note from its store. A wrap re-delivered later is refused again + * at ingest. + */ + fun sweepExpired(now: Long = TimeUtils.now()): List = + lock.withLock { + if (expiringByWrapId.isEmpty()) return@withLock emptyList() + val out = ArrayList() + val it = expiringByWrapId.values.iterator() + while (it.hasNext()) { + val expiring = it.next() + if (expiring.expiresAt <= now) { + channelWrapsById[expiring.channelIdHex]?.remove(expiring.wrapId) + wrapIdByRumorId.remove(expiring.rumorId) + out.add(expiring) + it.remove() + } + } + _nextExpiry.value = expiringByWrapId.values.minOfOrNull { it.expiresAt } + out + } + + /** True while [channelIdHex]'s buffer holds [wrapId] — for tests of the sweep. */ + internal fun isBuffered( + channelIdHex: HexKey, + wrapId: HexKey, + ): Boolean = lock.withLock { channelWrapsById[channelIdHex]?.containsKey(wrapId) == true } + + // ---- Pins (CORD-04 §7) ------------------------------------------------------------------ + + /** + * The Channel's conversation key at [epoch] for opening a sealed Pin List, or null when this + * account holds no plane of [channelIdHex] bound to that epoch. + */ + fun pinUnsealKey( + channelIdHex: HexKey, + epoch: Long, + ): ByteArray? = channelPlaneFor(channelIdHex, epoch)?.key?.conversationKey + + /** + * [channelIdHex]'s Pin List read from its current head: sealed form opened with the held key of + * the named epoch, entries verified (memoized), [isKilled] entries hidden, [newestEdit] applied. + * Null until the Control Plane has folded, so an unfolded community is never mistaken for one + * with no pins. + */ + fun readPins( + channelIdHex: HexKey, + isKilled: (ConcordPins.VerifiedPin) -> Boolean = { false }, + newestEdit: (ConcordPins.VerifiedPin) -> ConcordLocalEdit? = { null }, + now: Long = TimeUtils.now(), + ): ConcordChannelPins? { + val state = _state.value ?: return null + if (channelIdHex !in state.channels) return null + // An expired message leaves the pinned list too (CORD-08 §3: never display an expired rumor); + // its proof is still valid, but the rumor's own tag says it is gone. + val hidden = { pin: ConcordPins.VerifiedPin -> isKilled(pin) || pin.tags.isExpirationBefore(now) } + return ConcordPinning.read(_pinHeads.value[channelIdHex], channelIdHex, { pinUnsealKey(channelIdHex, it) }, pinVerifier, hidden, newestEdit) + } + + /** + * The proof source for pinning [rumorId] of [channelIdHex] (or for attaching an Edit): the wrap + * that carried it, reopened on the plane it arrived on so the disclosure derives from the key of + * the message's own epoch. Null when this session never held that wrap. + */ + fun pinSource( + channelIdHex: HexKey, + rumorId: HexKey, + ): ConcordPinSource? { + val (wrap, plane) = + lock.withLock { + val wrapId = wrapIdByRumorId[rumorId] ?: return null + val wrap = channelWrapsById[channelIdHex]?.get(wrapId) ?: return null + val plane = channelKeysByAddress[wrap.pubKey] ?: historicalChannelKeysByAddress[wrap.pubKey] ?: return null + wrap to plane + } + if (plane.channelIdHex != channelIdHex) return null + return ConcordPinning.sourceOf(wrap, plane, rumorId) + } + + /** True when this session holds the wrap that carried [rumorId] (jump-to-context resolves locally). */ + fun holdsRumor(rumorId: HexKey): Boolean = lock.withLock { rumorId in wrapIdByRumorId } + companion object { private fun privateKeySet(e: ConcordCommunityListEntry) = e.privateChannels.mapTo(HashSet()) { Triple(it.channelId.lowercase(), it.key.lowercase(), it.epoch) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt new file mode 100644 index 0000000000..10e74ca258 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt @@ -0,0 +1,278 @@ +/* + * 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.concord + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend +import com.vitorpamplona.quartz.concord.cord05Invites.OpenedDirectInvite +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.concurrent.Volatile + +/** + * One parked Direct Invite as the UI renders it (CORD-05 §6): who sent it (seal-verified), what it + * opens (name/icon preview from the bundle), whether its `expires_at` has passed, and whether it + * is a [catchUp] — a Private Channel key for a community this account already holds on the same + * base, which accepting merges in without moving the base or announcing a new Join. + */ +@Immutable +class ConcordDirectInviteView( + val opened: OpenedDirectInvite, + val catchUp: Boolean, + val expired: Boolean, +) { + val wrapId: HexKey get() = opened.wrapId + val sender: HexKey get() = opened.sender + val invite: CommunityInvite get() = opened.invite + val communityId: HexKey get() = opened.invite.communityId + val name: String get() = opened.invite.name + val icon: ImagePointer? get() = opened.invite.icon + + /** Names of the Private Channels the bundle carries (what a catch-up would add). */ + val channelNames: List get() = + opened.invite.channels + .filter { it.key.isNotBlank() } + .map { it.name } +} + +/** What accepting a Direct Invite does; see [ConcordDirectInviteInbox.acceptPlan]. */ +sealed interface DirectInviteAcceptPlan { + /** `expires_at` has passed: the preview renders, joining refuses. */ + data object Expired : DirectInviteAcceptPlan + + /** A community we don't hold: run the shared join path. */ + data object Join : DirectInviteAcceptPlan + + /** A held community: store [entry] — the held one plus the newly granted Private Channel keys. */ + class CatchUp( + val entry: ConcordCommunityListEntry, + ) : DirectInviteAcceptPlan + + /** A held community the bundle adds nothing to (or can't: a different base, or dissolved). */ + data object NothingNew : DirectInviteAcceptPlan + + /** The held community's roster bans us. */ + data object Banned : DirectInviteAcceptPlan + + /** The held community's roster isn't folded yet, so the ban verdict is unknown: wait. */ + data object RosterNotLoaded : DirectInviteAcceptPlan +} + +/** + * The Direct Invite inbox (CORD-05 §6) — headless, shared by the app and `amy`. + * + * Wraps arrive from anywhere — a `{"kinds":[1059],"#p":[me],"#k":["3313"]}` sweep + * ([com.vitorpamplona.amethyst.commons.actions.ConcordActions.directInvitesFilter]), or the general + * NIP-17 giftwrap pipeline, which honours an untagged invite all the same — and are [offer]ed here. + * The inbox opens each wrap once (two NIP-44 decrypts), dedupes by wrap id, drops a wrap whose NIP-40 + * `expiration` has passed, validates the bundle exactly like a fetched one, and parks it in + * [pending]. **Nothing** else happens: no relay connection, no icon fetch, no Join, until the user + * accepts (the caller's join path) or [decline]s. + * + * Declined wrap ids are remembered ([declined], restorable via [restoreDeclined]) so a re-delivered + * wrap never resurfaces. [newestWrapCreatedAt] is the sweep cursor; query from [since], which + * rewinds it by NIP-59's two-day backdate window. + */ +class ConcordDirectInviteInbox( + private val signer: NostrSigner, +) { + private val mutex = Mutex() + + /** Wrap ids already handled this session (opened, refused, or expired), oldest first. */ + private val seen = LinkedHashSet() + + private val _pending = MutableStateFlow>(emptyMap()) + + /** Parked invites by wrap id, as opened. See [visible] for what a UI should show. */ + val pending: StateFlow> = _pending.asStateFlow() + + private val _declined = MutableStateFlow>(emptySet()) + + /** Wrap ids the user declined; persisted by the front end so they stay declined across restarts. */ + val declined: StateFlow> = _declined.asStateFlow() + + /** The newest wrap `created_at` offered so far (the sweep cursor), or null on a cold inbox. */ + @Volatile + var newestWrapCreatedAt: Long? = null + private set + + /** The `since` for the next sweep: the cursor rewound by the backdate window (null = everything). */ + fun since(): Long? = ConcordDirectInvite.inboxSince(newestWrapCreatedAt) + + /** Replaces the declined set — used to restore it from disk at startup. Drops any pending one. */ + fun restoreDeclined(wrapIds: Set) { + _declined.value = wrapIds + _pending.update { current -> current.filterKeys { it !in wrapIds } } + } + + /** + * Considers one kind-1059 [wrap] addressed to us. Returns the parked invite (new or already + * pending), or null when it isn't one: not a direct invite for us, a forgery, an invalid + * bundle, an expired handoff, or a wrap the user already declined. Never throws. + */ + suspend fun offer( + wrap: Event, + nowSecs: Long = TimeUtils.now(), + ): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.open(wrap, signer) } + + /** + * [offer] for a pipeline that already peeled [wrap] down to its kind-13 [seal] (the NIP-17 + * giftwrap inbox). [wrap] only lends its id, `created_at` and tags, so a content-stripped copy + * is fine; the seal is re-opened with the anti-spoofing check the generic unseal skips. + */ + suspend fun offerSeal( + wrap: Event, + seal: Event, + nowSecs: Long = TimeUtils.now(), + ): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.openSeal(wrap.id, seal, signer) } + + private suspend fun admit( + wrap: Event, + nowSecs: Long, + open: suspend () -> OpenedDirectInvite?, + ): OpenedDirectInvite? { + if (wrap.kind != GiftWrapEvent.KIND) return null + mutex.withLock { + val newest = newestWrapCreatedAt + if (newest == null || wrap.createdAt > newest) newestWrapCreatedAt = wrap.createdAt + _pending.value[wrap.id]?.let { return it } + if (wrap.id in _declined.value || wrap.id in seen) return null + remember(wrap.id) + } + // An expired handoff is never decrypted or surfaced (NIP-40 on the wrap mirrors expires_at). + if (ConcordDirectInvite.isWrapExpired(wrap, nowSecs)) return null + val opened = open() ?: return null + mutex.withLock { + if (wrap.id in _declined.value) return null + _pending.update { it + (wrap.id to opened) } + } + return opened + } + + /** The parked invite behind [wrapId], if any. */ + fun get(wrapId: HexKey): OpenedDirectInvite? = _pending.value[wrapId.lowercase()] ?: _pending.value[wrapId] + + /** Discards [wrapId] for good (CORD-05 §6 "declining means discarding them"). False if not pending. */ + fun decline(wrapId: HexKey): Boolean { + val id = get(wrapId)?.wrapId ?: return false + _pending.update { it - id } + _declined.update { it + id } + return true + } + + /** Drops [wrapId] after it was accepted; this session will not re-park it. */ + fun resolve(wrapId: HexKey) { + _pending.update { it - wrapId } + } + + private fun remember(wrapId: HexKey) { + if (seen.size >= SEEN_CAP) { + val drop = seen.take(SEEN_CAP / 2) + seen.removeAll(drop.toSet()) + } + seen.add(wrapId) + } + + companion object { + /** Cap on remembered wrap ids; the oldest half is shed past it (a sweep re-dedupes deeper). */ + const val SEEN_CAP = 4096 + + /** + * What accepting [opened] should do (CORD-05 §6), given the community entry this account + * already [held] (if any) and its folded [heldState]: + * - past `expires_at` → [DirectInviteAcceptPlan.Expired] ("`expires_at` refuses a late join"); + * - not held → [DirectInviteAcceptPlan.Join] (the shared join path, which still ban-gates + * against the community's own Control Plane); + * - held on the SAME base with new Private Channel keys → [DirectInviteAcceptPlan.CatchUp], + * the held entry with only those keys merged in — never moving the base (Armada + * `catchUpChannelIds`) — unless the held roster bans [me]; refused while the roster isn't + * folded ([DirectInviteAcceptPlan.RosterNotLoaded]); + * - held otherwise (nothing new, a different base, dissolved) → [DirectInviteAcceptPlan.NothingNew]. + */ + fun acceptPlan( + opened: OpenedDirectInvite, + held: ConcordCommunityListEntry?, + heldState: ConcordCommunityState?, + me: HexKey, + nowMs: Long = TimeUtils.nowMillis(), + ): DirectInviteAcceptPlan { + if (opened.isExpired(nowMs)) return DirectInviteAcceptPlan.Expired + if (held == null) return DirectInviteAcceptPlan.Join + val adopted = ConcordInviteVend.adoptCatchUp(held, opened.invite) ?: return DirectInviteAcceptPlan.NothingNew + if (heldState == null) return DirectInviteAcceptPlan.RosterNotLoaded + // Death wins every race (CORD-02 §9): a dissolved community takes no new keys. + if (heldState.dissolved) return DirectInviteAcceptPlan.NothingNew + if (heldState.authority.isBanned(me)) return DirectInviteAcceptPlan.Banned + return DirectInviteAcceptPlan.CatchUp(adopted) + } + + /** + * What a UI shows out of [pending], given the communities this account already holds + * ([joined]): newest first, with + * - an invite for a community already held on the SAME base that carries a Private Channel + * key it lacks kept as a [ConcordDirectInviteView.catchUp]; + * - any other invite for a held community (nothing new, or a different base — which may + * never move the held one) hidden; + * - one invite per community (newest `sentAt`, ties by wrap id), catch-ups keyed by their + * channel set too since each may vend a key no other wrap carries (Armada + * `dedupeParkedInvites`). + */ + fun visible( + pending: Collection, + joined: List, + nowMs: Long = TimeUtils.nowMillis(), + ): List { + val heldById = joined.associateBy { it.id.lowercase() } + val byKey = LinkedHashMap() + for (opened in pending) { + val communityId = opened.invite.communityId.lowercase() + val held = heldById[communityId] + val newChannels = ConcordInviteVend.catchUpChannelIds(held, opened.invite) + if (held != null && newChannels.isEmpty()) continue + val catchUp = held != null + val key = if (catchUp) communityId + "|" + newChannels.sorted().joinToString(",") else communityId + val view = ConcordDirectInviteView(opened, catchUp, opened.isExpired(nowMs)) + val existing = byKey[key] + if (existing == null || + opened.sentAt > existing.opened.sentAt || + (opened.sentAt == existing.opened.sentAt && opened.wrapId < existing.opened.wrapId) + ) { + byKey[key] = view + } + } + return byKey.values.sortedWith(compareByDescending { it.opened.sentAt }.thenBy { it.wrapId }) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt index 44da2aa96a..82c43a895e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt @@ -69,6 +69,16 @@ class ConcordSessionManager( /** Monotonic counter bumped whenever the joined set or any community's fold changes. */ val revision: StateFlow = _revision + // Declared before `init`: the communities collector may run synchronously on an immediate dispatcher. + private val _nextExpiry = MutableStateFlow(null) + + /** + * The earliest disappearing-message deadline (unix seconds) across every joined community, or + * null when nothing expires (CORD-08 §3). The account schedules its [sweepExpired] on this, so + * communities without a timer cost nothing. + */ + val nextExpiry: StateFlow = _nextExpiry + private val lock = KmpLock() private val stateWatchers = HashMap() // communityId -> state collector @@ -97,13 +107,34 @@ class ConcordSessionManager( stateWatchers.remove(id)?.cancel() stateWatchers[id] = scope.launch { + // CORD-08: a rumor with an expiration moves the account-wide sweep deadline. + launch { session.nextExpiry.collect { recomputeNextExpiry() } } session.state.collect { bumpRevision() } } } } + recomputeNextExpiry() bumpRevision() } + private fun recomputeNextExpiry() { + _nextExpiry.value = registry.sessions().mapNotNull { it.nextExpiry.value }.minOrNull() + } + + /** + * Sweeps every session for rumors expired at [now] (CORD-08 §3): drops their wraps from the + * session buffers and returns them, per community, for the caller to purge from its store. + */ + fun sweepExpired(now: Long): Map> { + val out = HashMap>() + for (session in registry.sessions()) { + val expired = session.sweepExpired(now) + if (expired.isNotEmpty()) out[session.entry.id] = expired + } + recomputeNextExpiry() + return out + } + private fun bumpRevision() { // Called from the communities collector, every per-session state watcher, and the // ingest path — different coroutines/dispatchers — so the increment must be atomic diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuMintDirectoryState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuMintDirectoryState.kt index 9ca368adad..b398ea5305 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuMintDirectoryState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuMintDirectoryState.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.model.nip60Cashu import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryQueryState @@ -201,13 +202,17 @@ class CashuMintDirectoryState( private fun backfillFromCacheAsync() { scope.launch(Dispatchers.Default) { - cache.notes.forEach { _, note -> + // NIP-87 kinds are addressable: the current version lives in `addressables` (its + // per-id note is weakly held and pruned once superseded). Both maps are keyed by id. + val visit = { note: Note -> when (val e = note.event) { is CashuMintEvent -> announcements[e.id] = e is MintRecommendationEvent -> if (e.isCashuRecommendation()) recommendations[e.id] = e else -> Unit } } + cache.notes.forEach { _, note -> visit(note) } + cache.addressables.forEach { _, note -> visit(note) } rebuildEntries() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuWalletState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuWalletState.kt index 8726daaecd..2751ae7631 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuWalletState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuWalletState.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.commons.model.AccountSettings +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuProofBackfillFilters import com.vitorpamplona.quartz.nip01Core.core.Event @@ -906,12 +907,17 @@ class CashuWalletState( } private fun scanCacheForOwnEvents(): List { - val collected = mutableListOf() - cache.notes.forEach { _, note -> - val e = note.event ?: return@forEach - if (isRelevantEvent(e)) collected += e + // Replaceable and addressable kinds (the wallet, NIP-87 recommendations) live in + // `addressables`: their per-id note is only weakly held and pruned once superseded. + // The current version can sit in both maps, so collect by id. + val collected = LinkedHashMap() + val visit = { note: Note -> + val e = note.event + if (e != null && isRelevantEvent(e)) collected[e.id] = e } - return collected + cache.notes.forEach { _, note -> visit(note) } + cache.addressables.forEach { _, note -> visit(note) } + return collected.values.toList() } // ============================================================ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/ConcordDirectInviteDeclineStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/ConcordDirectInviteDeclineStore.kt new file mode 100644 index 0000000000..0fecf0b490 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/ConcordDirectInviteDeclineStore.kt @@ -0,0 +1,82 @@ +/* + * 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.preferences + +import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Per-account persistence for the Concord Direct Invites the user declined (CORD-05 §6), so a + * declined invite's wrap — which relays keep re-delivering until its NIP-40 expiration — never + * resurfaces after a restart. Mirrors [BuzzChannelStarStore]: loads this account's saved wrap ids + * into [inbox] on construction, then writes every later change back. Construct once per account. + */ +@Stable +class ConcordDirectInviteDeclineStore( + private val store: DataStore, + private val scope: CoroutineScope, + private val pubKeyHex: HexKey, + private val inbox: ConcordDirectInviteInbox, +) { + private val key = stringSetPreferencesKey("$KEY_PREFIX$pubKeyHex") + + init { + scope.launch { + restoreFromDisk() + // drop(1) skips the value present at collection start, which restoreFromDisk already wrote. + inbox.declined.drop(1).collect { persist(it) } + } + } + + private suspend fun restoreFromDisk() { + try { + val raw = store.data.first()[key] ?: return + if (raw.isNotEmpty()) inbox.restoreDeclined(raw + inbox.declined.value) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("ConcordDirectInvites") { "Error reading declined invites: ${e.message}" } + } + } + + private suspend fun persist(ids: Set) { + try { + store.edit { prefs -> prefs[key] = ids } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("ConcordDirectInvites") { "Error writing declined invites: ${e.message}" } + } + } + + companion object { + private const val KEY_PREFIX = "concord.declinedDirectInvites." + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt new file mode 100644 index 0000000000..63ff78adc8 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordDirectInviteActionsTest.kt @@ -0,0 +1,184 @@ +/* + * 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 com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteSendResult +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord04Roles.RoleScope +import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * CORD-05 §6 send side: a Direct Invite carries exactly the Private Channel keys the recipient's + * Roles entitle them to (Armada `vendableChannels`, audience "member"), and goes to the + * recipient's 10050 → NIP-65 read → stock relays. + */ +class ConcordDirectInviteActionsTest { + private val owner = NostrSignerInternal(KeyPair()) + private val mod = NostrSignerInternal(KeyPair()) + private val member = NostrSignerInternal(KeyPair()) + + private val modsChannel = "a1".repeat(32) + private val vipChannel = "b2".repeat(32) + private val modsRoleId = ByteArray(32) { 7 } + + private fun entryOf(community: NewConcordCommunity) = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), + privateChannels = + listOf( + PrivateChannelKey(modsChannel, "ca".repeat(32), 2, "mods"), + PrivateChannelKey(vipChannel, "db".repeat(32), 0, "vip"), + ), + relays = listOf("wss://relay.example"), + name = "Nostrichs", + ) + + /** A community where [mod] holds a Role scoped to [modsChannel]; nobody is scoped to [vipChannel]. */ + private suspend fun foldWithModsRole(community: NewConcordCommunity): ConcordCommunityState { + val cp = community.controlPlane + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + + fun add(wrap: Event) { + editions += ConcordActions.controlEditions(listOf(wrap), cp) + } + val role = + RoleEntity( + roleId = modsRoleId.toHexKey(), + name = "Mods", + position = 5, + permissions = ConcordPermissions.of(ConcordPermissions.MENTION_EVERYONE).toWire(), + scope = RoleScope(kind = "channel", channelId = modsChannel), + ) + add(ConcordModeration.defineRole(owner, cp, community.communityId, modsRoleId, role, editions, createdAt = 2L, owner = community.ownerPubKey)) + add(ConcordModeration.grant(owner, cp, community.communityId, mod.pubKey, listOf(modsRoleId.toHexKey()), editions, createdAt = 3L, owner = community.ownerPubKey)) + return ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey) + } + + @Test + fun aDirectInviteCarriesOnlyTheChannelsTheRecipientIsEntitledTo() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val state = foldWithModsRole(community) + assertTrue(modsRoleId.toHexKey() in state.authority.rolesOf(mod.pubKey)) + val entry = entryOf(community) + + // A plain member holds no channel-scoped Role: no Private Channel keys. + val toMember = ConcordActions.directInviteFor(entry, state.authority, member.pubKey, creator = owner.pubKey) + assertTrue(toMember.channels.isEmpty()) + + // The mod gets #mods (their Role's scope) and nothing else. + val toMod = ConcordActions.directInviteFor(entry, state.authority, mod.pubKey, creator = owner.pubKey, expiresAtMs = 1_900_000_000_000L) + assertEquals(listOf(modsChannel), toMod.channels.map { it.id }) + assertEquals("ca".repeat(32), toMod.channels.single().key) + assertEquals(2L, toMod.channels.single().epoch) + assertEquals(1_900_000_000_000L, toMod.expiresAt) + assertEquals(owner.pubKey, toMod.creatorNpub) + + // The owner is entitled to every channel. + val toOwner = ConcordActions.directInviteFor(entry, state.authority, owner.pubKey, creator = mod.pubKey) + assertEquals(setOf(modsChannel, vipChannel), toOwner.channels.map { it.id }.toSet()) + + // The bundle is the held base, and it validates as a fetched one would. + assertEquals(entry.root, toMember.communityRoot) + assertEquals(entry.rootEpoch, toMember.rootEpoch) + assertEquals(entry.controlPk, toMember.controlPk) + } + + @Test + fun draftRefusesBannedPartiesAndBadRecipients() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val cp = community.controlPlane + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, community.communityId, member.pubKey, editions, createdAt = 2L, owner = community.ownerPubKey)), cp) + val state = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey) + val entry = entryOf(community) + + fun refusal(draft: ConcordDirectInviteDraft) = (draft as? ConcordDirectInviteDraft.Refused)?.reason + + assertEquals(ConcordDirectInviteSendResult.RECIPIENT_BANNED, refusal(ConcordActions.draftDirectInvite(entry, state, owner.pubKey, member.pubKey))) + assertEquals(ConcordDirectInviteSendResult.NOT_MEMBER, refusal(ConcordActions.draftDirectInvite(entry, state, member.pubKey, mod.pubKey))) + assertEquals(ConcordDirectInviteSendResult.NOT_MEMBER, refusal(ConcordActions.draftDirectInvite(entry, state.withDissolved(true), owner.pubKey, mod.pubKey))) + assertEquals(ConcordDirectInviteSendResult.INVALID_RECIPIENT, refusal(ConcordActions.draftDirectInvite(entry, state, owner.pubKey, "npub1notahexkey"))) + + // The folded metadata names the preview. + val ready = assertIs(ConcordActions.draftDirectInvite(entry, state, owner.pubKey, mod.pubKey.uppercase())) + assertEquals("Nostrichs", ready.invite.name) + assertEquals(owner.pubKey, ready.invite.creatorNpub) + } + + @Test + fun theBuiltWrapOpensForTheRecipient() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val state = foldWithModsRole(community) + val invite = ConcordActions.directInviteFor(entryOf(community), state.authority, mod.pubKey, creator = owner.pubKey) + val wrap = ConcordActions.buildDirectInvite(owner, mod.pubKey, invite) + + // The indexed lookup a recipient runs matches the wrap's tags. + val filter = ConcordActions.directInvitesFilter(mod.pubKey, since = 5L) + assertEquals(listOf(mod.pubKey), filter.tags?.get("p")) + assertEquals(listOf("3313"), filter.tags?.get("k")) + assertEquals(5L, filter.since) + assertTrue(filter.match(wrap)) + + val opened = assertNotNull(ConcordActions.openDirectInvite(wrap, mod)) + assertEquals(owner.pubKey, opened.sender) + assertEquals(listOf(modsChannel), ConcordActions.privateChannelKeysOf(opened.invite).map { it.channelId }) + } + + @Test + fun deliveryGoesTo10050ThenNip65ReadThenStock() { + val dm = RelayUrlNormalizer.normalizeOrNull("wss://dm.example")!! + val withDm = RecipientRelayFetcher.Lists(dmInbox = listOf(dm), keyPackage = emptyList(), nip65 = null) + assertEquals(setOf(dm), ConcordActions.directInviteDeliveryRelays(withDm)) + + val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + assertEquals(stock, ConcordActions.directInviteDeliveryRelays(null)) + assertEquals(stock, ConcordActions.directInviteDeliveryRelays(RecipientRelayFetcher.Lists(emptyList(), emptyList(), null))) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordInviteRegistryPublishTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordInviteRegistryPublishTest.kt new file mode 100644 index 0000000000..2ae9fbd741 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordInviteRegistryPublishTest.kt @@ -0,0 +1,121 @@ +/* + * 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 com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * CORD-05 §5 end to end through the writer: a creator's Invite Registry edition, published over the + * Control Plane, opened and folded like any other edition, drives the Public/Private mode, and that + * mode decides whether a ban Refounds (CORD-06 §3) and whether a retire privatizes (CORD-05 §2). + */ +class ConcordInviteRegistryPublishTest { + private val owner = NostrSignerInternal(KeyPair()) + private val inviter = NostrSignerInternal(KeyPair()) + private val troll = NostrSignerInternal(KeyPair()) + private val link1 = "c1".repeat(32) + private val link2 = "c2".repeat(32) + + @Test + fun registriesPublishFoldAndDriveThePublicPrivateMode() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val cp = community.controlPlane + val cid = community.communityId + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + + fun add(wrap: Event) { + editions += ConcordActions.controlEditions(listOf(wrap), cp) + } + + fun fold(): ConcordCommunityState = ConcordCommunityState.fold(editions, cid, community.ownerPubKey) + + // A fresh community has no live link: Private, and a ban would Refound. + assertFalse(fold().isPublic) + assertTrue(fold().banRequiresRefounding(listOf(troll.pubKey))) + + // The owner mints: the registry lists the link signer and the community reads Public. + add(ConcordModeration.setInviteRegistry(owner, cp, cid, listOf(link1), editions, createdAt = 2L, owner = community.ownerPubKey)) + val ownerHead = editions.last() + assertEquals(ControlEntityKind.INVITE_REGISTRY, ownerHead.entityKind) + assertEquals(ConcordInviteRegistry.coordinateHex(cid, owner.pubKey), ownerHead.entityIdHex) + assertEquals("""["$link1"]""", ownerHead.content) + assertTrue(fold().isPublic) + assertFalse(fold().banRequiresRefounding(listOf(troll.pubKey)), "a Public ban is the Banlist alone") + + // A CREATE_INVITE holder's registry is honored beside the owner's; a troll's is not. + val roleId = ByteArray(32) { 5 } + add( + ConcordModeration.defineRole( + owner, + cp, + cid, + roleId, + RoleEntity(name = "Inviter", position = 2, permissions = ConcordPermissions.of(ConcordPermissions.CREATE_INVITE).toWire()), + editions, + createdAt = 3L, + owner = community.ownerPubKey, + ), + ) + add(ConcordModeration.grant(owner, cp, cid, inviter.pubKey, listOf(roleId.toHexKey()), editions, createdAt = 4L, owner = community.ownerPubKey)) + add(ConcordModeration.setInviteRegistry(inviter, cp, cid, listOf(link2), editions, createdAt = 5L, owner = community.ownerPubKey)) + add(ConcordModeration.setInviteRegistry(troll, cp, cid, listOf("dd".repeat(32)), editions, createdAt = 5L, owner = community.ownerPubKey)) + val both = fold() + assertEquals(setOf(link1, link2), both.liveInviteLinks) + assertEquals(listOf(link2), both.registryOf(inviter.pubKey)) + assertEquals(emptyList(), both.registryOf(troll.pubKey)) + + // The inviter's edition carried the `vac` citation that made it count. + val inviterEdition: ControlEdition = assertNotNull(editions.lastOrNull { it.author == inviter.pubKey && it.entityKind == ControlEntityKind.INVITE_REGISTRY }) + assertNotNull(inviterEdition.authorityCitation) + + // Retiring the owner's link leaves the inviter's: still Public, nothing privatizes. + assertFalse(both.retiringWouldPrivatize(listOf(link1))) + add(ConcordModeration.setInviteRegistry(owner, cp, cid, emptyList(), editions, createdAt = 6L, owner = community.ownerPubKey)) + val ownerRetired = fold() + assertEquals(2L, editions.last().version, "the retire chains onto the owner's own registry head") + assertEquals(setOf(link2), ownerRetired.liveInviteLinks) + + // Now the inviter's is the last live link: retiring it privatizes (a Refounding, CORD-05 §2). + assertTrue(ownerRetired.retiringWouldPrivatize(listOf(link2))) + // Banning the inviter would take their registry with them: that ban Refounds. + assertTrue(ownerRetired.banRequiresRefounding(listOf(inviter.pubKey))) + + add(ConcordModeration.setInviteRegistry(inviter, cp, cid, emptyList(), editions, createdAt = 7L, owner = community.ownerPubKey)) + assertFalse(fold().isPublic) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinningTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinningTest.kt new file mode 100644 index 0000000000..18fbf29889 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinningTest.kt @@ -0,0 +1,434 @@ +/* + * 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 com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPins +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** CORD-04 §7 Pins end to end at the commons layer: build → publish → fold → verify. */ +class ConcordPinningTest { + private val owner = NostrSignerInternal(KeyPair()) + private val alice = NostrSignerInternal(KeyPair()) + private val stranger = NostrSignerInternal(KeyPair()) + private val secretId = ByteArray(32) { 0x5C } + private val secretIdHex = secretId.toHexKey() + private val channelKey = ByteArray(32) { 0x3C } + private val channelEpoch = 3L + + private fun entryFor( + community: NewConcordCommunity, + privateChannels: List = emptyList(), + ) = ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), + privateChannels = privateChannels, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + + /** A session plus every rumor it emitted, which is the evidence (deletes, edits) a reader holds. */ + private class Harness( + val community: NewConcordCommunity, + entry: ConcordCommunityListEntry, + me: HexKey, + ) { + val rumors = mutableListOf() + val session = ConcordCommunitySession(entry, me) { _, _, rumor, _ -> rumors += rumor } + + fun pins(channelIdHex: HexKey) = ConcordPinEvidence(rumors).let { evidence -> assertNotNull(session.readPins(channelIdHex, evidence::isKilled, evidence::newestEdit)) } + + fun ctx( + actor: NostrSigner, + channelIdHex: HexKey, + authorized: Boolean = true, + ): ConcordPinContext { + val state = session.state.value!! + return ConcordPinContext( + actor = actor, + controlPlane = session.controlPlaneKeys(), + communityId = community.communityId, + owner = community.ownerPubKey, + current = session.controlEditions(), + channelIdHex = channelIdHex, + channelIsPrivate = state.channels[channelIdHex]!!.definition.private, + currentPlane = session.currentChannelPlane(channelIdHex), + pins = pins(channelIdHex), + authorized = authorized, + ) + } + + suspend fun post( + author: NostrSigner, + channelIdHex: HexKey, + text: String, + createdAt: Long, + ): Event { + val plane = session.currentChannelPlane(channelIdHex)!! + val wrap = ConcordActions.buildChannelMessage(author, plane.key, channelIdHex, plane.epoch, text, createdAt) + session.ingest(wrap) + return rumors.last() + } + + suspend fun pin( + actor: NostrSigner, + channelIdHex: HexKey, + rumor: Event, + createdAt: Long, + ): ConcordPinWrite { + val write = ConcordPinning.pin(ctx(actor, channelIdHex), assertNotNull(session.pinSource(channelIdHex, rumor.id)), createdAt) + write.wrap?.let { session.ingest(it) } + return write + } + } + + private suspend fun harness(withPrivate: Boolean = false): Harness { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val keys = if (withPrivate) listOf(PrivateChannelKey(secretIdHex, channelKey.toHexKey(), channelEpoch, "secret")) else emptyList() + val h = Harness(community, entryFor(community, keys), owner.pubKey) + community.genesisWraps.forEach { h.session.ingest(it) } + if (withPrivate) { + h.session.ingest( + ConcordModeration.defineChannel(owner, community.controlPlane, community.communityId, secretId, ChannelEntity(name = "secret", private = true), h.session.controlEditions(), 2L, owner = community.ownerPubKey), + ) + } + return h + } + + @Test + fun aPinRoundTripsThroughTheControlPlaneAndUnpinRemovesIt() = + runTest { + val h = harness() + val general = h.community.generalChannelIdHex + assertEquals(0, h.pins(general).count) + assertNull(h.pins(general).head) + + val message = h.post(alice, general, "ship it", 10L) + val write = h.pin(owner, general, message, 11L) + assertEquals(ConcordPinOutcome.PUBLISHED, write.outcome) + + // The edition is a vsk-11 Pin List at pins_locator(community, channel), chained from genesis. + val head = assertNotNull(h.session.pinHeads.value[general]) + assertEquals(ControlEntityKind.PIN_LIST, head.entityKind) + assertEquals(ConcordKeyDerivation.pinsCoordinate(h.community.communityId, general.hexToByteArray()).toHexKey(), head.entityIdHex) + assertEquals(1L, head.version) + assertFalse(ConcordPins.isSealedForm(head.content), "a public channel's list is plaintext") + + val pins = h.pins(general) + assertEquals(1, pins.count) + assertEquals(message.id, pins.pins.single().rumorId) + assertEquals(alice.pubKey, pins.pins.single().author) + assertEquals("ship it", pins.pins.single().content) + assertTrue(h.session.holdsRumor(message.id), "the wrap hint resolves locally, so the row can jump") + + assertEquals(ConcordPinOutcome.ALREADY_PINNED, ConcordPinning.pin(h.ctx(owner, general), h.session.pinSource(general, message.id)!!, 12L).outcome) + + val unpin = ConcordPinning.unpin(h.ctx(owner, general), message.id, 13L) + assertEquals(ConcordPinOutcome.PUBLISHED, unpin.outcome) + h.session.ingest(unpin.wrap!!) + assertEquals( + 2L, + h.session.pinHeads.value[general]!! + .version, + "unpinning is the next edition, not a deletion", + ) + assertEquals(0, h.pins(general).count) + assertEquals(ConcordPinOutcome.NOT_PINNED, ConcordPinning.unpin(h.ctx(owner, general), message.id, 14L).outcome) + } + + @Test + fun aPinnedMessageThatExpiresLeavesThePinnedList() = + runTest { + // CORD-08 §3 meets CORD-04 §7: the proof stays valid, but the rumor's own expiration says it is gone. + val h = harness() + val general = h.community.generalChannelIdHex + val plane = h.session.currentChannelPlane(general)!! + val sent = TimeUtils.now() + h.session.ingest(ConcordActions.buildChannelMessage(alice, plane.key, general, plane.epoch, "gone soon", sent, timerSecs = 3_600)) + val message = h.rumors.last() + assertEquals(ConcordPinOutcome.PUBLISHED, h.pin(owner, general, message, sent + 1).outcome) + + val evidence = ConcordPinEvidence(h.rumors) + assertEquals(1, h.session.readPins(general, evidence::isKilled, evidence::newestEdit, now = sent + 10)!!.count) + assertEquals(0, h.session.readPins(general, evidence::isKilled, evidence::newestEdit, now = sent + 3_600)!!.count) + } + + @Test + fun theWrapHintPointsAtTheCarryingWrap() = + runTest { + val h = harness() + val general = h.community.generalChannelIdHex + val plane = h.session.currentChannelPlane(general)!! + val wrap = ConcordActions.buildChannelMessage(alice, plane.key, general, plane.epoch, "hint", 10L) + h.session.ingest(wrap) + h.pin(owner, general, h.rumors.last(), 11L) + assertEquals( + wrap.id, + h + .pins(general) + .pins + .single() + .pin.wrapHint, + ) + } + + @Test + fun aNonPinMessagesAuthorsEditionIsIgnored() = + runTest { + val h = harness() + val general = h.community.generalChannelIdHex + val message = h.post(alice, general, "legit", 10L) + h.pin(owner, general, message, 11L) + + // A stranger who somehow holds the write key mints a newer edition emptying the list. + val rogue = ConcordModeration.setPinList(stranger, h.session.controlPlaneKeys(), h.community.communityId, general.hexToByteArray(), h.session.pinHeads.value[general], ConcordPins.serializePublic(emptyList()), h.session.controlEditions(), 12L, owner = h.community.ownerPubKey) + h.session.ingest(rogue) + assertEquals(1, h.pins(general).count, "the fold gates Pin Lists on PIN_MESSAGES") + assertEquals( + owner.pubKey, + h.session.pinHeads.value[general]!! + .author, + ) + + // And the verb refuses outright for an unauthorized actor. + val refused = ConcordPinning.pin(h.ctx(stranger, general, authorized = false), h.session.pinSource(general, message.id)!!, 13L) + assertEquals(ConcordPinOutcome.NOT_AUTHORIZED, refused.outcome) + assertNull(refused.wrap) + } + + @Test + fun aPrivateChannelsListIsSealedAndUnavailableWithoutTheKey() = + runTest { + val h = harness(withPrivate = true) + val message = h.post(alice, secretIdHex, "for members", 10L) + assertEquals(ConcordPinOutcome.PUBLISHED, h.pin(owner, secretIdHex, message, 11L).outcome) + val head = h.session.pinHeads.value[secretIdHex]!! + assertTrue(ConcordPins.isSealedForm(head.content), "the writer uses the form of the channel's folded type") + assertFalse(head.content.contains("for members")) + assertEquals( + "for members", + h + .pins(secretIdHex) + .pins + .single() + .content, + ) + + // A client of this community without the channel key (here the owner's other device). + val keylessHarness = Harness(h.community, entryFor(h.community), owner.pubKey) + h.session.controlPlaneWraps().forEach { keylessHarness.session.ingest(it) } + val dark = keylessHarness.pins(secretIdHex) + assertTrue(dark.sealedUnavailable, "unreadable, not empty") + assertEquals(0, dark.count) + assertNotNull(dark.head) + + // MUST withhold the write: even an unpin of nothing would drop every sealed entry. + val withheld = ConcordPinning.unpin(keylessHarness.ctx(owner, secretIdHex), message.id, 12L) + assertEquals(ConcordPinOutcome.LIST_UNAVAILABLE, withheld.outcome) + assertNull(withheld.wrap) + assertEquals(ConcordPinOutcome.LIST_UNAVAILABLE, ConcordPinning.omit(keylessHarness.ctx(owner, secretIdHex), setOf(message.id), 12L).outcome) + } + + @Test + fun aPrivateToPublicSwitchNeverReformsTheSealedList() = + runTest { + val h = harness(withPrivate = true) + val secretMessage = h.post(alice, secretIdHex, "private era", 10L) + h.pin(owner, secretIdHex, secretMessage, 11L) + + // The channel turns public. + h.session.ingest( + ConcordModeration.defineChannel(owner, h.community.controlPlane, h.community.communityId, secretId, ChannelEntity(name = "secret", private = false), h.session.controlEditions(), 12L, owner = h.community.ownerPubKey), + ) + assertFalse( + h.session.state.value!! + .channels[secretIdHex]!! + .definition.private, + ) + // Still readable (the key is held) — a reader accepts either form. + assertEquals(1, h.pins(secretIdHex).count) + + val publicMessage = h.post(alice, secretIdHex, "public era", 13L) + assertEquals(ConcordPinOutcome.PUBLISHED, h.pin(owner, secretIdHex, publicMessage, 14L).outcome) + val head = h.session.pinHeads.value[secretIdHex]!! + assertFalse(ConcordPins.isSealedForm(head.content)) + assertFalse(head.content.contains(secretMessage.id), "the private-era pin is not republished to everyone") + assertEquals(listOf(publicMessage.id), h.pins(secretIdHex).pins.map { it.rumorId }) + } + + @Test + fun capsRefuseBeforePublishing() = + runTest { + val h = harness() + val general = h.community.generalChannelIdHex + repeat(ConcordPins.MAX_ENTRIES) { i -> + val m = h.post(alice, general, "pin number $i", 100L + i) + assertEquals(ConcordPinOutcome.PUBLISHED, h.pin(owner, general, m, 200L + i).outcome, "pin $i") + } + assertEquals(ConcordPins.MAX_ENTRIES, h.pins(general).count) + val overflow = h.post(alice, general, "one too many", 300L) + val refused = ConcordPinning.pin(h.ctx(owner, general), h.session.pinSource(general, overflow.id)!!, 301L) + assertEquals(ConcordPinOutcome.TOO_MANY_PINS, refused.outcome) + assertNull(refused.wrap) + } + + @Test + fun aSealedListHitsTheByteCapBeforeTheEntryCap() = + runTest { + val h = harness(withPrivate = true) + var outcome = ConcordPinOutcome.PUBLISHED + var pinned = 0 + while (outcome == ConcordPinOutcome.PUBLISHED) { + val m = h.post(alice, secretIdHex, "a typical pinned announcement of about a hundred and thirty five characters, give or take, number $pinned", 100L + pinned) + outcome = h.pin(owner, secretIdHex, m, 200L + pinned).outcome + if (outcome == ConcordPinOutcome.PUBLISHED) pinned++ + } + assertEquals(ConcordPinOutcome.TOO_LARGE, outcome) + assertTrue(pinned in 10 until ConcordPins.MAX_ENTRIES, "the byte cap governs a sealed list (pinned $pinned)") + assertEquals(pinned, h.pins(secretIdHex).count, "the refused write published nothing") + } + + @Test + fun theAuthorsDeleteHidesThePinAndTheOmissionDropsIt() = + runTest { + val h = harness() + val general = h.community.generalChannelIdHex + val keep = h.post(alice, general, "keep", 10L) + val oops = h.post(alice, general, "oops", 11L) + h.pin(owner, general, keep, 12L) + h.pin(owner, general, oops, 13L) + + // Someone else's delete of alice's message does nothing. + val plane = h.session.currentChannelPlane(general)!! + h.session.ingest(ConcordActions.buildChannelDelete(stranger, plane.key, general, plane.epoch, listOf(oops), 14L)) + assertEquals(2, h.pins(general).count) + + h.session.ingest(ConcordActions.buildChannelDelete(alice, plane.key, general, plane.epoch, listOf(oops), 15L)) + val read = h.pins(general) + assertEquals(listOf(keep.id), read.pins.map { it.rumorId }, "a held delete hides the entry immediately") + assertEquals(listOf(oops.id), read.killed.map { it.rumorId }) + assertTrue(read.owesRepublish) + + // The duty write drops it from the head; after that nothing is owed. + val settled = ConcordPinning.settle(h.ctx(owner, general), { null }, 16L) + assertEquals(ConcordPinOutcome.PUBLISHED, settled.outcome) + h.session.ingest(settled.wrap!!) + assertFalse( + h.session.pinHeads.value[general]!! + .content + .contains(oops.id), + ) + assertFalse(h.pins(general).owesRepublish) + assertEquals(ConcordPinOutcome.NOTHING_TO_DO, ConcordPinning.settle(h.ctx(owner, general), { null }, 17L).outcome) + } + + @Test + fun thePinnersOmissionPublishesAtOnce() = + runTest { + val h = harness() + val general = h.community.generalChannelIdHex + val mine = h.post(owner, general, "my announcement", 10L) + h.pin(owner, general, mine, 11L) + val omitted = ConcordPinning.omit(h.ctx(owner, general), setOf(mine.id), 12L) + assertEquals(ConcordPinOutcome.PUBLISHED, omitted.outcome) + h.session.ingest(omitted.wrap!!) + assertEquals(0, h.pins(general).count) + assertEquals(ConcordPinOutcome.NOTHING_TO_DO, ConcordPinning.omit(h.ctx(owner, general), setOf(mine.id), 13L).outcome) + } + + @Test + fun aNewerHeldEditMarksThePinEditedAndTheRefreshAttachesItsProof() = + runTest { + val h = harness() + val general = h.community.generalChannelIdHex + val original = h.post(alice, general, "teh plan", 10L) + h.pin(owner, general, original, 11L) + assertFalse( + h + .pins(general) + .pins + .single() + .edited, + ) + + val plane = h.session.currentChannelPlane(general)!! + h.session.ingest(ConcordActions.buildChannelEdit(alice, plane.key, general, plane.epoch, original, "the plan", 12L)) + val edit = h.rumors.last() + // A forged edit by someone else never counts. + h.session.ingest(ConcordActions.buildChannelEdit(stranger, plane.key, general, plane.epoch, original, "pwned", 13L)) + + val shown = h.pins(general).pins.single() + assertTrue(shown.edited, "a client holding a newer Edit MUST mark the pin edited") + assertEquals("the plan", shown.content) + assertEquals(edit.id, shown.newerEdit?.rumorId) + assertFalse(shown.pin.edited, "the proof itself still carries the original words") + + val refreshed = ConcordPinning.settle(h.ctx(owner, general), { h.session.pinSource(general, it.newerEdit!!.rumorId) }, 14L) + assertEquals(ConcordPinOutcome.PUBLISHED, refreshed.outcome) + h.session.ingest(refreshed.wrap!!) + val after = h.pins(general).pins.single() + assertTrue(after.pin.edited, "the proof now carries the Edit for keyless readers") + assertEquals("the plan", after.pin.content) + assertNull(after.newerEdit, "nothing newer is owed") + assertFalse(h.pins(general).owesRepublish) + } + + @Test + fun verificationIsCachedByEntryIdentity() = + runTest { + val verifier = ConcordPinVerifier() + val h = harness() + val general = h.community.generalChannelIdHex + h.pin(owner, general, h.post(alice, general, "one", 10L), 11L) + h.pin(owner, general, h.post(alice, general, "two", 12L), 13L) + val head = h.session.pinHeads.value[general] + ConcordPinning.read(head, general, { null }, verifier) + assertEquals(2, verifier.misses) + ConcordPinning.read(head, general, { null }, verifier) + assertEquals(2, verifier.misses, "a re-read redoes no signature, MAC or decryption") + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt new file mode 100644 index 0000000000..d50201fabb --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt @@ -0,0 +1,263 @@ +/* + * 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.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.InviteChannel +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * The headless Direct Invite inbox (CORD-05 §6): collects wraps, dedupes by wrap id, skips expired + * handoffs, validates, parks — and never joins. Plus the accept decision (expired → refuse; held → + * catch-up keys only on the same base, never a base move). + */ +class ConcordDirectInviteInboxTest { + private val owner = NostrSignerInternal(KeyPair()) + private val sender = NostrSignerInternal(KeyPair()) + private val me = NostrSignerInternal(KeyPair()) + private val stranger = NostrSignerInternal(KeyPair()) + + private val vip = "b2".repeat(32) + + private suspend fun community(): NewConcordCommunity = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + + private fun inviteFor( + c: NewConcordCommunity, + expiresAt: Long? = null, + channels: List = emptyList(), + root: String = c.communityRoot.toHexKey(), + ) = CommunityInvite( + communityId = c.communityIdHex, + owner = c.ownerPubKey, + ownerSalt = c.ownerSalt.toHexKey(), + communityRoot = root, + rootEpoch = c.rootEpoch, + controlPk = c.controlPkHex, + channels = channels, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + expiresAt = expiresAt, + ) + + private fun heldEntryOf(c: NewConcordCommunity) = + ConcordCommunityListEntry( + id = c.communityIdHex, + owner = c.ownerPubKey, + ownerSalt = c.ownerSalt.toHexKey(), + root = c.communityRoot.toHexKey(), + rootEpoch = c.rootEpoch, + controlPk = c.controlPkHex, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + inviteRef = "anchor", + ) + + private fun stateOf(c: NewConcordCommunity): ConcordCommunityState = ConcordCommunityState.fold(ConcordActions.controlEditions(c.genesisWraps, c.controlPlane), c.communityId, c.ownerPubKey) + + @Test + fun aValidWrapIsParkedWithItsVerifiedSenderAndDedupedByWrapId() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + + val first = assertNotNull(inbox.offer(wrap)) + assertEquals(sender.pubKey, first.sender) + assertEquals(c.communityIdHex, first.invite.communityId) + assertEquals(setOf(wrap.id), inbox.pending.value.keys) + + // The same wrap again (a re-delivery, or the DM pipeline seeing it too) is the same entry. + assertSame(first, inbox.offer(wrap)) + assertEquals(1, inbox.pending.value.size) + assertEquals(wrap.createdAt, inbox.newestWrapCreatedAt) + } + + @Test + fun wrapsForSomeoneElseOrForgedOrExpiredAreNotParked() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + // Addressed to someone else. + assertNull(inbox.offer(ConcordActions.buildDirectInvite(sender, stranger.pubKey, inviteFor(c)))) + // A bundle whose owner proof fails. + assertNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c).copy(owner = stranger.pubKey)))) + // A handoff whose NIP-40 expiration passed is never decrypted. + val expired = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, expiresAt = 1_000_000L)) + assertNull(inbox.offer(expired, nowSecs = 1_000L)) + assertTrue(inbox.pending.value.isEmpty()) + } + + @Test + fun theDmPipelineSealPathParksTheSameInvite() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + val seal = assertIs(wrap.unwrapOrNull(me)) + val opened = assertNotNull(inbox.offerSeal(wrap.copyNoContent(), seal)) + assertEquals(sender.pubKey, opened.sender) + assertEquals(wrap.id, opened.wrapId) + // The sweep delivering the full wrap later doesn't duplicate it. + assertSame(opened, inbox.offer(wrap)) + } + + @Test + fun declineDiscardsAndTheWrapNeverResurfaces() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + inbox.offer(wrap) + + assertTrue(inbox.decline(wrap.id)) + assertTrue(inbox.pending.value.isEmpty()) + assertEquals(setOf(wrap.id), inbox.declined.value) + assertNull(inbox.offer(wrap)) + assertFalse(inbox.decline(wrap.id)) + + // After a restart the persisted declines are restored and still win. + val fresh = ConcordDirectInviteInbox(me) + fresh.restoreDeclined(inbox.declined.value) + assertNull(fresh.offer(wrap)) + assertTrue(fresh.pending.value.isEmpty()) + } + + @Test + fun sinceRewindsTheCursorByTheBackdateWindow() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + assertNull(inbox.since()) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + inbox.offer(wrap) + assertEquals(wrap.createdAt - 2 * 24 * 60 * 60L, inbox.since()) + } + + @Test + fun visibleHidesJoinedCommunitiesButKeepsCatchUpsAndFlagsExpiry() = + runTest { + val joinedCommunity = community() + val newCommunity = community() + val inbox = ConcordDirectInviteInbox(me) + + val toNew = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(newCommunity, expiresAt = 5_000L)), nowSecs = 1L)) + val plainForJoined = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(joinedCommunity)))) + val catchUp = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(joinedCommunity, channels = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")))))) + val baseMove = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(joinedCommunity, root = "99".repeat(32), channels = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")))))) + + val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, listOf(heldEntryOf(joinedCommunity)), nowMs = 10_000L) + val byWrap = views.associateBy { it.wrapId } + assertEquals(setOf(toNew.wrapId, catchUp.wrapId), byWrap.keys) + assertFalse(plainForJoined.wrapId in byWrap) + assertFalse(baseMove.wrapId in byWrap) + assertTrue(byWrap.getValue(catchUp.wrapId).catchUp) + assertFalse(byWrap.getValue(toNew.wrapId).catchUp) + assertTrue(byWrap.getValue(toNew.wrapId).expired) + assertFalse(byWrap.getValue(catchUp.wrapId).expired) + assertEquals(listOf("vip"), byWrap.getValue(catchUp.wrapId).channelNames) + } + + @Test + fun visibleKeepsOneInvitePerCommunity() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val older = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = 1_700_000_000L))) + val newer = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = 1_700_000_100L))) + assertEquals(2, inbox.pending.value.size) + val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList()) + assertEquals(listOf(newer.wrapId), views.map { it.wrapId }) + assertFalse(older.wrapId in views.map { it.wrapId }) + } + + @Test + fun acceptRefusesAnExpiredInvite() = + runTest { + val c = community() + val opened = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, expiresAt = 5_000L)), me)) + assertEquals(DirectInviteAcceptPlan.Expired, ConcordDirectInviteInbox.acceptPlan(opened, null, null, me.pubKey, nowMs = 5_001L)) + assertEquals(DirectInviteAcceptPlan.Join, ConcordDirectInviteInbox.acceptPlan(opened, null, null, me.pubKey, nowMs = 4_999L)) + } + + @Test + fun acceptOnAHeldCommunityOnlyAddsKeysAndNeverMovesTheBase() = + runTest { + val c = community() + val held = heldEntryOf(c) + val state = stateOf(c) + val grant = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")) + + // Same base, new key: a catch-up that keeps the held base and anchor. + val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant)), me)) + val plan = assertIs(ConcordDirectInviteInbox.acceptPlan(catchUp, held, state, me.pubKey)) + assertEquals(held.root, plan.entry.root) + assertEquals(held.rootEpoch, plan.entry.rootEpoch) + assertEquals(held.controlPk, plan.entry.controlPk) + assertEquals("anchor", plan.entry.inviteRef) + assertEquals(listOf(vip), plan.entry.privateChannels.map { it.channelId }) + + // No fold yet: the ban verdict is unknown, so it waits. + assertEquals(DirectInviteAcceptPlan.RosterNotLoaded, ConcordDirectInviteInbox.acceptPlan(catchUp, held, null, me.pubKey)) + + // Already holding that key: nothing new. + val holding = held.let { ConcordCommunityListEntry(it.id, it.owner, it.ownerSalt, it.root, it.rootEpoch, it.controlPk, privateChannels = listOf(PrivateChannelKey(vip, "db".repeat(32), 0, "vip")), relays = it.relays, name = it.name) } + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, holding, state, me.pubKey)) + + // A different base for a held community is never adopted, keys or not. + val baseMove = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, root = "99".repeat(32), channels = grant)), me)) + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(baseMove, held, state, me.pubKey)) + + // A dissolved community takes no new keys. + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, held, state.withDissolved(true), me.pubKey)) + } + + @Test + fun acceptRefusesACatchUpWhenTheHeldRosterBansUs() = + runTest { + val c = community() + val editions = ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList() + editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, c.controlPlane, c.communityId, me.pubKey, editions, createdAt = 2L, owner = c.ownerPubKey)), c.controlPlane) + val banned = ConcordCommunityState.fold(editions, c.communityId, c.ownerPubKey) + assertTrue(banned.authority.isBanned(me.pubKey)) + + val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")))), me)) + assertEquals(DirectInviteAcceptPlan.Banned, ConcordDirectInviteInbox.acceptPlan(catchUp, heldEntryOf(c), banned, me.pubKey)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDisappearingSessionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDisappearingSessionTest.kt new file mode 100644 index 0000000000..4b073861e0 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDisappearingSessionTest.kt @@ -0,0 +1,245 @@ +/* + * 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.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * CORD-08 Disappearing Messages at the commons layer: what the chat builders tag (§2), the + * session's refusal and sweep (§3), and the timer notice (§4). + */ +class ConcordDisappearingSessionTest { + private val owner = NostrSignerInternal(KeyPair()) + private val day = 86_400L + + private fun entryFor(community: NewConcordCommunity) = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + + /** A community whose timer is [timerSecs], folded into a session that captures what it emits. */ + private suspend fun session( + timerSecs: Long?, + captured: MutableList = mutableListOf(), + ): Pair { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val session = ConcordCommunitySession(entryFor(community), owner.pubKey) { _, _, rumor, _ -> captured += rumor } + community.genesisWraps.forEach { session.ingest(it) } + if (timerSecs != null) { + val standing = session.state.value!!.metadata!! + val edit = ConcordModeration.setMessageExpiration(owner, community.controlPlane, community.communityId, standing, timerSecs, session.controlEditions(), 2L, owner = community.ownerPubKey) + session.ingest(edit) + } + return community to session + } + + private fun opened( + wrap: Event, + plane: GroupKey, + ): Event = ConcordStreamEnvelope.open(wrap, plane).rumor + + private fun wrapExpiration(wrap: Event): String? = wrap.tags.firstOrNull { it[0] == "expiration" }?.get(1) + + @Test + fun theFoldedTimerIsWhatTheSessionSendsWith() = + runTest { + assertNull(session(null).second.messageExpirationSecs(), "no timer until staff set one") + assertEquals(30 * day, session(30 * day).second.messageExpirationSecs()) + } + + @Test + fun everyDurableChatRumorAndItsWrapCarryTheSameExpiration() = + runTest { + val (community, session) = session(day) + val general = community.generalChannelIdHex + val plane = session.currentChannelPlane(general)!! + val timer = session.messageExpirationSecs() + val at = 1_000_000L + val parent = ChannelChat.message(owner.pubKey, general, plane.epoch, "parent", at - 10) + val imeta = listOf(IMetaTagBuilder("https://blossom.example/x").build()) + + val durable = + listOf( + ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "hi", at, timerSecs = timer), + ConcordActions.buildChannelImageMessage(owner, plane.key, general, plane.epoch, "pic", imeta, at, timerSecs = timer), + ConcordActions.buildChannelInlineReply(owner, plane.key, general, plane.epoch, parent, "quote", at, timerSecs = timer), + ConcordActions.buildChannelReply(owner, plane.key, general, plane.epoch, parent, "thread", at, timerSecs = timer), + ConcordActions.buildChannelImageReply(owner, plane.key, general, plane.epoch, parent, "thread pic", imeta, at, timerSecs = timer), + ConcordActions.buildChannelEdit(owner, plane.key, general, plane.epoch, parent, "edited", at, timerSecs = timer), + ConcordActions.buildChannelReaction(owner, plane.key, general, plane.epoch, parent, "+", at, timerSecs = timer), + ) + for (wrap in durable) { + val rumor = opened(wrap, plane.key) + assertEquals(at + day, ConcordDisappearing.expirationOf(rumor), "kind ${rumor.kind} signs the deadline") + assertEquals((at + day).toString(), wrapExpiration(wrap), "kind ${rumor.kind}'s wrap repeats it") + assertEquals("p", wrap.tags[0][0], "the random p stays first") + } + + // Exempt: deletes, timer notices, typing — neither inside nor outside. + val exempt = + listOf( + ConcordActions.buildChannelDelete(owner, plane.key, general, plane.epoch, listOf(parent), at), + ConcordActions.buildChannelTimerNotice(owner, plane.key, general, plane.epoch, day, at), + ConcordActions.buildChannelTyping(owner, plane.key, general, plane.epoch, at), + ) + for (wrap in exempt) { + assertNull(ConcordDisappearing.expirationOf(opened(wrap, plane.key))) + assertNull(wrapExpiration(wrap)) + } + + // Timer off: nothing anywhere. + val off = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "forever", at, timerSecs = null) + assertNull(ConcordDisappearing.expirationOf(opened(off, plane.key))) + assertEquals(listOf("p"), off.tags.map { it[0] }) + } + + @Test + fun anAlreadyExpiredRumorIsRefusedAndItsWrapPurged() = + runTest { + val captured = mutableListOf() + val (community, session) = session(day, captured) + val general = community.generalChannelIdHex + val plane = session.currentChannelPlane(general)!! + val longAgo = TimeUtils.now() - 2 * day + val stale = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "stale", longAgo, timerSecs = day) + + session.ingest(stale) + assertTrue(captured.none { it.content == "stale" }, "never stored (CORD-08 §3)") + // The one-shot readers refuse it too (what `amy concord read` prints). + assertTrue(ConcordActions.channelMessages(listOf(stale), plane.key, general, plane.epoch).isEmpty()) + assertNull(ConcordActions.openChannelRumor(stale, plane.key, general, plane.epoch)) + + // Queued for an immediate sweep, which takes the wrap out of the buffer. + assertTrue(session.nextExpiry.value!! <= TimeUtils.now()) + val swept = session.sweepExpired() + assertEquals(listOf(stale.id), swept.map { it.wrapId }) + assertFalse(session.isBuffered(general, stale.id)) + assertNull(session.nextExpiry.value) + } + + @Test + fun theSweepDropsALiveRumorFromTheBufferWhenItExpires() = + runTest { + val captured = mutableListOf() + val (community, session) = session(day, captured) + val general = community.generalChannelIdHex + val plane = session.currentChannelPlane(general)!! + val now = TimeUtils.now() + val live = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "for a day", now, timerSecs = day) + val forever = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "forever", now) + + session.ingest(live) + session.ingest(forever) + val rumor = captured.single { it.content == "for a day" } + assertEquals(now + day, session.nextExpiry.value) + + // Not yet due: nothing moves. + assertTrue(session.sweepExpired(now).isEmpty()) + assertTrue(session.isBuffered(general, live.id)) + + // Due: the wrap leaves the buffer (no re-projection can bring it back); the untagged one stays. + val swept = session.sweepExpired(now + day) + assertEquals(listOf(rumor.id), swept.map { it.rumorId }) + assertEquals(listOf(live.id), swept.map { it.wrapId }) + assertFalse(session.isBuffered(general, live.id)) + assertTrue(session.isBuffered(general, forever.id)) + assertNull(session.nextExpiry.value) + } + + @Test + fun theManagerSchedulesOnTheEarliestDeadlineAndSweepsPerCommunity() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val manager = ConcordSessionManager(MutableStateFlow(listOf(entryFor(community))), owner.pubKey, backgroundScope) + testScheduler.runCurrent() + community.genesisWraps.forEach { manager.ingest(it) } + testScheduler.runCurrent() + assertNull(manager.nextExpiry.value, "nothing expires: the sweep never wakes") + + val general = community.generalChannelIdHex + val plane = manager.sessionFor(community.communityIdHex)!!.currentChannelPlane(general)!! + val now = TimeUtils.now() + val wrap = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "for a day", now, timerSecs = day) + manager.ingest(wrap) + testScheduler.runCurrent() + assertEquals(now + day, manager.nextExpiry.value) + + assertTrue(manager.sweepExpired(now).isEmpty()) + val swept = manager.sweepExpired(now + day) + assertEquals(listOf(wrap.id), swept[community.communityIdHex]!!.map { it.wrapId }) + testScheduler.runCurrent() + assertNull(manager.nextExpiry.value) + } + + @Test + fun aTimerNoticeIsATypedChatRumorBelievedOnlyFromStaff() = + runTest { + val captured = mutableListOf() + val (community, session) = session(null, captured) + val general = community.generalChannelIdHex + val plane = session.currentChannelPlane(general)!! + + session.ingest(ConcordActions.buildChannelTimerNotice(owner, plane.key, general, plane.epoch, 30 * day, TimeUtils.now())) + val notice = assertIs(captured.single()) + assertEquals(30 * day, notice.timerSecs()) + + val authority = session.state.value!!.authority + assertTrue(ConcordDisappearing.isBelievedNotice(notice, authority), "the owner holds MANAGE_METADATA") + + val rando = NostrSignerInternal(KeyPair()) + val forged = ConcordDisappearing.timerNotice(rando.pubKey, general, plane.epoch, 0, TimeUtils.now()) + assertFalse(ConcordDisappearing.isBelievedNotice(forged, authority), "anyone can spell the tag") + + val malformed = ChannelChat.message(owner.pubKey, general, plane.epoch, "", TimeUtils.now(), arrayOf(arrayOf("timer", "soon"))) + assertFalse(ConcordDisappearing.isBelievedNotice(malformed, authority)) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index a18f22e6bf..336560bf04 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -79,7 +79,7 @@ सन्देश आपके रहस्यीकृत सीधे सन्देश यहाँ दिखेंगे। सूचनाएँ - उल्लेख प्रत्युत्तर तथा प्रतिक्रियाएँ यहाँ दिखेंगे। + उल्लेख प्रतिवचन तथा प्रतिक्रियाएँ यहाँ दिखेंगे। प्रयोक्ता परिचायकचित्र मार्गदर्शन @@ -327,6 +327,7 @@ स्थान जाना निश्चित सम्भाव्य + घटना आवहन… नहीं जा सकते %1$d घटना @@ -1014,11 +1015,11 @@ क्या %1$s के रूप में प्रवेशांकन करें। पूछता है आप कौन हैं प्रवेशांकन बिना स्वीकार नहीं करेगा आपका सन्देश %1$s के प्रति। - %1$s को आपका प्रत्युत्तर प्राप्त नहीं होगा जबतक आप प्रवेशांकन नहीं करते। + %1$s को आपका प्रतिवचन प्राप्त नहीं होगा जबतक आप प्रवेशांकन नहीं करते। %1$s से पत्र प्राप्त नहीं होंगे जबतक आप प्रवेशांकन नहीं करते। प्रवेशांकन बिना आपका पत्र स्वीकार नहीं करेगा %1$s में। %1$s प्राप्त नहीं होगा जबतक आप प्रवेशांकन नहीं करते। - आपको भेजे गए प्रत्युत्तर तथा ज्साप तथा सन्देश इससे प्राप्त नहीं होंगे जबतक आप प्रवेशांकन नहीं करते। + आपको भेजे गए प्रतिवचन तथा ज्साप तथा सन्देश इससे प्राप्त नहीं होंगे जबतक आप प्रवेशांकन नहीं करते। शेष वार्तालाप इससे प्राप्त नहीं होगा जबतक आप प्रवेशांकन नहीं करते। %1$s के साथ शेष वार्तालाप इससे प्राप्त नहीं होगा जबतक आप प्रवेशांकन नहीं करते। यह आपके पुनःप्रसारकों में से एक है। तथा सबको प्रवेशांकन करने को कहता है। @@ -1373,13 +1374,13 @@ ज्साप अतिरिक्त नोस्ट्र में कोई पदचिह्न नहीं, केवल लैटनिंग पर नामरहित - स्थानीय ब्लोस्सम॰ द्रुतस्मृति का प्रयोग करें - जब एक ब्लोस्सम॰ द्रुतस्मृति चल रही है इस यन्त्र पर (संयोजनद्वार २४२४२) तब चित्र चलचित्र अवरोहण उसके द्वारा करें। + स्थानीय ब्लोस्सम द्रुतस्मृति का प्रयोग करें + जब एक ब्लोस्सम द्रुतस्मृति चल रही है इस यन्त्र पर (संयोजनद्वार २४२४२) तब चित्र चलचित्र अवरोहण उसके द्वारा करें। स्थानीय द्रुतस्मृति का पता चला संयोजनद्वार २४२४२ पर। स्थानीय द्रुतस्मृति का पता नहीं चला संयोजनद्वार २४२४२ पर। केवल परिचय चित्रों को द्रुतस्मृति में रखें स्थानीय द्रुतस्मृति को केवल परिचय चित्रों तक सीमित रखें। सूचनावली चित्र तथा चलचित्र सीधे मूल सेवासंगणकों से प्राप्त किए जाएँगे। - आपका कोई ब्लोस्सम॰ प्रसारसंगणक स्थापित नहीं। आप अमेथिस्ट की सूची का प्रयोग कर सकते हैं अथवा नीचे एक जोड सकते हैं ↓ + आपका कोई ब्लोस्सम प्रसारसंगणक स्थापित नहीं। आप अमेथिस्ट की सूची का प्रयोग कर सकते हैं अथवा नीचे एक जोड सकते हैं आरोहण व्यवहार प्रतिबिम्ब आरोहण आरोहण पश्चात अभिलेख की अनुकृति आपके अन्य ब्लोस्सम सेवासंगणकों में करें जिससे वह उपलब्ध रहेगा एक संगणक असंयोजित होने पर भी। @@ -1406,14 +1407,14 @@ अधिक क्रियाएँ अभिलेख आयात… अभिलेख आयात - अन्य ब्लोस्सम॰ सेवासंगणकों की जाँच करें अभिलेखों के लिए जिनका आरोहण आपने अन्यत्र किए तथा उन्हें अपने स्वयम के सेवासंगणकों में अनुकृति करके रखें। + अन्य ब्लोस्सम सेवासंगणकों की जाँच करें अभिलेखों के लिए जिनका आरोहण आपने अन्यत्र किए तथा उन्हें अपने स्वयम के सेवासंगणकों में अनुकृति करके रखें। सेवासंगणक जाँच करने के लिए अथवा सेवासंगणक पता चिपकाएँ सेवासंगणक जाँच जाँच चालू… यह सेवासंगणक अभिगम्य नहीं कोई नए अभिलेख प्राप्त नहीं चयनित सेवासंगणकों पर। - अपने ब्लोस्सम॰ सेवासंगणकों को पहले जोडें। जिससे कि कोई स्थान हो आयातित अभिलेखों की अनुकृति करके रखने के लिए। + अपने ब्लोस्सम सेवासंगणकों को पहले जोडें। जिससे कि कोई स्थान हो आयातित अभिलेखों की अनुकृति करके रखने के लिए। मेरे सेवासंगणकों का प्रबन्धन मूलविकल्प सूची का प्रयोग करें श्रव्यदृश्याभिलेख सेवासंगणक जोडें @@ -1794,7 +1795,7 @@ पुनःप्रसारक पता वीक्षण यह पुनःप्रसारक अभिगम्य नहीं टोर॰ पर - %1$s से कोई प्रत्युत्तर नहीं टोर॰ द्वारा। इसका जालनिवास सम्भाव्यतः टोर॰ निर्गमन जालबिन्दुओं को अवरुद्ध कर रहा है। क्या इसके स्थान पर स्पष्टजाल पर संयोजन करें। + %1$s से कोई प्रतिवचन नहीं टोर॰ द्वारा। इसका जालनिवास सम्भाव्यतः टोर॰ निर्गमन जालबिन्दुओं को अवरुद्ध कर रहा है। क्या इसके स्थान पर स्पष्टजाल पर संयोजन करें। स्पष्टजाल का उपयोग करें पुनःप्रसारक जिन पर आप हैं लोकप्रिय पुनःप्रसारक @@ -2138,8 +2139,8 @@ विवरण शीर्षक अनिवार्य। इस दिनदर्शिका में कोई घटनाएँ नहीं अब तक। - शीघ्रप्रत्युत्तर (%1$d) - कोई शीघ्रप्रत्युत्तर नहीं अब तक। + याचित प्रतिवचन। %1$d + कोई याचित प्रतिवचन नहीं अब तक। सहभागी (%1$d) दिनदर्शिकाओं में (%1$d) किसी भी दिनदर्शिका का भाग नहीं अब तक। @@ -2307,7 +2308,7 @@ आप किस विषय पर चर्चा करना चाहते हैं। विषय प्रकाशन मंच सूत्र - कोई प्रत्युत्तर नहीं अब तक। सबसे पहले उत्तर देनेवाले बनें। + कोई प्रतिवचन नहीं अब तक। सबसे पहले उत्तर देनेवाले बनें। सीधे सन्देश नया सन्देश कोई सीधेसन्देश नहीं अब तक @@ -2883,7 +2884,7 @@ पुनःप्रसारक से जुडने का अनुरोध पुनःप्रसारक से चले जाने का अनुरोध हस्ताक्षर - सन्देश के अन्त में जोडा जाएगा नए पत्र प्रत्युत्तर उद्धरण अथवा निबन्ध खोलते समय। रिक्त छोड दें अक्षम करने के लिए। + सन्देश के अन्त में जोडा जाएगा नए पत्र प्रतिवचन उद्धरण अथवा निबन्ध खोलते समय। रिक्त छोड दें अक्षम करने के लिए। आपका हस्ताक्षर कठिनाई एक निप॰१३ श्रमप्रमाण का खनन करता है आपके पत्रों के लिए प्रकाशन पूर्व जिससे पुनःप्रसारक तथा पढनेवाले उसका तोलन कर सके कचरालेख के प्रति। उच्चतर मूल्यों के लिए घातांकीयतः अधिक समय लगेगा। पत्र प्रकाशित होंगे अदृश्यतः खनन पश्चात। @@ -3153,7 +3154,7 @@ %1$d छलनियाँ आरोपित नहीं अब तक जोडें - ब्लोस्सम॰ प्रसारसंगणक जोडें + ब्लोस्सम प्रसारसंगणक जोडें निप॰-९६ सेवासंगणक जोडें जब सक्षम अमेथिस्ट निप॰८९ ग्राहक सूचक जोडेगा आपके द्वारा प्रकाशित घटनाओं में। मेरे घटनाओं में ग्राहक सूचक जोडें @@ -3424,6 +3425,13 @@ दिनदर्शिका अनुस्मारक दिन सूचनावली + मित्र जो जा रहे हैं + कोई योजना नहीं अब तक + जब इस सूची में लोग जाना निश्चित प्रतिवचन देते हैं एक आगामी घटना के लिए तब वह यहाँ प्रकट होता है। + + %1$d जाना निश्चित + %1$d जाना निश्चित + मास सप्ताह स्वीकार @@ -3570,7 +3578,7 @@ नयी कोंकोर्ड प्रणाली समुदाय सम्पादन सन्देश सम्पादन - कोंकोर्ड॰ प्रणाली + कोंकोर्ड प्रणाली लोगों को आमन्त्रण आमन्त्रण अप्राप्त। सम्भाव्यतः योजक निष्क्रिय है अथवा इसके पुनःप्रसारक अनभिगम्य। इस समुदाय ने आपको हटा दिया। योजक अब भी कार्य करता है पर इसके सदस्यसूची में आप स्वीकृत नहीं। @@ -3852,7 +3860,7 @@ पक्षी अवलोकन घटनाएँ चतुरंग खेल वर्गीकृत विज्ञापन - टिप्पणियाँ तथा प्रत्युत्तर + टिप्पणियाँ तथा प्रतिवचन अस्थायी चर्चाएँ धनसंग्रहण प्रमुखताएँ @@ -3864,6 +3872,7 @@ पुटप्रसार मतदान मूल्यांकन तथा समीक्षाएँ + घटना याचित प्रतिवचन पुनःप्रकाशन लघु चलचित्र लेखीय टीकाएँ @@ -3931,7 +3940,7 @@ क्रमक अनुशंसाएँ समयनियुक्ति क्रमक - समयनियुक्ति प्रत्युत्तर अपेक्षित + समयनियुक्ति याचित प्रतिवचन लेख सूची ध्वनि शीर्षक ध्वनि अभिलेख @@ -3942,8 +3951,8 @@ द्व्यंकीय अभिलेख शीर्षक बाधित पुनःप्रसारक जाललेख - ब्लोस्सम॰ प्रमाणीकरण - ब्लोस्सम॰ प्रसारसंगणक + ब्लोस्सम प्रमाणीकरण + ब्लोस्सम प्रसारसंगणक स्मर्त्तव्य सूची स्मर्त्तव्य सूची प्रसारण पुनःप्रसारक @@ -3974,10 +3983,10 @@ सीधा संदेश पुनःप्रसारक पाण्डुलिपियाँ डीवीएम॰ विषयवस्तु अनुरोध - डीवीएम॰ विषयवस्तु प्रत्युत्तर + डीवीएम॰ विषयवस्तु प्रतिवचन डीवीएम॰ स्थिति डीवीएम॰ प्रयोक्ता अनुरोध - डीवीएम॰ प्रयोक्ता प्रत्युत्तर + डीवीएम॰ प्रयोक्ता प्रतिवचन सम्पादन भावचिह्न पोटली सूची भावचिह्न पोटलियाँ @@ -4030,7 +4039,7 @@ नोस्टर संयोजन टीकाएँ नोस्टर धनकोष अनुरोध - नोस्टर धनकोष प्रत्युत्तर + नोस्टर धनकोष प्रतिवचन पुरानी स्मर्त्तव्यचिह्न सूची ओटीएस॰ निर्गतपेटिका पुनःप्रसारक @@ -4208,7 +4217,7 @@ ध्वनि सक्रिय। मौन करने के लिए टाँकें सूचनाएँ मौन करें मौन किया गया। अमौन करने के लिए टाँकें - मेरे ब्लोस्सम॰ अभिलेख + मेरे ब्लोस्सम अभिलेख मेरा स्वास्थ्य मेरे सूचियाँ नाम अनिवार्य @@ -4225,7 +4234,7 @@ पुनःप्रसारक पढें हस्ताक्षर करें तथा प्रकाशित करें आपके घटनाओं को जाल - जाल तथा ब्लोस्सम॰ संसाधन ले आएँ + जाल तथा ब्लोस्सम संसाधन ले आएँ रहस्यीकरण निजी सन्देशों का रहस्यीकरण तथा अरहस्यीकरण करें अपनी कुंचिका से भण्डार @@ -4478,8 +4487,8 @@ %1$d सम्पत्ति समावेशित %1$d सम्पत्ति समावेशित - ब्लोस्सम॰ क्रमक प्राप्त नहीं। कृपया एक स्थानीय ब्लोस्सम॰ क्रमक की स्थापना करें इस अभिलेख को देखने के लिए - ब्लोस्सम॰ योजक खोला नहीं जा सकता + ब्लोस्सम क्रमक प्राप्त नहीं। कृपया एक स्थानीय ब्लोस्सम क्रमक की स्थापना करें इस अभिलेख को देखने के लिए + ब्लोस्सम योजक खोला नहीं जा सकता कोई चित्रग्राहक क्रमक उपलब्ध नहीं इस यन्त्र में कोई लैटनिंग पता स्थापित नहीं कोई क्रमक प्राप्त नहीं जो इस भुगतान को सम्भाल सके। कृपया अनुकूल धनकोष स्थापित करें। @@ -4637,8 +4646,8 @@ %1$d मतदाता आपकी मौनसूची द्वारा अदृश्यकृत। - %1$d प्रत्युत्तर मान्य विकल्प का चयन नहीं किया तथा बहिष्कृत है। - %1$d प्रत्युत्तर मान्य विकल्प का चयन नहीं किए तथा बहिष्कृत हैं। + %1$d प्रतिवचन मान्य विकल्प का चयन नहीं किया तथा बहिष्कृत है। + %1$d प्रतिवचन मान्य विकल्प का चयन नहीं किए तथा बहिष्कृत हैं। %1$d मत आया मतदान समाप्त होने के पश्चात तथा बहिष्कृत है। @@ -4648,8 +4657,8 @@ %1$d मतदाता %1$d मतदाता - %2$d में से %1$d प्रत्युत्तरों का आवहन हो गया %3$d पुनःप्रसारकों से। - लगभग %2$d में से %1$d प्रत्युत्तरों का आवहन हो गया %3$d पुनःप्रसारकों से। + %2$d में से %1$d प्रतिवचनों का आवहन हो गया %3$d पुनःप्रसारकों से। + लगभग %2$d में से %1$d प्रतिवचनों का आवहन हो गया %3$d पुनःप्रसारकों से। %1$d चयन %1$d चयन @@ -4667,7 +4676,7 @@ इस पत्र मे %1$d से अधिक विषयसूचक हैं टिप्पणियाँ - निबन्ध अभिलेख तथा अन्य विषयवस्तुओं के लिए प्रत्युत्तर + निबन्ध अभिलेख तथा अन्य विषयवस्तुओं के लिए प्रतिवचन निजी सन्देश कोष सीधासन्देश पुनःप्रसारकों को आगतपेटिका कचरालेख छालन करने में सक्षम करता है। केवल बाहरी कोष का खनन होता है। आपका सन्देश कभी नहीं दीर्घरूप तथा उद्दीप्तव्य @@ -4682,10 +4691,10 @@ पुनःप्रसारक वृत्तान्तों का तोलन करते हैं उनके परिव्यय के अनुसार पुनःप्रकाशन उच्च मात्रा सक्रिय प्रयोक्ताओं के लिए - लघु टीकाएँ तथा प्रत्युत्तर + लघु टीकाएँ तथा प्रतिवचन प्राथमिक सार्वजनिक कचरालेख रूप ध्वनि सन्देश - सार्वजनिक ध्वनि पत्र तथा प्रत्युत्तर + सार्वजनिक ध्वनि पत्र तथा प्रतिवचन %1$d दिन %1$d दिन @@ -4808,7 +4817,7 @@ कभी नहीं कभी नही तथा सभी पुनःप्रसारक - अमेथिस्ट पुनःप्रसारकों को आप %1$s हैं बताना रोकेगा तथा आपको पूछना भी रोकेगा। कुछ पुनःप्रसारक आपकी सेवा करना रोकेंगे। सन्देश प्रत्युत्तर तथा सूचनाएँ सम्भाव्यतः प्राप्त नहीं होंगे। पुनःप्रसारक जिनपर आपने सर्वदा प्रवेशांकन करने का विकल्प स्थापित किए हैं वैसे ही रहेंगे। आप इसका परिवर्तन कर सकते हैं पुनःप्रसारक प्रवेशांकन के नीचे। + अमेथिस्ट पुनःप्रसारकों को आप %1$s हैं बताना रोकेगा तथा आपको पूछना भी रोकेगा। कुछ पुनःप्रसारक आपकी सेवा करना रोकेंगे। सन्देश प्रतिवचन तथा सूचनाएँ सम्भाव्यतः प्राप्त नहीं होंगे। पुनःप्रसारक जिनपर आपने सर्वदा प्रवेशांकन करने का विकल्प स्थापित किए हैं वैसे ही रहेंगे। आप इसका परिवर्तन कर सकते हैं पुनःप्रसारक प्रवेशांकन के नीचे। क्या किसी भी पुनःप्रसारक में प्रवेशांकन ना करें। सर्वदा प्रवेशांकन प्रत्येक पुनःप्रसारक जो पूछता है। @@ -4818,7 +4827,7 @@ कुछ पुनःप्रसारक आपकी सेवा करना नहीं स्वीकारेंगे। आपकी आगतपेटिका आपका अपना पुनःप्रसारक - आपके प्रत्युत्तर + आपके प्रतिवचन इस पुनःप्रसारक एक शाला जिसमें आप पत्र प्रकाशित करते हैं लोग जिन्हें आप पढते हैं @@ -4834,7 +4843,7 @@ पुनःप्रसारक जिनपर प्रत्येक समुदाय अपने समतलों को प्रकाशित करते हैं। आपके सीधासन्देश पुनःप्रसारक। जहाँ उपहारकोषयुक्त सन्देश भेजे जाते हैं। समूह सन्देश तथा कुंचिकापेटलियाँ। प्रत्येक समूह के पुनःप्रसारकों पर। - घटनाओं का अवलोकन करता है जो वर्तमान में प्रदर्शित हो रहे हैं नए प्रत्युत्तर प्रतिक्रियाएँ उद्धरण ज्साप तथा वृत्तान्तों के लिए जिससे गिनतियों का नवीकरण होता है जब आप पढ रहे हैं। + घटनाओं का अवलोकन करता है जो वर्तमान में प्रदर्शित हो रहे हैं नए प्रतिवचन प्रतिक्रियाएँ उद्धरण ज्साप तथा वृत्तान्तों के लिए जिससे गिनतियों का नवीकरण होता है जब आप पढ रहे हैं। चर्चाशालाएँ जो कोई इतिहास नहीं रखते। सन्देश केवल तब तक रहते हैं जब तक आप संयोजित हैं। इसलिए ये ग्राहकता बनाए रखते हैं कुछ भी प्राप्त होने के लिए। अनुचरण सूचियाँ। आपकी सूचनावली तथा आपका विश्वासजाल का निर्माण के लिए उपयुक्त। स्थान आधारित शालाएँ उन क्षेत्रों के लिए जिनका आप अनुगमन करते हैं। पृष्ट उन पुनःप्रसारको से जो इनके जालावास हैं। @@ -4848,7 +4857,7 @@ आपके संयोजित धनकोष से सूचनाएँ। वर्तमानतः पटल पर लोगों के परिचय। मुख्य पुनःप्रसारक प्रत्येक चर्चा का जिन्हें आप खोल रखे हैं अथवा जिनसे आप जुड चुके हैं। - घटनाओं को सूचक अनुसार ले आता है जिसका उल्लेख आपके पटल पर अमुक करता है पर जिसकी प्राप्ती अभी नहीं हुई। एक उद्धरण अथवा एक प्रत्युत्तर का पूर्वपत्र अथवा एक सूत्र का मूल। + घटनाओं को सूचक अनुसार ले आता है जिसका उल्लेख आपके पटल पर अमुक करता है पर जिसकी प्राप्ती अभी नहीं हुई। एक उद्धरण अथवा एक प्रतिवचन का पूर्वपत्र अथवा एक सूत्र का मूल। निप॰२९ समूह जिनसे आप जुडे हैं। प्रत्येक समूह एक जालावास पुनःप्रसारक में रहता है। इसलिए क्रमक प्रत्येक पुनःप्रसारक से संयोजन करता है जो आपके किसी समूह का जालावास है। खोजता है किन पुनःप्रसारकों पर प्रत्येक व्यक्ति प्रकाशन करता है। जिससे कि उनके पत्र सम्यक स्थल से प्राप्प हो। आपके अपने धनकोष घटनाएँ। पुनःपठित उन पुनःप्रसारकों से जिनपर आपने उनके प्रकाशन किए। diff --git a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml index 3d7081f80a..7699cf40a3 100644 --- a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml @@ -327,6 +327,7 @@ Helyszín Ott leszek Talán + Esemény betöltése… Nem leszek ott %1$d esemény @@ -3424,6 +3425,13 @@ Naptár-emlékeztetők Nap Hírfolyam + A barátai részt vesznek rajta + Még nincsenek tervek + Amikor a listán szereplő személyek jelzik, hogy részt vesznek egy közelgő eseményen, az itt jelenik meg. + + %1$d ember vesz részt rajta + %1$d ember vesz részt rajta + Hónap Hét Elfogadás @@ -3864,6 +3872,7 @@ Podcastok Szavazások Értékelések és megjegyzések + Esemény-visszajelzések Továbbosztások Rövid videók Szöveges jegyzetek @@ -4126,7 +4135,7 @@ Ön nyilvános kulcsot használ, és a nyilvános kulcsok csak olvashatóak. Jelentkezzen be a privát kulccsal a hozzászólások kedveléséhez Videók p - Saját emodzsicsomagok + Emodzsicsomagok Fotó hozzáadása %1$s hozzáadása… Adminisztrátori jogosultságok megadva @@ -4208,9 +4217,9 @@ Hang bekapcsolva. Koppintson a némításhoz Értesítések némítása Némítva. Koppintson a némitás megszüntetéséhez - Az én Blossom fájljaim + Blossom fájlok Fitnesz - Saját listák + Listák Név megadása kötelező %1$d ms Namecoin-beállítások diff --git a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index 2a9bf95377..480ed9bac4 100644 --- a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -339,6 +339,7 @@ Lokalizacja Będę Może + Ładowanie wydarzenia… Nie mogę iść %1$d wydarzenie @@ -601,6 +602,7 @@ Dodaj baner Miejsce, w którym publikowane i czytane są zaszyfrowane plany tej społeczności. Ta społeczność została rozwiązana i jest tylko do odczytu. Nadal możesz przeczytać jej historię, ale nie można opublikować żadnych nowych wiadomości. + To jest kanał prywatny, a ty nie posiadasz klucza dostępu do niego, więc nie możesz czytać ani publikować w nim postów. Nazwa Informacje (opcjonalne) Banuj @@ -3484,6 +3486,15 @@ Przypomnienia z kalendarza Dzień Notatki + Przyjaciele zbierają się + Brak planów + Gdy osoby z tej listy potwierdzą udział w nadchodzącym wydarzeniu, informacja ta pojawi się tutaj. + + %1$d idzie + %1$d idzie + %1$d idzie + %1$d idą + Miesiąc Tydzień Akceptuj @@ -3960,6 +3971,7 @@ Podcasty Ankiety Oceny i recenzje + Wydarzenie RSVPs Reposty Filmiki Notatki tekstowe @@ -5781,6 +5793,7 @@ Zezwól podczas odwiedzin Tylko tym razem Nie zezwalaj + %1$s oznajmia Na tej stronie napisano Nie pozwól tej stronie wyświetlać więcej dialogów Twoja odpowiedź @@ -6270,6 +6283,12 @@ Odpowiedział, ale niczego nie nazwał. Pokaż wszystkie %1$d Pokaż mniej + + Pokaż %1$d który przestał nadawać komunikaty + Pokaż %1$d, które przestały nadawać komunikaty + Pokaż %1$d, które przestały nadawać komunikaty + Pokaż %1$d, które przestały nadawać komunikaty + Od ponad miesiąca nie pojawiały się żadne komunikaty. Próba utworzenia grupy na platformie, która przestała działać, zakończy się niepowodzeniem. Utwórz Grupę @@ -6347,6 +6366,69 @@ Zaakceptowane przez koordynatora Zrozumiałe Co ten koordynator może zobaczyć + Informacje o grupie + Ta grupa jest niedostępna. Być może jej koordynator został usunięty. + Dodaj koordynatora w Ustawieniach, aby zacząć korzystać z grup cordn. + + %1$d grupa + %1$d grup + %1$d grup + %1$d grupy + + Nic nie pojawia się z tych grup. Amethyst nieustannie próbuje. + Koordynatorzy + Brak grup cordn + Grupa typu „cordn” opiera się na jednym koordynatorze, który zarządza listą członków i przekazuje wiadomości, nie mając przy tym wglądu w treść wpisów poszczególnych użytkowników. + Otwórz grupę + Dodaj kogoś + Nie znaleziono nikogo o tym imieniu. + Można ich dodać tylko wtedy, gdy opublikowali pakiet kluczy dla tego koordynatora. + Nazwa, npub lub nazwa@domena + Administrator + + Administrator grupy %1$d pozostaje bez zmian. + Administratorów grupy %1$d pozostaje bez zmian. + Administratorów grupy %1$d pozostaje bez zmian. + Administratorzy grupy %1$d pozostają bez zmian. + + Koordynator + Klucz koordynatora + Link koordynatora + Skopiuj nprofil koordynatora + Edytuj szczegóły + Ta grupa nie ma administratorów: każdy może dodawać, usuwać i zmieniać nazwę, na stałe. + Epoka + Id grupy + Można to dodać w tym koordynatorze + Członkowie + %1$s przestaje otrzymywać wiadomości z tego źródła. Zachowuje już przeczytane wiadomości, a tej zmiany nie da się cofnąć — trzeba by ponownie wysłać mu zaproszenie. + Usunąć z grupy? + Usuń + Zapisz + Szczegóły techniczne + Dołącz + Odrzuć + Odmowa jest ostateczna. Powrót oznacza, że trzeba ponownie otrzymać zaproszenie. + Sprawdzane jest to tylko w momencie otwarcia tego ekranu, a nie w żadnym innym momencie — zapytanie koordynatora, czy ktoś cię zaprosił, informuje system o twojej obecności, więc Amethyst nie wykonuje tej czynności w tle. + Nie można odczytać zaproszeń. + + %1$d osoba jest już w tej grupie + %1$d osób jest już w tej grupie + %1$d osób jest już w tej grupie + %1$d osoby są już w tej grupie + + +%1$d więcej + Nie masz jeszcze wyznaczonego koordynatora. Zaproszenia do cordn przychodzą za pośrednictwem koordynatora, więc na razie nie ma gdzie ich szukać. + Brak oczekujących zaproszeń. + Sprawdź ponownie + Zaproszenie zostało wysłane na inne urządzenie + Zaproszenia do Cordn + %1$s nie odpowiedział + poprzez %1$s + Publikowanie podpisuje rekord pod własnym kluczem konta. Koordynator wie wtedy że to konto istnieje i jest niezmienne — na stałe i czy ktoś Cię zaprasza czy nie. + Inni mogą dodać cię do grupy cordn wyłącznie poprzez pobranie pakietu kluczy, który opublikowałeś na tym koordynatorze. cordn nie ma innego miejsca, w którym mógłby go przechowywać, więc jeśli nic nie opublikujesz, nikt nie będzie mógł cię zaprosić — i nikt nie będzie wiedział dlaczego. + Nie można odczytać pakietów kluczy. + Brak pakietu awaryjnego. Gdy skończą się zaproszenia jednorazowego użytku, wysyłanie zaproszeń nie będzie możliwe. Opublikowano pakiet na wypadek ostateczności, dzięki czemu nadal będzie można wysłać zaproszenie, gdy skończą się zaproszenia jednorazowego użytku. %1$d pakiet, którego to urządzenie nie może otworzyć @@ -6357,4 +6439,63 @@ Koordynator przekaże je każdemu, kto cię zaprosi, ale część prywatna nie znajduje się na tym urządzeniu — należy ona do innej instalacji. Jeśli ta instalacja zniknie, zaproszenie wysłane przy użyciu jednego z tych plików spowoduje wygenerowanie wiadomości powitalnej, której nikt nigdy nie będzie mógł otworzyć. Opublikuj jeden Opublikuj ostatnią instancję + + %1$d dostępny jednorazowy pakiet. + %1$d dostępnych pakietów jednorazowych. + %1$d dostępnych pakietów jednorazowych. + %1$d dostępne jednorazowe pakiety. + + Po opublikowaniu tutaj Amethyst po cichu wymienia pakiety w miarę ich zużywania. Nigdy nie publikuje za Ciebie pierwszego z nich. + Wycofaj wszystkie + Wycofaj te + Poproś o dołączenie do grupy + Spowoduje to opublikowanie pakietu kluczy przy użyciu klucza Twojego konta i poinformuje koordynatora, że chcesz dołączyć do tej grupy — niezależnie od tego, czy ktoś odpowie, czy nie. + Nie można poprosić o dołączenie. + Zapytano. Jeden z członków grupy musi Cię dodać; zaproszenie pojawi się w sekcji „Zaproszenia cordn”. + Skanuj + Nie można otworzyć tego pliku. + Dla tego konta nie skonfigurowano żadnego serwera multimediów, więc nie ma gdzie umieścić pliku. Wybierz serwer w sekcji „Ustawienia”. + Nie można odczytać tego pliku. + Nie można wysłać tego pliku. + + %1$d członek + %1$d członków + %1$d członków + %1$d członkowie + + Wiadomość usunięta + Koordynator + Kursor + Wysłano + Szczegóły wiadomości + edytowano + Przypięte przez %1$s + + %1$d przypięta wiadomość w tej grupie + %1$d przypiętych wiadomości w tej grupie + %1$d przypiętych wiadomości w tej grupie + %1$d przypięte wiadomości w tej grupie + + Następna przypięta wiadomość + Poprzednia przypięta wiadomość + Pokaż wszystkie przypięte wiadomości + Przypięte wiadomości + Reakcje + Dodaj + Tylko administrator może wyrazić zgodę. W grupie, w której nie ma administratorów, są to wszyscy członkowie. + Sprawdzanie żądań + Zignoruj + Nie można odczytać żądań. + Nikt nie czeka na dołączenie. + Prośby o dołączenie + Wyślij + Nie udało się wysłać. + Koordynator tej grupy nie jest dostępny, więc na razie nie można niczego wysłać. + Skopiowano + Kopiuj link + Każdy, kto posiada ten link, może poprosić o dołączenie do grupy. Administrator musi jednak zatwierdzić tę prośbę. + Udostępnij tę grupę + Odtwórz wiadomość głosową + Nagraj wiadomość głosową + Zatrzymaj i wyślij diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 4103af3df7..799c2bf27a 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -590,6 +590,14 @@ Revoke this link? Anyone still holding this link will no longer be able to join. People who already joined with it stay in the community. This can't be undone. Revoke + This is the community's last live invite link. Revoking it makes the community Private and rotates its keys, so only current members keep access. + Link revoked. The community is now Private and its keys were rotated. + Link revoked. The community is now Private, but its keys were not rotated: an admin who can ban members needs to rotate them. + Private + + Public · %1$d live invite link + Public · %1$d live invite links + Community name is only revealed after you join Joining connects to this invite's relays, publishes a join announcement signed by your account, and adds the community to your list. Nothing is sent until you tap Join. Relays this invite will contact: %1$s @@ -598,8 +606,25 @@ Open channel Add a banner Where this community's encrypted planes are published and read. + Disappearing messages + New messages in every channel are deleted from members' devices and from relays after this long. Changing it doesn't affect messages already sent. Anyone who can read a message could still copy it. + Off + %1$s set disappearing messages to %2$s + %1$s turned off disappearing messages + Messages disappear after %1$s This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted. This is a private channel and you don't hold its key, so you can't read it or post here. + Pinned messages + No pinned messages in this channel yet. + This channel's pins are sealed under a key you don't hold, so they can't be shown here, and pinning is paused until they can be read. + %1$d of %2$d pins · %3$d% of the size budget used + Tap a pin to jump to it + Pins + The pinned list is sealed under a key you don't hold. Changing it now would drop pins you can't see. + This channel already has 25 pins. Unpin one first. + The pinned list is out of room. Unpin a message first. + This message's original signature isn't held here, so it can't be proven and pinned. + You can't change this channel's pins right now. Name About (optional) Ban @@ -3618,6 +3643,23 @@ Relays Relays that store this community's encrypted messages. Leave empty to use your own. New Concord Channel + Accept + Could not reach this community. Try again in a moment. + Invite by npub… + New channels for a community you are in: %1$s + Decline + The invite is encrypted to this person and delivered to their inbox relays. It carries only the private channels their roles give them, and it cannot be revoked once sent. + This invite has expired + The invite could not be delivered to this person's inbox relays. + This person is banned from this community. + This community is still loading. Try again in a moment. + You can't invite people to this community. + Invited by %1$s + Name, npub or NIP-05 + Send invite to %1$s + Invite sent. + Invite someone directly + Community invites Edit community Editing message Concord Channels @@ -4110,6 +4152,9 @@ Profile Gallery Proxy Relays Public Message + Push Registration + Push Deregistration + Push Preferences Reactions Relay Auth Relay Discovery @@ -4134,6 +4179,7 @@ Video Video Collaboration Video List + Video Views Video (Repl) Video Subtitles Voice Msg diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Attestation.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Attestation.kt index 7088cfd7c8..b2cd8c1c3e 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Attestation.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Attestation.kt @@ -292,7 +292,7 @@ fun RenderAttestationRequest( val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() } val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() } - val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() } + val aboutPubkey = remember(noteEvent) { noteEvent.attestorPubKeys().firstOrNull() } Column( modifier = diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderConcordTimerNotice.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderConcordTimerNotice.kt new file mode 100644 index 0000000000..f9157784f3 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/feed/types/RenderConcordTimerNotice.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.amethyst.commons.ui.screen.loggedIn.chats.feed.types + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.commons.chats.ui.ChatSystemMessage +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.concord_timer_notice_off +import com.vitorpamplona.amethyst.commons.resources.concord_timer_notice_set +import com.vitorpamplona.amethyst.commons.resources.duration_days +import com.vitorpamplona.amethyst.commons.resources.duration_hours +import com.vitorpamplona.amethyst.commons.resources.duration_minutes +import com.vitorpamplona.amethyst.commons.resources.duration_weeks +import com.vitorpamplona.amethyst.commons.resources.duration_years +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.note.UserPicture +import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.Size18dp +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A Concord timer notice (CORD-08 §4, kind 1740) as an inline system line: "Alice set disappearing + * messages to 30 days" / "Alice turned off disappearing messages", with the actor's avatar. The feed + * only reaches here for a notice whose author holds MANAGE_METADATA (see `Account.isAcceptable`). + */ +@Composable +fun RenderConcordTimerNotice( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event as? ConcordTimerNoticeEvent ?: return + val secs = event.timerSecs() ?: return + val actor = observeUserNameByHex(event.pubKey, accountViewModel) + val text = + if (secs > 0) { + stringRes(Res.string.concord_timer_notice_set, actor, concordTimerText(secs)) + } else { + stringRes(Res.string.concord_timer_notice_off, actor) + } + + ChatSystemMessage( + text = text, + onClick = { nav.nav(Route.Profile(event.pubKey)) }, + leading = { + UserPicture( + userHex = event.pubKey, + size = Size18dp, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) +} + +/** + * A disappearing-messages timer in words, in the largest whole unit that divides it: "1 year", + * "1 week", "30 days", "90 days" — the offered presets read the way staff picked them. + */ +@Composable +fun concordTimerText(seconds: Long): String { + val day = TimeUtils.ONE_DAY.toLong() + return when { + seconds >= 365 * day && seconds % (365 * day) == 0L -> (seconds / (365 * day)).toInt().let { pluralStringRes(Res.plurals.duration_years, it, it) } + seconds >= 7 * day && seconds % (7 * day) == 0L -> (seconds / (7 * day)).toInt().let { pluralStringRes(Res.plurals.duration_weeks, it, it) } + seconds >= day -> (seconds / day).toInt().let { pluralStringRes(Res.plurals.duration_days, it, it) } + seconds >= TimeUtils.ONE_HOUR -> (seconds / TimeUtils.ONE_HOUR).toInt().let { pluralStringRes(Res.plurals.duration_hours, it, it) } + else -> (seconds / TimeUtils.ONE_MINUTE).toInt().coerceAtLeast(1).let { pluralStringRes(Res.plurals.duration_minutes, it, it) } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt new file mode 100644 index 0000000000..756ca3cb9c --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt @@ -0,0 +1,269 @@ +/* + * 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.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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 androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteSendResult +import com.vitorpamplona.amethyst.commons.model.ConcordInviteResult +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteView +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_accept +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_accept_failed +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_catch_up +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_decline +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_expired +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_explainer +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_failed +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_failed_banned +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_failed_loading +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_failed_member +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_from +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_hint +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_send +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_sent +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_title +import com.vitorpamplona.amethyst.commons.resources.concord_direct_invites_title +import com.vitorpamplona.amethyst.commons.resources.concord_home_title +import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_banned +import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_expired +import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_invalid +import com.vitorpamplona.amethyst.commons.resources.concord_invite_failed_not_saved +import com.vitorpamplona.amethyst.commons.ui.components.ConcordInvitePreviewRow +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import kotlinx.coroutines.launch + +/** + * "Invite by npub" (CORD-05 §6): pick a person with the app's ordinary user typeahead (cache, relay + * search, NIP-05, a pasted npub/nprofile), then hand them the community's keys as a Direct Invite — + * a giftwrap to their inbox relays carrying only the private channels their roles grant. + */ +@Composable +fun ConcordDirectInviteDialog( + communityId: String, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + var query by remember { mutableStateOf("") } + var picked by remember { mutableStateOf(null) } + var sending by remember { mutableStateOf(false) } + val userSuggestions = + remember(accountViewModel) { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + + LaunchedEffect(query) { userSuggestions.processCurrentWord(query) } + + AlertDialog( + onDismissRequest = { if (!sending) onDismiss() }, + title = { Text(stringRes(Res.string.concord_direct_invite_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringRes(Res.string.concord_direct_invite_explainer), style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + value = query, + onValueChange = { + query = it + picked = null + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !sending, + label = { Text(stringRes(Res.string.concord_direct_invite_hint)) }, + ) + if (picked == null && query.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> + picked = user + query = user.toBestDisplayName() + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightChat, + itemColors = ListItemDefaults.colors(containerColor = Color.Transparent), + showDividers = false, + contentPadding = PaddingValues(0.dp), + ) + } + } + }, + confirmButton = { + val target = picked + TextButton( + enabled = target != null && !sending, + onClick = { + if (target == null) return@TextButton + sending = true + scope.launch { + try { + val result = accountViewModel.account.concord.sendConcordDirectInvite(communityId, target.pubkeyHex) + accountViewModel.toastManager.toast(Res.string.concord_direct_invite_title, sendResultMessage(result)) + if (result == ConcordDirectInviteSendResult.SENT) onDismiss() + } finally { + sending = false + } + } + }, + ) { + Text(stringRes(Res.string.concord_direct_invite_send, picked?.toBestDisplayName() ?: "…")) + } + }, + dismissButton = { + TextButton(enabled = !sending, onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } + }, + ) +} + +private fun sendResultMessage(result: ConcordDirectInviteSendResult) = + when (result) { + ConcordDirectInviteSendResult.SENT -> Res.string.concord_direct_invite_sent + ConcordDirectInviteSendResult.ROSTER_NOT_LOADED -> Res.string.concord_direct_invite_failed_loading + ConcordDirectInviteSendResult.RECIPIENT_BANNED -> Res.string.concord_direct_invite_failed_banned + ConcordDirectInviteSendResult.NOT_MEMBER, ConcordDirectInviteSendResult.NOT_WRITEABLE -> Res.string.concord_direct_invite_failed_member + ConcordDirectInviteSendResult.INVALID_RECIPIENT, ConcordDirectInviteSendResult.NOT_DELIVERED -> Res.string.concord_direct_invite_failed + } + +/** + * The Direct Invites waiting for this account (CORD-05 §6), as cards with Accept / Decline — shown + * at the top of the Concord communities list. Renders nothing when there are none. + * + * Opening the hub sweeps the inbox relays once; wraps the DM pipeline sees arrive on their own. + * The preview is the bundle's own name and a robohash of the community id — **no** icon fetch, no + * relay connection to the community, no Join happens before the user taps Accept. The sender is + * shown by whatever name the cache already has, without fetching their profile. + */ +@Composable +fun ConcordPendingDirectInvites( + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + val concord = accountViewModel.account.concord + LaunchedEffect(concord) { runCatching { concord.refreshConcordDirectInvites() } } + + val invites by concord.pendingConcordDirectInvites.collectAsStateWithLifecycle() + if (invites.isEmpty()) return + + Column(modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringRes(Res.string.concord_direct_invites_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + invites.forEach { invite -> + ConcordDirectInviteCard(invite, accountViewModel, nav) + } + } +} + +@Composable +private fun ConcordDirectInviteCard( + invite: ConcordDirectInviteView, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scope = rememberCoroutineScope() + var working by remember(invite.wrapId) { mutableStateOf(false) } + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + val senderName = remember(invite.sender) { LocalCache.checkGetOrCreateUser(invite.sender)?.toBestDisplayName() ?: invite.sender.take(12) } + + val subtitle = + when { + invite.expired -> stringRes(Res.string.concord_direct_invite_expired) + invite.catchUp -> stringRes(Res.string.concord_direct_invite_catch_up, invite.channelNames.joinToString(", ") { "#$it" }) + else -> stringRes(Res.string.concord_direct_invite_from, senderName) + } + + ElevatedCard(Modifier.fillMaxWidth()) { + ConcordInvitePreviewRow( + robotSeed = invite.communityId, + title = invite.name.ifBlank { stringRes(Res.string.concord_home_title) }, + subtitle = subtitle, + accountViewModel = accountViewModel, + autoPlayGif = autoPlayGif, + ) + Row( + Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.End), + ) { + OutlinedButton( + enabled = !working, + onClick = { accountViewModel.account.concord.declineConcordDirectInvite(invite.wrapId) }, + ) { + Text(stringRes(Res.string.concord_direct_invite_decline)) + } + Button( + enabled = !working && !invite.expired, + onClick = { + working = true + scope.launch { + try { + when (val result = accountViewModel.account.concord.acceptConcordDirectInvite(invite.wrapId)) { + is ConcordInviteResult.Joined -> nav.nav(Route.ConcordServer(result.communityId)) + is ConcordInviteResult.Expired -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_expired) + is ConcordInviteResult.Banned -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_banned) + is ConcordInviteResult.InvalidLink -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_invalid) + is ConcordInviteResult.NotSaved -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_invite_failed_not_saved) + else -> accountViewModel.toastManager.toast(Res.string.concord_direct_invites_title, Res.string.concord_direct_invite_accept_failed) + } + } finally { + working = false + } + } + }, + ) { + Text(stringRes(Res.string.concord_direct_invite_accept)) + } + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordPinnedMessages.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordPinnedMessages.kt new file mode 100644 index 0000000000..9bf91f3b60 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordPinnedMessages.kt @@ -0,0 +1,319 @@ +/* + * 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.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPins +import com.vitorpamplona.amethyst.commons.actions.ConcordPinnedMessage +import com.vitorpamplona.amethyst.commons.actions.ConcordPinning +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.concord_pinned_budget +import com.vitorpamplona.amethyst.commons.resources.concord_pinned_empty +import com.vitorpamplona.amethyst.commons.resources.concord_pinned_open_hint +import com.vitorpamplona.amethyst.commons.resources.concord_pinned_title +import com.vitorpamplona.amethyst.commons.resources.concord_pinned_unavailable +import com.vitorpamplona.amethyst.commons.resources.message_edited +import com.vitorpamplona.amethyst.commons.resources.relay_group_pinned_content_description +import com.vitorpamplona.amethyst.commons.resources.relay_group_unpin_message +import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDot +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent +import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPins +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.StringResource + +/** + * [channelId]'s verified pins (CORD-04 §7), re-read whenever the Control Plane seats a new Pin List + * head, the fold changes, or a delete / Edit lands in the cache (a held delete hides its entry at + * once; a held newer Edit marks it edited). Null until the community has folded the channel. + */ +@Composable +fun rememberConcordChannelPins( + communityId: String, + channelId: String, + accountViewModel: AccountViewModel, +): State { + val account = accountViewModel.account + return produceState(null, account, communityId, channelId) { + val session = account.concordSessions.sessionFor(communityId) ?: return@produceState + val evidence = + account.cache.live.newEventBundles.filter { notes -> + notes.any { it.event is DeletionRequestEvent || it.event is ConcordChatEditEvent } + } + merge(session.pinHeads.map { }, session.state.map { }, evidence.map { }).collect { + value = withContext(Dispatchers.Default) { account.concord.concordChannelPins(communityId, channelId) } + } + } +} + +/** + * The deletion omission and the Edit refresh a PIN_MESSAGES holder owes keyless readers (§7), run + * the way the spec asks: after a short random wait, re-read, and publish only if still owed — so + * simultaneous curators collapse to one publisher and a burst of edits costs one write. One attempt + * per distinct debt, so a failure never spins. + */ +@Composable +fun ConcordPinDuties( + communityId: String, + channelId: String, + pins: ConcordChannelPins?, + accountViewModel: AccountViewModel, +) { + val debt = + remember(pins) { + pins + ?.takeIf { it.owesRepublish } + ?.let { p -> (p.killed.map { "d" + it.rumorId } + p.pins.mapNotNull { it.newerEdit?.let { e -> "e" + e.rumorId } }).sorted().joinToString("|") } + } ?: return + val attempted = remember(communityId, channelId) { HashSet() } + LaunchedEffect(communityId, channelId, debt) { + if (debt in attempted || !accountViewModel.account.concord.canPinConcord(communityId)) return@LaunchedEffect + delay(ConcordPinning.dutyDelayMs()) + attempted.add(debt) + accountViewModel.launchSigner { accountViewModel.account.concord.settleConcordPins(communityId, channelId) } + } +} + +/** The channel header's pinned-messages entry point: a pin with a count badge. Hidden when there is nothing to show. */ +@Composable +fun ConcordPinnedButton( + pins: ConcordChannelPins?, + onClick: () -> Unit, +) { + if (pins == null || (pins.count == 0 && !pins.sealedUnavailable)) return + IconButton(onClick = onClick) { + BadgedBox( + badge = { + if (pins.count > 0) Badge { Text(pins.count.toString()) } + }, + ) { + Icon(symbol = MaterialSymbols.PushPin, contentDescription = stringRes(Res.string.relay_group_pinned_content_description)) + } + } +} + +/** + * The pinned-messages sheet: each verified pin with its author, time and words (marked edited when + * revised), an "unavailable" notice when the list is sealed under a key this account never held, + * a jump to the message when it resolves locally, and Unpin for those who may write pins. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordPinnedMessagesSheet( + communityId: String, + channelId: String, + pins: ConcordChannelPins, + accountViewModel: AccountViewModel, + onJumpToMessage: (HexKey) -> Unit, + onDismiss: () -> Unit, +) { + val canPin = remember(pins) { accountViewModel.account.concord.canPinConcord(communityId) } + val session = remember(communityId) { accountViewModel.account.concordSessions.sessionFor(communityId) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) { + Text( + text = stringRes(Res.string.concord_pinned_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + if (canPin && !pins.sealedUnavailable) { + // Bytes, not the count, are the real ceiling for a sealed list (§7 Limits): surface both. + val bytes = + remember(pins) { + pins.head + ?.content + ?.encodeToByteArray() + ?.size ?: 0 + } + Text( + text = stringRes(Res.string.concord_pinned_budget, pins.count, ConcordPins.MAX_ENTRIES, bytes * 100 / ConcordPins.MAX_CONTENT_BYTES), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + if (pins.count > 0) { + Text( + text = stringRes(Res.string.concord_pinned_open_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + Spacer(Modifier.height(8.dp)) + + if (pins.sealedUnavailable) { + PinNotice(Res.string.concord_pinned_unavailable, MaterialSymbols.Lock) + } else if (pins.count == 0) { + PinNotice(Res.string.concord_pinned_empty, MaterialSymbols.PushPin) + } + + LazyColumn { + items(pins.pins, key = { it.rumorId }) { pinned -> + val jumpable = remember(pinned.rumorId, session) { session?.holdsRumor(pinned.rumorId) == true } + PinnedRow( + pinned = pinned, + accountViewModel = accountViewModel, + onClick = + if (jumpable) { + { + onJumpToMessage(pinned.rumorId) + onDismiss() + } + } else { + null + }, + onUnpin = if (canPin) ({ accountViewModel.unpinConcordRumor(communityId, channelId, pinned.rumorId) }) else null, + ) + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun PinNotice( + text: StringResource, + symbol: MaterialSymbol, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(symbol = symbol, contentDescription = null, tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(20.dp)) + Text(text = stringRes(text), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.placeholderText) + } +} + +@Composable +private fun PinnedRow( + pinned: ConcordPinnedMessage, + accountViewModel: AccountViewModel, + onClick: (() -> Unit)?, + onUnpin: (() -> Unit)?, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .let { if (onClick != null) it.clickable(onClick = onClick) else it } + .padding(start = 16.dp, end = 4.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.Top, + ) { + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = rememberPinAuthorName(pinned.author, accountViewModel), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Text( + text = timeAgoNoDot(pinned.pin.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + if (pinned.edited) { + // §7: a revised message is never shown as if its words were the original, current ones. + Text( + text = stringRes(Res.string.message_edited), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } + Text( + text = pinned.content, + style = MaterialTheme.typography.bodyMedium, + maxLines = 6, + overflow = TextOverflow.Ellipsis, + ) + } + if (onUnpin != null) { + IconButton(onClick = onUnpin) { + Icon(symbol = MaterialSymbols.Close, contentDescription = stringRes(Res.string.relay_group_unpin_message), modifier = Modifier.size(18.dp)) + } + } + } +} + +/** [hex]'s best display name, reactively, falling back to a short hex. */ +@Composable +private fun rememberPinAuthorName( + hex: HexKey, + accountViewModel: AccountViewModel, +): String { + val user = remember(hex) { LocalCache.checkGetOrCreateUser(hex) } ?: return remember(hex) { hex.take(8) } + val info by observeUserInfo(user, accountViewModel) + return info?.info?.bestName() ?: remember(user) { user.pubkeyDisplayHex() } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordTimerPicker.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordTimerPicker.kt new file mode 100644 index 0000000000..f365cd2968 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordTimerPicker.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.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.concord_timer_off +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.concordTimerText +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing + +/** + * The staff picker for a community's disappearing-messages timer (CORD-08): Off plus the offered + * presets ([ConcordDisappearing.PRESET_SECS] — 1 day, 1 week, 30 days, 90 days, 1 year; never under a + * day). A timer another client set to a non-preset value is listed too, so the current choice is + * always visible. [selected] is in seconds, `0` = off. + */ +@Composable +fun ConcordTimerPicker( + selected: Long, + onSelect: (Long) -> Unit, + enabled: Boolean = true, +) { + val options = if (selected in ConcordDisappearing.PRESET_SECS) ConcordDisappearing.PRESET_SECS else (ConcordDisappearing.PRESET_SECS + selected).sorted() + Column(Modifier.fillMaxWidth()) { + options.forEach { secs -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .height(44.dp) + .selectable(selected = secs == selected, enabled = enabled, onClick = { onSelect(secs) }), + ) { + RadioButton(selected = secs == selected, onClick = { onSelect(secs) }, enabled = enabled) + Text(if (secs > 0) concordTimerText(secs) else stringRes(Res.string.concord_timer_off)) + } + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/KindDisplayName.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/KindDisplayName.kt index 22692e2c39..4990ed244e 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/KindDisplayName.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/KindDisplayName.kt @@ -140,6 +140,9 @@ import com.vitorpamplona.amethyst.commons.resources.kind_profile_badges import com.vitorpamplona.amethyst.commons.resources.kind_profile_gallery import com.vitorpamplona.amethyst.commons.resources.kind_proxy_relays import com.vitorpamplona.amethyst.commons.resources.kind_public_message +import com.vitorpamplona.amethyst.commons.resources.kind_push_deregistration +import com.vitorpamplona.amethyst.commons.resources.kind_push_preferences +import com.vitorpamplona.amethyst.commons.resources.kind_push_registration import com.vitorpamplona.amethyst.commons.resources.kind_reactions import com.vitorpamplona.amethyst.commons.resources.kind_relay_auth import com.vitorpamplona.amethyst.commons.resources.kind_relay_discovery @@ -165,6 +168,7 @@ import com.vitorpamplona.amethyst.commons.resources.kind_video_collaboration import com.vitorpamplona.amethyst.commons.resources.kind_video_list import com.vitorpamplona.amethyst.commons.resources.kind_video_repl import com.vitorpamplona.amethyst.commons.resources.kind_video_subtitles +import com.vitorpamplona.amethyst.commons.resources.kind_video_views import com.vitorpamplona.amethyst.commons.resources.kind_voice_msg import com.vitorpamplona.amethyst.commons.resources.kind_voice_reply import com.vitorpamplona.amethyst.commons.resources.kind_wake @@ -300,6 +304,7 @@ import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent +import com.vitorpamplona.quartz.nip71Video.views.VideoViewEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent @@ -333,6 +338,9 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.deregistration.PushDeregistrationEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.preferences.PushPreferencesEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.registration.PushRegistrationEvent import org.jetbrains.compose.resources.StringResource /** Returns the catalog entry for the translated kind name, or null if unknown. */ @@ -485,6 +493,10 @@ fun kindDisplayName(kind: Int): StringResource? = VideoCurationSetEvent.KIND -> Res.string.kind_video_list VideoCollaborationEvent.KIND -> Res.string.kind_video_collaboration TextTrackEvent.KIND -> Res.string.kind_video_subtitles + VideoViewEvent.KIND -> Res.string.kind_video_views + PushRegistrationEvent.KIND -> Res.string.kind_push_registration + PushDeregistrationEvent.KIND -> Res.string.kind_push_deregistration + PushPreferencesEvent.KIND -> Res.string.kind_push_preferences AddressableNormalVideoEvent.KIND -> Res.string.kind_video_repl AddressableShortVideoEvent.KIND -> Res.string.kind_shorts_repl VideoNormalEvent.KIND -> Res.string.kind_video 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 d29cb0a26a..b72017d6d4 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 @@ -26,6 +26,7 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.actions.ConcordPinOutcome import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.commons.chats.rooms.markRoomNoteAsRead @@ -76,6 +77,12 @@ import com.vitorpamplona.amethyst.commons.resources.cashu_successful_redemption import com.vitorpamplona.amethyst.commons.resources.cashu_successful_redemption_explainer import com.vitorpamplona.amethyst.commons.resources.concord_members_roles_failed import com.vitorpamplona.amethyst.commons.resources.concord_members_roles_title +import com.vitorpamplona.amethyst.commons.resources.concord_pin_failed_generic +import com.vitorpamplona.amethyst.commons.resources.concord_pin_failed_message +import com.vitorpamplona.amethyst.commons.resources.concord_pin_failed_title +import com.vitorpamplona.amethyst.commons.resources.concord_pin_failed_too_large +import com.vitorpamplona.amethyst.commons.resources.concord_pin_failed_too_many +import com.vitorpamplona.amethyst.commons.resources.concord_pin_failed_unavailable import com.vitorpamplona.amethyst.commons.resources.draft_note import com.vitorpamplona.amethyst.commons.resources.error_dialog_zap_error import com.vitorpamplona.amethyst.commons.resources.it_s_not_possible_to_quote_to_a_draft_note @@ -597,6 +604,34 @@ class AccountViewModel( } } + /** Pin or unpin Concord message [note] (CORD-04 §7, PIN_MESSAGES holders); a refusal surfaces as a toast. */ + fun toggleConcordPin(note: Note) { + val pinned = account.concord.concordPinState(note) ?: return + launchSigner { + toastConcordPinOutcome(if (pinned) account.concord.unpinConcordMessage(note) else account.concord.pinConcordMessage(note)) + } + } + + /** Unpin the entry [rumorId] from [channelIdHex]'s Pin List — also for a pin whose message this account never held. */ + fun unpinConcordRumor( + communityId: String, + channelIdHex: String, + rumorId: HexKey, + ) = launchSigner { toastConcordPinOutcome(account.concord.unpinConcordRumor(communityId, channelIdHex, rumorId)) } + + private fun toastConcordPinOutcome(outcome: ConcordPinOutcome) { + val message = + when (outcome) { + ConcordPinOutcome.PUBLISHED, ConcordPinOutcome.ALREADY_PINNED, ConcordPinOutcome.NOT_PINNED, ConcordPinOutcome.NOTHING_TO_DO -> return + ConcordPinOutcome.LIST_UNAVAILABLE -> Res.string.concord_pin_failed_unavailable + ConcordPinOutcome.TOO_MANY_PINS -> Res.string.concord_pin_failed_too_many + ConcordPinOutcome.TOO_LARGE -> Res.string.concord_pin_failed_too_large + ConcordPinOutcome.MESSAGE_UNAVAILABLE, ConcordPinOutcome.UNVERIFIABLE -> Res.string.concord_pin_failed_message + else -> Res.string.concord_pin_failed_generic + } + toastManager.toast(Res.string.concord_pin_failed_title, message) + } + /** Promote/demote [member] as an Admin of [communityId] (from the Members roster; owner only takes effect). */ fun setConcordAdmin( communityId: String, diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 6a28bd9344..a821ef0416 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -129,18 +129,18 @@ "Polish" ] }, - { - "user": "summoner001", - "languages": [ - "Hungarian" - ] - }, { "user": "rajs19420616", "languages": [ "Hindi" ] }, + { + "user": "summoner001", + "languages": [ + "Hungarian" + ] + }, { "user": "markkks", "languages": [ diff --git a/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index b4a9ec4eb9..51f1ff2cc6 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -59,7 +59,7 @@ Ranked security > interop > feature inside each group. | # | Spec | Finding | Status | |---|---|---|---| -| I1 | 02 §8 | Community List on retired 13302, hex, no fragments, no tombstones | in progress (this branch) | +| I1 | 02 §8 | Community List on retired 13302, hex, no fragments, no tombstones | **fixed** — fragmented kind 33302 (`ConcordListFragments`/`ConcordListFragmentSet`), unpadded base64url, seed/current rules, tombstone on leave, byte-identical to Armada's `listFrag.ts` (golden tests), 13302 read as a rescue source and migrated on the next write | | I2 | 02 §6 | Metadata/Channel edits rebuilt from scratch, wiping `custom`, `message_expiration` (CORD-08), `av_brokers` | **fixed** — `ConcordJson.encodePreserving` lays every edit over the authorized head; metadata/channel forms start from the folded entity | | I3 | 03 §2 | Per-channel `voice` flag still modeled and rendered (every Channel is callable since `23dcea5`) | **fixed** — field removed (rides through as an unknown key), Mic icon and blank-preview special case removed | | I4 | 04 §1/§5 | `vac` never written or verified — Armada drops every non-owner edition we author | **fixed** — `ConcordModeration` stamps every non-owner edition with `AuthorityCitations.forActor` (own grant coordinate, folded head version + hash); every fold gate (roles, grants, banlist, metadata, channels, unmodeled kinds, floors/compaction) requires it per Armada `citationSatisfied` | @@ -82,12 +82,12 @@ Ranked security > interop > feature inside each group. | # | Spec | Finding | Status | |---|---|---|---| -| F1 | 04 §7 | Pins | open → pins batch | -| F2 | 08 | Disappearing Messages (sender tags, reader refusal/hiding/purge, 1740 notice, settings UI) | metadata field + parse **fixed**; the rest open → chat-plane batch | +| F1 | 04 §7 | Pins | **fixed** — commons `ConcordPinning` reads each Channel's Pin List off the session fold (`pinHeads`, gated on PIN_MESSAGES + `vac`, with the fold's floors), opens the sealed form with the held key of its epoch (`sealedUnavailable` kept distinct from empty), verifies entries through a per-entry-identity cache, hides entries killed by the author's held kind 5 and marks entries behind a newer held Edit as edited; pin/unpin reopen the message's original wrap (session rumor→wrap index) and write the next edition over the head read, in the channel's folded form (a private-era sealed list is never re-formed public), withheld when unreadable, refused past 25 entries / 32,768 bytes; deleting your own pinned message publishes the omission at once, and an open channel runs the delayed (3–15 s) re-read-then-publish duty for other holders' omissions and the Edit refresh. App: Pin/Unpin in the message sheet, header pin badge + pinned sheet (author, time, edited, unavailable, jump), budget line; `amy concord pins/pin/unpin`. Also fixed `DeletionIndex.DeletionRequest.compareTo` (compared the pubkey with itself, so any author's kind 5 matched on JVM/Android). Open SHOULDs: the duties run only while the channel screen is open (no background scheduler); a Rotator does not republish under the new key after a private-channel rekey, and a Banlist revert is not re-healed; compaction does not omit a deleted Channel's Pin List; pinned attachments render as text only | +| F2 | 08 | Disappearing Messages (sender tags, reader refusal/hiding/purge, 1740 notice, settings UI) | **fixed** — every durable Chat rumor (9/1111/7/3302, image variants) signs `created_at + timer` from the send-time fold and its wrap repeats it (`ConcordStreamEnvelope.wrap(outerTags)`, random `p` kept; never on 5/1740/typing); expired rumors refused at ingest (`openChannelRumor`, session, rumor sink), hidden in feed/preview/unread (`Account.isAcceptable`), and purged from LocalCache + wrap note + session buffer by a sweep scheduled on the earliest deadline (`ConcordSessionManager.nextExpiry`); typed `ConcordTimerNoticeEvent` posted per held channel after a timer change and rendered as a system row only for MANAGE_METADATA authors; timer picker in the edit screen + composer indicator; `amy concord timer`, `send` tags, `read` filters | | F3 | 07 | A/V calls: only key derivation, the 27235 grant and 23313 presence builders exist; no broker/SFU client, no media E2EE. Needs a LiveKit client whose license must be checked first | open — out of scope for this pass | | F4 | 07 | Broker token has no nonce (same-second requests collide in the broker's replay set); presence fold doesn't take latest-per-author | open → chat-plane batch (quartz only) | -| F5 | 05 §5 | Invite Registry (vsk 8) not published or folded | open | -| F6 | 05 §6 | Direct invites: wire format only, no send/receive | open | +| F5 | 05 §5 | Invite Registry (vsk 8) not published or folded | **fixed** — `ConcordInviteRegistry` (builder, strict-array decode, `nextLinks` pruning expired/tombstoned links) + `ConcordCommunityState.inviteRegistries`/`liveInviteLinks`/`isPublic`/`hasForeignLiveLinks`/`banRequiresRefounding`/`retiringWouldPrivatize` (gated on CREATE_INVITE, coordinate bound to author); mint/revoke publish the registry (app + amy); a Private ban Refounds, a Public one is the Banlist alone; retiring the last live link runs a privatizing Refounding (`privatizeConcordCommunity`; amy reports it and adds `refound --privatize`); Public/Private shown in the server view and warned in the revoke dialog. Deviation from Armada, following the spec: a ban Refounds iff the community is Private without the targets' registries (Armada rotates whenever no *foreign* link exists, and only warns on privatizing revokes) | +| F6 | 05 §6 | Direct invites: wire format only, no send/receive | **fixed** — wrap backdates seal/wrap ≤2 days, carries NIP-40 `expiration` = `expires_at`, `ConcordDirectInvite.open` returns the seal-verified sender and refuses rumor/seal pubkey mismatch, bad seal sig, non-3313 rumors, §1 bounds and bad owner proof; send (`sendConcordDirectInvite` / `amy concord invite --to`) vends only the private channels the recipient's channel-scoped roles grant (`ConcordInviteVend`, Armada `vendableChannels`) to their 10050 → NIP-65 read → stock relays; headless `ConcordDirectInviteInbox` (sweep via `directInvitesFilter` + the NIP-17 seal handler) dedupes by wrap id, skips expired wraps, parks invites, remembers declines; accept shares the link join path, refuses past `expires_at`, and for a held community only adopts new private-channel keys on the same root/epoch/control_pk (`catchUpChannelIds`); UI card + "Invite by npub"; `amy concord invites/accept/decline`. Not done: Armada's auto-adopt of staff-sent catch-ups (`judgeCatchUp`) and `channel_cuts` (not modeled here) | | F7 | 06 §1-2 | Channel-scope rekeys; private-channel keys in invites | open (depends on S2) | | F8 | 06 §2, 02 §8 | Walk forward from `seed`; we still keep intermediate roots in a `held_roots` List extension the spec says doesn't belong there | open | | F9 | 04 §6 | Kick (kind 3309) | open | diff --git a/quartz/plans/2026-09-29-graph-link-vocabulary-appendix.md b/quartz/plans/2026-09-29-graph-link-vocabulary-appendix.md new file mode 100644 index 0000000000..18b0e6fa5f --- /dev/null +++ b/quartz/plans/2026-09-29-graph-link-vocabulary-appendix.md @@ -0,0 +1,945 @@ +# Appendix: every Quartz event class, and what its references mean + +Companion to [`2026-09-29-graph-link-vocabulary.md`](2026-09-29-graph-link-vocabulary.md). +Generated 2026-09-29 from `utils/EventFactory.kt`: **410 classes**, each read against its tags, +its tag parsers and its NIP (or, for Quartz-only families, its package docs). **340** carry +references; **70** carry none (they get only `AUTHOR`, and `ADDRESS` when addressable). + +How to read a row: +- **Links**: `tag[marker/slot] -> RELATION (targets)`, targets **E** event, **A** address, + **U** user, **T** tag value. `AUTHOR` and `ADDRESS` apply to every class and are not + repeated. +- **Built from**: the Quartz accessor or tag parser the class's `links()` would call, or + "new parser needed". +- **Notes**: the NIP or spec, `UNCERTAIN:` where the spec is silent or unmerged, `DRAFT FIX:` + where the vocabulary tables need correcting, and Quartz bugs found while reading. + +Relation names are normalized to one per role (rule 2): `ADDED`→`ADDED_USER`, +`REMOVED`→`REMOVED_USER`, `JOB_REQUEST`→`REQUEST`, `CUSTOMER`→`REQUEST_AUTHOR` (NIP-90's word +is "customer"), `BENEFICIARY`→`ZAP_SPLIT`. + +## Relations this review adds + +Not in the vocabulary tables yet; each needs the maintainer's review like the tables did. +**Any kind** marks a tag that can appear on every event, better emitted once by the default on +`Event` than per class. + +| Relation | Kinds | Justification (from the reviewing pass) | +|---|---|---| +| `ABOUT` | 23903, 30392, 30393, 30394, 30395 | the event a wake-up is about (Quartz builder about(); also the trusted lists' discovery slot) | +| `ABOUT_AUTHOR` | 23903 | its author (rule 3; KDoc: 'p-tags identify the AUTHORS of the referenced events', not recipients) | +| `ACCEPTED` | 30065 | the challenge this event accepts (past participle of the action). OPPONENT: see 30 | +| `ACTOR` | 8002, 8003, 40099, 44100, 44101, 48001 | – | +| `ADDED_USER` | 8000, 9000, 9030, 40099, 41011, 44100 | past participle of NIP-43 'Add User' / NIP-29 put-user; the member an add command/notification adds. Props: role. Should be shared with NIP-43 8000 and NIP-29 9000 | +| `ADMIN` | 39001 | a NIP-29 group's admins (kind 39001 'group admins'; props: roles). It is kept apart from NIP-72 MODERATOR because roles are relay-defined | +| `AGENT` | 24200, 30177, 43001, 44200 | Buzz's `agent` tag word; the AI agent a frame/metric/job/managed-agent record is about | +| `ALLOWED` | 30175, 30177, 34551 | entries of NIP-AP's respond_to_allowlist (who the agent answers), named as the list names them | +| `APP` | 5129, 15128, 15129, 35128, 35129 | NIP-5A `app` tag, 'an addressable event reference to an app descriptor' (NIP-89 31990 / 32267) | +| `APPLICATION` | 30063 | NIP-51's own example names the a the 'Reference to parent software application' (kind 32267): the release belongs to it, it is not a curated item | +| `APPLIED` | 1631 | the patch(es) a 1631 status applied or merged (NIP-34 'applied-or-merged-patch-event-id'; past participle of the status' own name). A q here is not a NIP-18 quote. REPOSITORY_OWNER: see 1617 | +| `APPROVED_AUTHOR` | 4550 | rule 3 - NIP-72 requires 'the p tag of the author of the post (for approval notifications)' | +| `APPROVER` | 46010 | Buzz's word; the person whose approval a paused workflow waits for | +| `ARCHIVED` | 8002, 9035, 13535 | past participle of NIP-IA's action (archive identity); also the entries of the 13535 archived list \| ACTOR: who performed/consented to the action a relay-signed record reports (NIP-IA consent tag's 'actor'); props path=self/owner/admin \| REQUEST: the originating request this relay-signed delta answers (Buzz 'originating request'; also fits NIP-90 results) \| REPLACED_BY: NIP-IA's own word for the successor identity (rotation pointer) | +| `ASSERTION` | 31871, 31872 | the event an attestation or attestation request is about (Quartz assertionEventId/assertionAddrId; UNCERTAIN it is the spec's word, fallback ATTESTED) | +| `ATTESTOR` | 31872 | an attestor asked to attest (the spec family's own word; builder attestorPubKeys). ASSERTION: see 31871 | +| `AUCTION_AUTHOR` | 1021 | the auction's merchant (rule 3, the author of acted-on content, as REACTED_AUTHOR). Quartz writes it via notifyAuthor() | +| `AUDITED` | 48001 | past participle of the audit action; the object an entry records an action on. Props: action | +| `AUTHORED` | 10064 | NIP-F4/NIP-51 'podcasts the user authors' - the counter-claim that verifies a 10154 PODCAST_AUTHOR; past participle of the NIP's action | +| `BADGE_SET` | 10008 | NIP-58 '(Profile badges) may also contain a tags referencing "Badge Set" events' (30008) - the NIP's own noun; not a badge definition | +| `BANNED` | 9040 | past participle of Buzz's ban action. Props: expiration, reason. (Unban 9041, not registered, would be UNBANNED) | +| `BASE_VERSION` | 818 | 'version of the article on which this modification is based' (no marker) | +| `BID_AUTHOR` | 1022 | the bidder (rule 3). Quartz writes it via notifyBidder() | +| `CALENDAR` | 31922, 31923 | NIP-52 'a (repeated) reference tag to kind 31924 calendar event requesting to be included in Calendar' - the NIP's noun for the target | +| `CALENDAR_EVENT_AUTHOR` | 31925 | rule 3 author of the acted-on calendar event; NIP-52 'p (optional) pubkey of the author of the calendar event being responded to' | +| `CHILD` | 9002, 39000 | see 9002 | +| `CLIENT` | **any kind** — seen on 31990 | NIP-89 client tag ('identifying the client that published the note' by its 31990 address) - cross-cutting, any event may carry it | +| `COLLABORATED` | 34238 | the NIP-71 video the signer accepts (or declines) a collaborator credit on; props role, status (accepted\|declined; absent = accepted). COLLABORATED_AUTHOR: that video's author (rule 3) | +| `COLLABORATED_AUTHOR` | 34238 | – | +| `CONCEPT_GRAPH` | 39998 | – | +| `CONFIRMED` | 1316 | the kind-1315 report a Roadstr confirmation confirms or denies (spec: 'report being confirmed or denied'; the kind is named Road Event Confirmation). Props: status (still_there \| no_longer_there) | +| `COPIED` | 15128, 15129, 35128, 35129 | NIP-5A 'a copied site MUST include exactly one lowercase a tag referencing the immediate parent nsite from which it was copied' (past participle of the NIP's action) | +| `CREATED` | 7376 | NIP-60 marker 'created' - the token event this spend created | +| `CREDITED` | 21, 22, 34235, 34236 | divine.video credit markers on p/a/e (inspired-by, audio, collaborator...) that are neither a NIP-71 participant nor a mention; one relation + props.credit instead of one relation per free-text label | +| `CURRENT_SCENE` | 30298 | the scene a reader is at (Quartz currentScene()) | +| `DEFER` | 30818 | the NIP-54 `defer` marker (rule 7, marker wins) - 'considers someone else's entry as a better version of itself'; a WoT-weight transfer, neither a fork nor a mention | +| `DELETED_AUTHOR` | 5 | rule 3 author of the acted-on content; Quartz's builders write a `p` per deleted event's author. For a valid request it always equals AUTHOR, so it may be dropped if the maintainer prefers - but the tag exists and points at a pubkey | +| `DENIED` | 34551 | – | +| `DESTINATION` | 818 | NIP-54 addresses the request to 'destination-pubkey' and its a is '30818::' (the NIP's only other word is the generic 'target') | +| `DESTINATION_AUTHOR` | 818 | rule 3 author of the acted-on article (NIP-54 'destination pubkey') | +| `DESTROYED` | 7376 | NIP-60 marker 'destroyed' - the token event it consumed | +| `EDITED_AUTHOR` | 1010 | the edited note's author (rule 3). create(notify=) writes it only when editing someone else's note (EditPostViewModel), so it is the author of acted-on content, not a passing mention | +| `ELEMENT_OF` | 39999 | – | +| `EMOJI_SET` | **any kind** — seen on 0, 1, 7, 17, 1111, 10030, 30023, 30030 … (9 kinds) | NIP-30's own name for the optional 4th emoji-tag slot ('the kind 30030 emoji set the emoji belongs to'); an address pointer, so it needs a relation; cross-cutting on every kind NIP-30 allows emoji tags on (0, 1, 1111, 7, 30315) plus 10030/30030 | +| `EXERCISE` | 1301 | a POWR/NIP-101e set's kind-33401 exercise template (the tag's own name; props weight/reps/rpe/set_type) | +| `FAVORITE` | 10012, 10021, 10054, 10090 | NIP-51 names these lists by 'favorite' (10012 'user favorite browsable relays (and relay sets)', 10021 'Favorite follow sets', 10054 'Favorite podcasts'); alternative SUBSCRIBED | +| `FILE_DATA` | 1065 | the kind-1064 storage event holding the bytes this header describes (NIP-95 draft); no existing relation means 'the payload of this metadata' | +| `FINDER` | 7517 | NIP-CC's word for the person the verification attests ('the finder's pubkey') | +| `FOR_USER` | 5300, 5301 | DVM spec kinds/5300 'pubkey of the user to generate recommendations for' (Quartz writes it as ["param","user",hex]); USER alone would collide with the User node label | +| `FOUND` | 7516 | NIP-CC kind 7516 is the 'Found Log' that 'record[s] successful visits'; past participle of the NIP's action | +| `FUNDED` | 9041 | NIP-75 'The goal MAY include an r or a tag linking to a URL or addressable event' - use case 'adding funding goals to events'; past participle of the goal's action | +| `GOAL` | 30311 | the NIP-75 zap goal (kind 9041) a stream raises toward (the tag's own name, 'goal') | +| `GROUP` | 444, 445, 9000, 9001, 9002, 9005, 9007, 9008 … (59 kinds) | NIP-29 `h` group id (Buzz channel UUID) the event is scoped to, target Tag("h", id); NIP-29's word for the slot; `h` is the one reference every channel-scoped Buzz kind carries and it is not E/A/U, so it needs a T relation (propose adding `h` to the allowlisted value tags) | +| `INHERIT_FROM` | 39998, 39999 | the node a b tag claims to inherit from / correspond to (Tapestry draft 'Inherit-From'); props type (pointer\|inherit\|inherit-items). CONCEPT_GRAPH: the concept's Concept Graph core node (tag name) | +| `INPUT` | 5000, 5001, 5002, 5050, 5100, 5200, 5201, 5202 … (38 kinds) | NIP-90 'i' is 'Input data for the job' (props input_type, marker) | +| `INPUT_JOB` | 5000, 5001, 5002, 5050, 5100, 5200, 5201, 5202 … (38 kinds) | NIP-90 input-type 'job' = 'the output of a previous job with the specified event ID' (job chaining), target is that job request | +| `ITEM` | 9999, 39999 | – | +| `KEY_PACKAGE` | 444 | the kind 30443 KeyPackage event this Welcome consumed (MIP-02 names the slot 'KeyPackage'). GROUP: see 9007 | +| `KICKED` | 4312 | the participant a room host ejects (the nostrnests / EGG-07 verb 'kick', as past participle); CHANNEL_MUTED reused for the force-mute verb (room-scoped moderation, not a personal mute) | +| `LINKED` | 30315 | NIP-38 'The status MAY include an r, p, e or a tag linking to a URL, profile, note, or addressable event' - past participle of the NIP's verb; deliberate (the status is about it), so not MENTION | +| `MAINTAINER` | 30617 | the repository's other recognized maintainers (NIP-34 'maintainers' tag; a list named as the list names it) | +| `MERCHANT` | 30019 | the merchants a NIP-15 marketplace groups ('merchants': array of pubkeys). Lists name their entries as the list does (rule 7) | +| `NOTIFICATION_SERVER` | 447, 448, 449 | the push notification server a token record targets (features/push-notifications.md 'notification server'; record key member_id, leaf, platform, server_pubkey) | +| `OBSERVER` | 30392, 30393, 30394, 30395 | the point of view the list was computed under (tag name, NIP-85 vocabulary) | +| `OPEN_TIMESTAMP` | 31 | the kind-1040 NIP-03 proof attesting when the cited page was seen; named after the tag (the spec's word for the slot), not TIMESTAMPED, which is the 1040's own link to its target | +| `OPPONENT` | 30, 30064, 30065, 30066, 30067, 30068 | the other player (Quartz OpponentTag/opponentPubkey(); Jester FLOW 'opponent'), shared with the live chess kinds 30064-30068 | +| `OPTION` | 30296, 30297 | a scene an interactive story branches to (the tag's own name; props: the option text) | +| `ORIGIN` | 5129, 15128, 15129, 35128, 35129 | the uppercase A, 'the origin nsite of the copy lineage' (the NIP's word) | +| `OWNER` | 9035, 9036, 30174, 44200 | NIP-OA's own word; the owner key attesting the event's (agent) author via the `auth` tag, or the owner an agent reports to (NIP-AM/NIP-AE `p`). Props: conditions (attestation only) | +| `PALETTE` | 3330, 11333, 33331 | DECK-0003 §1.3b names the payload field `palette`: an nevent/naddr to a palette published as its own event; a content-borne event/address reference that is not a passing mention | +| `PARENT_LIST` | 9999, 39999 | the list header an item is filed under (spec: 'a pointer to the parent list (the list header)'); T when the z is the bare name of an undeclared list. ITEM: the thing declared as an item on that list (spec: 'declaring a pubkey, event id, string, or naddr as an item on a list') | +| `PERSONA` | 30177 | NIP-AP's word; the 30175 persona a managed agent is defined by (Address 30175:author:persona_id) | +| `PODCAST_AUTHOR` | 10154 | NIP-F4 '["p", , ]'; AUTHOR is taken by the signer (here the podcast key itself), so the NIP's own 'podcast author' is the name | +| `POLL_AUTHOR` | 1018 | rule 3 - the author of the acted-on poll; Quartz writes a p for the poll author (notifyAuthor) that NIP-88 does not define | +| `PUBLICATION` | 30041 | the kind-30040 index a section belongs to (Quartz publicationAddress(); the inverse of the index's MEMBER, stated by the section) | +| `RATED_AUTHOR` | 34259 | the rated entity's author (rule 3; the p is 'the rated author', not the rated user, which comes from d when mark=profile) | +| `REDEEMED` | 7376 | NIP-60/61 marker 'redeemed' - the nutzap (9321) this history entry claimed | +| `REDEEMED_AUTHOR` | 7376 | rule 3 - NIP-61 'pubkey of the author of the 9321 event (nutzap sender)' | +| `RELEASE` | 32267 | an application's release (kind 30063 NIP-82 / NIP-51 release artifact set) that the app event points to | +| `REMINDED` | 40007 | past participle of the reminder action; the message a reminder is about (would also serve NIP-ER 30300 targets) | +| `REMOVED_USER` | 8001, 9001, 9031, 40099, 44101 | past participle of NIP-43 'Remove User' / NIP-29 remove-user; should be shared with NIP-43 8001 and NIP-29 9001 | +| `REPLACED_BY` | 8002, 9035 | – | +| `REPOSITORY_OWNER` | 1617, 1618, 1619, 1621, 1630, 1631, 1632, 1633 | NIP-34's 'repository-owner' p on patches, PRs, issues and statuses (rule 3 author-of-acted-on-content, named by the NIP's word). It is the one-hop 'patches to my repos'. | +| `REQUEST` | 819, 6000, 6001, 6002, 6050, 6100, 6200, 6201 … (33 kinds) | the request event a response answers (CLINK: 'a response is distinguished by an e tag referencing the request'); same role as NIP-47/NIP-90 responses and an attestation's request, none classified yet | +| `REQUEST_AUTHOR` | 819, 6000, 6001, 6002, 6050, 6100, 6200, 6201 … (25 kinds) | the requester, i.e. the author of the REQUEST target (rule 3; also fits NIP-90 result/feedback `p`) | +| `RESOLVED` | 9044 | past participle of Buzz's resolve-report action; the kind 1984 report being closed. Props: status (resolved/dismissed), action (delete/kick/ban/timeout/dismiss/escalate), reason | +| `RESULT` | 819, 6300, 6301, 6302, 6303, 6900, 6905, 6970 | NIP-90 'Job result ... providing the output'; the entities a job returns in content (via content) | +| `REVISED` | 1618 | the root patch this PR is a revision of (NIP-34: 'indicate PR is a revision of an existing patch, which should be closed'; past participle of the action) | +| `ROLE_CHANGED` | 9032 | past participle of Buzz's change-role action; the member whose role changes. Props: role. Distinct from ADDED_USER because the target is already a member | +| `SCHEDULED` | 5905 | DVM spec kinds/5905 'Schedule events to be published in the future'; past participle of the action for the signed event the DVM will publish | +| `SEARCH_AUTHOR` | 5302 | DVM spec kinds/5302 param 'users' = 'pubkeys of users to filter notes from' (the authors the search is restricted to) | +| `SITE_MANIFEST` | 31990 | NIP-89 'App descriptor events SHOULD tag or otherwise reference related site manifest events' (latest/next nsite manifests) | +| `SNAPSHOTTED` | 5129 | NIP-5A snapshot (5128) 'MUST include exactly one a tag referencing the source root site or named site' - past participle of the NIP's action; ORIGIN / APP: see 15128 | +| `SOURCE` | 818, 1163, 30040 | the event a reproduced piece of content was taken from (here the post the gallery picture came from; also used for NKBIP-01 derivative works on 30040). Quartz calls the slot fromEvent | +| `SOURCE_TAG` | 30392, 30393, 30394, 30395 | the tag definition the membership was computed from (tag name) | +| `STALL` | 30018, 30020 | the NIP-15 stall a product or auction belongs to ('stall_id: id of the stall to which this product belong to'). The address is derived from the author plus the content stall_id | +| `SUBSET_OF` | 39999 | the superset a set claims to be a subset of ('s'). Named with the draft's words; the draft derives the reversed edges HAS_ELEMENT / IS_A_SUPERSET_OF | +| `TAGGED` | 20 | NIP-68 names its p tags 'Tagged users' and annotate-user 'places a user link in the image' - people shown in the picture, not a passing mention | +| `TEMPLATE` | 1301 | the kind-33402 workout template the session was built from (the tag's own name) | +| `TEXT_TRACK` | 21, 22, 34235, 34236 | NIP-71 `text-track` names the captions/subtitles track (an encoded event or a 39307 coordinate) - the NIP's own tag word | +| `TIMED_OUT` | 9042 | past participle of Buzz's timeout action. Props: expiration, reason | +| `TIMEOUT_CLEARED` | 9043 | Buzz's 'untimeout' = 'clears a timeout'; UNTIMED_OUT is the literal participle but unreadable | +| `TRIGGERED` | 46020 | past participle of Buzz's trigger action; the 30620 workflow definition run | +| `UNARCHIVED` | 8003, 9036 | past participle of NIP-IA unarchive; split from ARCHIVED so 'is X archived' needs no property filter (rule 4) | +| `VERIFIED` | 7517 | past participle - 'the geocache naddr being verified' | +| `VERIFIER` | 37516 | NIP-CC verification tag 'public key for verifying finds' - the key that signs 7517s | +| `VIDEO` | 39307 | the NIP-71 video this caption/subtitle track belongs to (Quartz video()); the target named by its slot, like POLL or COMMUNITY | +| `VIEWER` | 30622 | NIP-DV's own word; the user a relay-signed per-viewer snapshot belongs to | +| `VOTED` | 45002 | past participle of Buzz's vote action; the voted post. Props: direction (+/-) | +| `WIKILINK` | 30041 | a resolved [[double bracket]] reference from the body (NKBIP-01 tag name; T = the target slug when no id is given) | +| `WIKILINK_AUTHOR` | 30041 | the author named in the wikilink's pubkey slot (rule 3) | +| `WINNER` | 30067, 37516 | the winning player (the tag's own name, 'winner'). OPPONENT: see 30 | +| `WOT_ROOT` | 34551 | the wot tag's 'root-pubkey' from which posters must be reachable | +| `ZAP_REQUEST` | 9735 | NIP-57's own name ('zap request') for the event embedded in the `description` tag; a content-borne event reference, so it needs a relation (props could carry the request's comment) | +| `ZAP_SPLIT` | **any kind** — seen on 1, 14, 1111, 9041, 30023 | NIP-57 Appendix G `zap` tag names a pubkey that receives zaps sent to this event; a configuration, not a payment, so it must not be ZAP_RECIPIENT (rule 4); props weight; cross-cutting (Amethyst writes it on 1, 14, 1111, 30023, 30402). EMOJI_SET: see kind 0 | + +## The classes, by package + +### `buzz` (74) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 8002 | `ArchivedIdentityEvent` | p -> ARCHIVED (U); consent[actor] -> ACTOR (U); e -> REQUEST (E); replaced-by -> REPLACED_BY (U) | ArchivedIdentityEvent.target()/consent()/requestId()/replacedBy() (PTag.parseKey, ConsentTag.parse, ETag.parseId, ReplacedByTag.parse) | Buzz NIP-IA, relay-signed delta. Props on ARCHIVED: reason. BUG: ReplacedByTag.parse and ConsentTag.parse do not check hex64 (a malformed key would become a User link). ACTOR is the 9035 author, so ACTOR duplicates (e)-[:REQUEST]->(r)-[:AUTHOR]; kept because the request is never stored (relay audits, not stores, 9035). | +| 8003 | `UnarchivedIdentityEvent` | p -> UNARCHIVED (U); consent[actor] -> ACTOR (U); e -> REQUEST (E) | UnarchivedIdentityEvent.target()/consent()/requestId() (PTag.parseKey, ConsentTag.parse, ETag.parseId) | Buzz NIP-IA relay-signed delta. Props: reason. ConsentTag actor not hex-validated. REQUEST target (9036) is never stored by the relay. | +| 9030 | `RelayAdminAddMemberEvent` | p -> ADDED_USER (U) | RelayAdminAddMemberEvent.target()/role() (PTag.parseKey, RoleTag.parse) | Buzz 'NIP-43' admin command (not the NIP-43 kind 8000); relay executes and never stores it. No `h` (tenant = connection host). Cross-group consistency: NIP-29/NIP-43 classifier should use the same ADDED/REMOVED. | +| 9031 | `RelayAdminRemoveMemberEvent` | p -> REMOVED_USER (U) | RelayAdminRemoveMemberEvent.target() (PTag.parseKey) | Buzz admin command, never stored. No `h`. | +| 9032 | `RelayAdminChangeRoleEvent` | p -> ROLE_CHANGED (U) | RelayAdminChangeRoleEvent.target()/role() (PTag.parseKey, RoleTag.parse) | UNCERTAIN: NIP-29 folds role assignment into put-user (9000), so ADDED+props role is the alternative. Owner-signed, never stored. | +| 9033 | `SetWorkspaceProfileEvent` | *none* | – | NIP-WP: only an `icon` URL tag (http(s)/data: URL) - not modelled. Only AUTHOR applies. | +| 9035 | `ArchiveRequestEvent` | p -> ARCHIVED (U); replaced-by -> REPLACED_BY (U); auth[owner] -> OWNER (U) | ArchiveRequestEvent.target()/replacedBy()/auth() (PTag.parseKey, ReplacedByTag.parse, AuthTag.parse -> OwnerAttestation.ownerPubKey) | Buzz NIP-IA request, NIP-70 protected; audited but not stored by the relay. Props on ARCHIVED: reason. ReplacedByTag.parse lacks hex64 check. OWNER link is unverified unless OwnerAttestation.verify(author) passes - suggest emitting only verified attestations. | +| 9036 | `UnarchiveRequestEvent` | p -> UNARCHIVED (U); auth[owner] -> OWNER (U) | UnarchiveRequestEvent.target()/auth() (PTag.parseKey, AuthTag.parse) | Buzz NIP-IA request, NIP-70 protected, not stored. Props: reason. | +| 9040 | `ModerationBanEvent` | p -> BANNED (U) | ModerationBanEvent.target()/expiresAt()/reason() (PTag.parseKey, ReasonTag.parse) | Mod-signed command, executed not stored; no `h`. Kind 9041 ModerationUnbanEvent collides with NIP-75 ZapGoalEvent (documented, not in EventFactory). | +| 9042 | `ModerationTimeoutEvent` | p -> TIMED_OUT (U) | ModerationTimeoutEvent.target()/expiresAt()/reason() (PTag.parseKey) | Mod-signed command, executed not stored; no `h`. | +| 9043 | `ModerationUntimeoutEvent` | p -> TIMEOUT_CLEARED (U) | ModerationUntimeoutEvent.target() (PTag.parseKey) | UNCERTAIN naming (alternative UNTIMED_OUT for symmetry with TIMED_OUT). Command, not stored. | +| 9044 | `ModerationResolveReportEvent` | report -> RESOLVED (E) | ModerationResolveReportEvent.report()/status()/action()/reason() (ReportTag.parse) | Custom `report` tag (not `e`) by design. BUG: ReportTag.parse does not check hex64. Command, not stored. No REPORTED_* links derivable without the 1984. | +| 10100 | `AgentProfileEvent` | *none* | – | Replaceable, content-only (loose JSON). Content `channel_ids` are group UUIDs (would be GROUP T via content if content JSON refs are ever modelled) - not modelled; schema flagged conservative. Only AUTHOR/ADDRESS. | +| 13535 | `ArchivedIdentitiesListEvent` | p -> ARCHIVED (U) | ArchivedIdentitiesListEvent.archivedIdentities() (PTag.parseKey) | Relay-signed replaceable snapshot, one bare `p` per archived identity; list entries named as the list names them (archived identities). | +| 20002 | `TypingIndicatorEvent` | h -> GROUP (T); e[root] -> ROOT (E); e[reply] -> PARENT (E) | TypingIndicatorEvent.channelId()/threadRootId()/threadReplyId() (GroupIdTag.parse, MarkedETag.parseAllThreadTags) | Ephemeral, never stored (graph may skip). Builder emits root only when root != reply, so a lone `reply` e is both ROOT and PARENT. parseAllThreadTags does not check hex64 on the id. | +| 24134 | `PairingEvent` | p -> RECIPIENT (U) | PairingEvent.recipientPubKey() (PTag.parseKey) | Buzz NIP-AB, ephemeral; the `p` is an EPHEMERAL session key, not a user identity (graph may want to skip ephemeral kinds). | +| 24200 | `ObserverFrameEvent` | p -> RECIPIENT (U); agent -> AGENT (U) | ObserverFrameEvent.recipientPubKey()/agentPubKey() (PTag.parseKey, AgentTag.parse) | Buzz NIP-AO, ephemeral. Telemetry: AGENT == author (self-link); control: AGENT == RECIPIENT (duplicate). Props: frame (telemetry/control). BUG: AgentTag.parse lacks hex64 check. | +| 24810 | `HuddleReactionEvent` | h -> GROUP (T) | HuddleReactionEvent.channelId() (GroupIdTag.parse) | Ephemeral. `h` is the EPHEMERAL huddle channel UUID, not the parent timeline channel. `reaction`/`sender_name` are values; NIP-30 `emoji` URL not modelled. No reacted target. | +| 30174 | `EngramEvent` | p -> OWNER (U) | EngramEvent.ownerPubKey() (PTag.parseKey) | Buzz NIP-AE. TRAP: `d` is a blinded 64-hex HMAC that looks like an id/pubkey by shape - must never be shape-matched into a link. | +| 30175 | `PersonaEvent` | content{respond_to_allowlist} -> ALLOWED (U) | new parser needed (PersonaEvent.personaOrNull()?.respondToAllowlist) | UNCERTAIN: reference lives in plaintext content JSON, not tags; entries appear to be pubkey hex (test uses a truncated key) - validate hex64. `d` = persona slug (ADDRESS). | +| 30176 | `TeamEvent` | content{persona_ids} -> MEMBER (A) | new parser needed (TeamEvent.teamOrNull()?.personaIds -> Address(30175, author, id)) | UNCERTAIN: content JSON; persona ids assumed to be 30175 `d` slugs under the SAME author (a team groups the owner's personas) - confirm against Buzz team_events.rs. | +| 30177 | `ManagedAgentEvent` | d -> AGENT (U); content{persona_id} -> PERSONA (A); content{respond_to_allowlist} -> ALLOWED (U) | ManagedAgentEvent.agentPubKey() (dTag); new parser needed for agentOrNull()?.personaId / respondToAllowlist | The `d` tag IS the agent pubkey (like NIP-85 d -> SUBJECT); dTag() not hex-validated. UNCERTAIN: persona_id assumed to be the owner's own 30175 slug. | +| 30300 | `EventReminderEvent` | *none* | – | Buzz NIP-ER: public tags are d/not_before/expiration/alt only. The reminder target (id / a) is inside self-encrypted content - private, not modelled (would be REMINDED if ever decrypted). | +| 30350 | `PushLeaseEvent` | *none* | – | Buzz NIP-PL: d = installation id, `exec` = gateway key id (opaque, not a Nostr pubkey), expiration; descriptor encrypted. Only AUTHOR/ADDRESS. | +| 30620 | `WorkflowDefEvent` | h -> GROUP (T) | WorkflowDefEvent.channel() (GroupIdTag via firstTagValue) | d = workflow UUID (ADDRESS). `name` is a value. workflowChannel() uses firstTagValue with no emptiness check. | +| 30622 | `DmVisibilityEvent` | p -> VIEWER (U); h -> HIDDEN (T) | DmVisibilityEvent.viewerFromPTag()/hiddenChannels() (PTag.parseKey, GroupIdTag.parse) | Relay-signed addressable; d == p == viewer pubkey (duplicate, d not validated). HIDDEN extended to a T target (group id): these `h` are hidden DMs, not scope, so they are HIDDEN not GROUP. | +| 39006 | `WindowBoundsEvent` | h -> GROUP (T) | WindowBoundsEvent.channelId() (GroupIdTag.parse) | Relay-synthesized, never stored. d = :. content next_cursor.id is a pagination boundary event id - not modelled (not a statement). | +| 40002 | `StreamMessageV2Event` | h -> GROUP (T); p -> MENTION (U); e[root] -> ROOT (E); e[reply] -> PARENT (E) | StreamMessageV2Event.channel()/mentions() (GroupIdTag.parse, PTag.parseKey); buzzThreadRoot()/buzzThreadReply() in buzz/threading (not exposed on the class) | Thread e-tags per Buzz thread_tags (shared with 45003, per buzz/threading KDoc) but the Quartz class/builder neither reads nor writes them - GAP. Lone `reply` marker = direct reply, so it is both ROOT and PARENT. `broadcast` is a flag. Content nostr: URIs not parsed by the class. | +| 40003 | `StreamMessageEditEvent` | h -> GROUP (T); e -> EDITED (E) | StreamMessageEditEvent.channel()/editedMessage() (ETag.parseId) | Buzz build_edit. | +| 40004 | `StreamMessagePinnedEvent` | h -> GROUP (T); e -> PIN (E) | StreamMessagePinnedEvent.channel()/pinnedMessage() (ETag.parseId) | PIN extended to a channel pin. Tag shape inferred (no Buzz builder). | +| 40005 | `StreamMessageBookmarkedEvent` | h -> GROUP (T); e -> BOOKMARK (E) | StreamMessageBookmarkedEvent.channel()/bookmarkedMessage() (ETag.parseId) | Tag shape inferred (no Buzz builder). | +| 40006 | `StreamMessageScheduledEvent` | h -> GROUP (T) | StreamMessageScheduledEvent.channel() (GroupIdTag.parse) | Schema inferred; only `h` modelled. | +| 40007 | `StreamReminderEvent` | h -> GROUP (T); p -> RECIPIENT (U); e -> REMINDED (E) | StreamReminderEvent.channel()/recipients()/targetMessage() (PTag.parseKey, ETag.parseId) | UNCERTAIN: no Buzz builder; `e` target is read but never written by Quartz's builder. RECIPIENT = 'the user the reminder is for'. | +| 40008 | `StreamMessageDiffEvent` | h -> GROUP (T); l -> TAG (T) | StreamMessageDiffEvent.channel()/diffMeta() (GroupIdTag.parse, LanguageTag.parse) | `l` here is the diff's programming language, NOT NIP-32 (target Tag(l, lang)). repo (URL), commit/parent-commit (git SHAs), file, branch, pr are not Nostr entities - not modelled. | +| 40099 | `SystemMessageEvent` | h -> GROUP (T); content{actor} -> ACTOR (U); content{target}[member_joined] -> ADDED_USER (U); content{target}[member_removed\|member_left] -> REMOVED_USER (U); content{target_event_id}[message_deleted] -> DELETED (E); content{participants}[dm_created] -> PARTICIPANT (U) | SystemMessageEvent.channel()/payload() (SystemMessagePayload.actor/target/targetEventId/participants); new parser needed to map by type | Relay-signed. UNCERTAIN: all refs are in content JSON, keyed by payload.type; no hex validation on actor/target/target_event_id/participants. Props: type, reason_code. | +| 40100 | `CanvasEvent` | h -> GROUP (T) | CanvasEvent.channel() (GroupIdTag.parse) | Buzz build_set_canvas; markdown content, no nostr: parsing. | +| 40901 | `ChannelSummaryEvent` | h -> GROUP (T) | ChannelSummaryEvent.channel() (GroupIdTag.parse) | Relay-only sidecar; schema unconfirmed (no Buzz emitter). content channel_id duplicates `h`. | +| 40902 | `PresenceSnapshotEvent` | content{entries[].pubkey} -> SUBJECT (U) | new parser needed (PresenceSnapshotEvent.snapshot()?.entries) | UNCERTAIN: relay-only sidecar, schema unconfirmed (no Buzz emitter; relay answers with 20001s whose `p` is the 'subject'). SUBJECT extended from NIP-85 to a relay's presence statement; props status, last_seen_at. No `h`. | +| 41001 | `DmCreatedEvent` | d -> GROUP (T); p -> PARTICIPANT (U) | DmCreatedEvent.dmId()/participants() (DTag via firstTagValue, PTag.parseKey) | Relay-signed. The DM id rides in `d` on a REGULAR kind (not addressable) - emit as Tag("h", id) so it joins the DM's GROUP links. dmId() returns "" when absent (must not emit). | +| 41010 | `DmOpenEvent` | p -> PARTICIPANT (U) | DmOpenEvent.participants() (PTag.parseKey) | Command (1-8 participants); relay replies with 41001. | +| 41011 | `DmAddMemberEvent` | h -> GROUP (T); p -> ADDED_USER (U) | DmAddMemberEvent.channelId()/member() (GroupIdTag.parse, PTag.parseKey) | – | +| 41012 | `DmHideEvent` | h -> HIDDEN (T) | DmHideEvent.channelId() (GroupIdTag.parse) | HIDDEN (NIP-28 'hide message') extended to a T target: the `h` is the DM being hidden, the object of the action, not just scope. | +| 42000 | `ProductFeedbackEvent` | *none* | – | Only `category` value and optional `imeta` (media URLs, not modelled). Never stored by the relay. | +| 43001 | `JobRequestEvent` | h -> GROUP (T); p -> AGENT (U) | JobRequestEvent.channel()/target() (GroupIdTag.parse, PTag.parseKey) | UNCERTAIN: 43001-43006 reserved in Buzz with no builder; Quartz tag layout is best-effort. | +| 43002 | `JobAcceptedEvent` | e -> REQUEST (E); h -> GROUP (T); p -> REQUEST_AUTHOR (U) | JobAcceptedEvent.jobRequest()/channel()/requester() (ETag.parseId, GroupIdTag.parse, PTag.parseKey) | UNCERTAIN schema (reserved kind). | +| 43003 | `JobProgressEvent` | e -> REQUEST (E); h -> GROUP (T) | JobProgressEvent.jobRequest()/channel() (ETag.parseId, GroupIdTag.parse) | UNCERTAIN schema. Props: status. | +| 43004 | `JobResultEvent` | e -> REQUEST (E); h -> GROUP (T); p -> REQUEST_AUTHOR (U) | JobResultEvent.jobRequest()/channel()/requester() | UNCERTAIN schema. Props: status. | +| 43005 | `JobCancelEvent` | e -> REQUEST (E); h -> GROUP (T) | JobCancelEvent.jobRequest()/channel() | UNCERTAIN schema. | +| 43006 | `JobErrorEvent` | e -> REQUEST (E); h -> GROUP (T); p -> REQUEST_AUTHOR (U) | JobErrorEvent.jobRequest()/channel()/requester() | UNCERTAIN schema. Props: status. | +| 44100 | `MemberAddedNotificationEvent` | p -> ADDED_USER (U); h -> GROUP (T); content{actor} -> ACTOR (U) | MemberAddedNotificationEvent.target()/channel()/actor() (PTag.parseKey, firstTagValue(h), MembershipNotificationContent.parse) | Relay-signed. Self-join reports actor == target (ACTOR == ADDED). | +| 44101 | `MemberRemovedNotificationEvent` | p -> REMOVED_USER (U); h -> GROUP (T); content{actor} -> ACTOR (U) | MemberRemovedNotificationEvent.target()/channel()/actor() | Relay-signed. | +| 44200 | `AgentTurnMetricEvent` | p -> OWNER (U); agent -> AGENT (U) | AgentTurnMetricEvent.ownerPubKey()/agentPubKey() (PTag.parseKey, AgentTag.parse) | Buzz NIP-AM. AGENT normally == author (self-link). AgentTag.parse lacks hex64 check. | +| 45001 | `ForumPostEvent` | h -> GROUP (T); p -> MENTION (U) | ForumPostEvent.channel()/mentions() (firstTagValue(h), PTag.parseKey) | Thread root of a forum thread. | +| 45002 | `ForumVoteEvent` | h -> GROUP (T); e -> VOTED (E) | ForumVoteEvent.channel()/target()/direction() (ETag.parseId) | Alternative: REACTED (a +/- vote is reaction-like); no author `p`, so no VOTED_AUTHOR on the wire. | +| 45003 | `ForumCommentEvent` | h -> GROUP (T); e[root] -> ROOT (E); e[reply] -> PARENT (E); p -> MENTION (U) | ForumCommentEvent.channel()/threadRoot()/replyTo()/mentions() (buzzThreadRoot/buzzThreadReply, PTag.parseKey) | Buzz thread_tags: a direct reply has ONLY a `reply` marker (root == parent), so the method must emit ROOT too when no root marker is present (threadRoot() returns null there). | +| 46001 | `WorkflowTriggeredEvent` | h -> GROUP (T) | WorkflowTriggeredEvent.channel() | Relay-emitted lifecycle; run/step ids not modelled (schema not fixed). | +| 46002 | `WorkflowStepStartedEvent` | h -> GROUP (T) | WorkflowStepStartedEvent.channel() | Same as 46001. | +| 46003 | `WorkflowStepCompletedEvent` | h -> GROUP (T) | WorkflowStepCompletedEvent.channel() | Same as 46001. | +| 46004 | `WorkflowStepFailedEvent` | h -> GROUP (T) | WorkflowStepFailedEvent.channel() | Same as 46001. | +| 46005 | `WorkflowCompletedEvent` | h -> GROUP (T) | WorkflowCompletedEvent.channel() | Same as 46001. | +| 46006 | `WorkflowFailedEvent` | h -> GROUP (T) | WorkflowFailedEvent.channel() | Same as 46001. | +| 46007 | `WorkflowCancelledEvent` | h -> GROUP (T) | WorkflowCancelledEvent.channel() | Same as 46001. | +| 46010 | `WorkflowApprovalRequestedEvent` | h -> GROUP (T); p -> APPROVER (U) | WorkflowApprovalRequestedEvent.channel()/approver() (PTag.parseKey) | Relay-signed needs-action item. | +| 46011 | `WorkflowApprovalGrantedEvent` | h -> GROUP (T) | WorkflowApprovalGrantedEvent.channel() | Relay lifecycle event; no link to the 46010/46030 modelled. | +| 46012 | `WorkflowApprovalDeniedEvent` | h -> GROUP (T) | WorkflowApprovalDeniedEvent.channel() | Same as 46011. | +| 46020 | `WorkflowTriggerEvent` | d -> TRIGGERED (A) | WorkflowTriggerEvent.workflowId() (DTag via firstTagValue) -> Address(30620, author, d) | UNCERTAIN: the `d` (on a REGULAR kind) holds only the workflow UUID; address assumes owner == author, which the relay enforces ('only the workflow owner may trigger'). | +| 46030 | `ApprovalGrantEvent` | *none* | – | `d` (on a regular kind) = approval token hash - opaque, not an event id, not modelled. Only AUTHOR. | +| 46031 | `ApprovalDenyEvent` | *none* | – | Same as 46030. | +| 48001 | `AuditEntryEvent` | p -> ACTOR (U); object -> AUDITED (E\|T) | AuditEntryEvent.actor()/objectId() (PTag.parseKey, ObjectTag.parse) | UNCERTAIN: schema is a Quartz-side projection - Buzz never emits 48001 on the wire. `object` is polymorphic (event id, channel UUID, media sha256): E only when the action targets an event, else T; a 64-hex sha256 must not be shape-matched as an event. | +| 48100 | `HuddleStartedEvent` | h -> GROUP (T) | HuddleStartedEvent.channelId() | Content ephemeral_channel_id (UUID) not modelled. | +| 48101 | `HuddleParticipantJoinedEvent` | h -> GROUP (T); p -> PARTICIPANT (U) | HuddleParticipantJoinedEvent.channelId()/participant() (PTag.parseKey) | Relay-signed; participant is the `p`, not the author. | +| 48102 | `HuddleParticipantLeftEvent` | h -> GROUP (T); p -> PARTICIPANT (U) | HuddleParticipantLeftEvent.channelId()/participant() | Relay-signed. PARTICIPANT for both join and leave: the kind says which. | +| 48103 | `HuddleEndedEvent` | h -> GROUP (T); p -> PARTICIPANT (U) | HuddleEndedEvent.channelId()/participant() | `p` = last participant (optional). | +| 48106 | `HuddleGuidelinesEvent` | h -> GROUP (T) | HuddleGuidelinesEvent.channelId() | Schema uncertain (no Buzz constructor). | + +### `experimental` (57) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 31 | `ExternalCitationEvent` | open_timestamp -> OPEN_TIMESTAMP (E); g -> TAG (T) | ExternalCitationEvent.openTimestamp() (CitationTags.OPEN_TIMESTAMP; no 64-hex validation); CitationEvent.geohash() (CitationTags.GEOHASH) | Spec: silberengel/jumble citation vocabulary (no NIP). u/url (the cited URL) is not modelled in v1 (not an allowlisted value tag; would be TAG if u joins the list). Bug: openTimestamp() returns any non-empty string, no id-shape check. Kind 30 (internal citation) deliberately unmodelled: collides with Jester chess. | +| 32 | `HardcopyCitationEvent` | g -> TAG (T) | CitationEvent.geohash() (CitationTags.GEOHASH) | Spec: jumble citations (no NIP). doi, published_in, author are free-text values, not link targets. Only g carries a link, via the shared base. | +| 33 | `PromptCitationEvent` | g -> TAG (T) | CitationEvent.geohash() (CitationTags.GEOHASH) | Spec: jumble citations (no NIP). u (conversation URL) and llm are not modelled. | +| 82 | `FhirResourceEvent` | *none* | – | No spec reference in KDoc; FHIR JSON payload in content, class reads no tags. Anything an initializer adds is unparsed. | +| 1010 | `TextNoteModificationEvent` | e -> EDITED (E); p -> EDITED_AUTHOR (U) | editedNote() = firstTaggedEvent(); p read only via generic taggedUsers (new parser needed for the p slot) | Draft edits NIP (kind 1010). Only the FIRST e is read (firstTaggedEvent). summary is a value tag. Draft-plan correction: the '1010 claimed by two classes' note is wrong: GoodWikiRelayListEvent.KIND is 10102, not 1010, and EventFactory types 1010 only as TextNoteModificationEvent (the text scan matched the 10102 prefix). | +| 1064 | `FileStorageEvent` | *none* | – | NIP-95 draft (base64 file blob in content). Only m (mime) tag. | +| 1065 | `FileStorageHeaderEvent` | e -> FILE_DATA (E) | dataEventIds() / dataEvent() (ETag::parseId / ETag::parse) | NIP-95 draft (never merged). x/url/image/thumb/fallback/service/magnet are hashes and URLs, not modelled. The e tag carries an author slot (EventHintBundle.toETag) that is a hint, not a separate statement. | +| 1163 | `ProfileGalleryEntryEvent` | e -> SOURCE (E) | fromEvent() (ETag::parseId) | Amethyst profile-gallery kind (no NIP). url/x/ox/imeta-style fields are not modelled. No p for the source author; if one is added later it would be SOURCE_AUTHOR (rule 3). | +| 1301 | `WorkoutRecordEvent` | exercise[coordinate] -> EXERCISE (A); template -> TEMPLATE (A); t -> HASHTAG (T) | exerciseSetAddressIds() (ExerciseSetTag::parseAddressId), templateAddressId() (TemplateTag::parseAddressId), addressHints()/linkedAddressIds(); hashtags via generic HashtagTag | NIP-101e (draft) + RUNSTR dialect. RUNSTR exercise tag is a plain verb (not a link); ExerciseSetTag.isCoordinate distinguishes. Oddity: builder writes a d tag on a regular (non-addressable) kind 1301, so d has no replaceability meaning. Implements RootScope (can be a NIP-22 root). | +| 1315 | `RoadEventReportEvent` | t -> HASHTAG (T); g -> TAG (T) | roadEventTypeCode() (RoadEventTypeTag::parseCode), geohashes() | Roadstr draft NIP (jooray/roadstr nips/roadstr.md). t carries the road-event type code (police, accident...), a category rather than a free hashtag; HASHTAG per the t rule. lat/lon/expiration are values. | +| 1316 | `RoadEventConfirmationEvent` | e -> CONFIRMED (E); g -> TAG (T) | reportId() / linkedEventIds() (RoadReportTag::parseId), geohashes() | Roadstr draft NIP. RoadReportTag reads an author at e[3] (Quartz writes the report author there); the spec does not define it, so it stays a hint, not a CONFIRMED_AUTHOR link. UNCERTAIN: if denials must be counted separately, split per rule 4 (CONFIRMED / DENIED). | +| 1808 | `AudioHeaderEvent` | *none* | – | No spec reference. download_url / stream_url / waveform only: URLs and data, not modelled. | +| 2473 | `BirdDetectionEvent` | i -> TAG (T); g -> TAG (T) | speciesReference() (firstTagValue("i"), http(s)-filtered); Event.geohashes() | Birdstar app kind (no NIP). i is a Wikidata species URL (NIP-73 style). n (scientific name) is a value, not modelled. | +| 3063 | `SoftwareAssetEvent` | i -> TAG (T) | appId() (AppIdTag::parse) | NIP-82 (draft; 32267 is listed in the NIPs README but 82.md is not on master). i is the application's d-tag identifier, not a NIP-73 id. UNCERTAIN: it effectively names the address 32267::; a derived APPLICATION (A) link would be more useful than TAG. url/x/f/apk_certificate_hash are not modelled. | +| 4312 | `AdminCommandEvent` | a -> ROOT (A); p[action=kick] -> KICKED (U); p[action=mute] -> CHANNEL_MUTED (U) | room() and targetPubkey() (raw first a / first p, no validation); action() | nostrnests EGG-07 (ephemeral kind). a names the kind-30312 room; ROOT chosen for consistency with 10312 presence and 1311 chat, but this a carries no root marker: UNCERTAIN (alternative: a new ROOM). Rule 4 split kick vs mute on the same U target. Weak parsing: room()/targetPubkey() take the first a/p without shape checks. | +| 6969 | `ZapPollEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); e[mention] -> MENTION (E); q -> QUOTE (E,A); a -> MENTION (A); p -> MENTION (U); content nostr: -> MENTION (E,A,U) | BaseThreadedEvent.root()/reply()/threadTags() (MarkedETag), QTag::parseEventId/parseAddressId, ATag::parseAddressId, PTag::parseKey, citedNIP19() | Zap polls (old NIP-69 draft); a kind-1-shaped threaded note plus poll_option tags, so it should take kind 1's classification verbatim. UNCERTAIN: unmarked a tags treated as MENTION pending kind 1's decision. Known Quartz bug applies: QTag.parseAddressId rejects every address. Votes are zaps (9734/9735) carrying poll_option; not this class. | +| 9998 | `ListHeaderEvent` | *none* | – | Decentralized Lists (Tapestry pre-NIP, nous-clawds4/tapestry protocols/nips/decentralized-lists.md). names/titles/slugs/required/allowed/item-kind/description are values; item-kind names a kind, not an entity. Items point at the header, not vice versa. | +| 9999 | `ListItemEvent` | z -> PARENT_LIST (E,A,T); p -> ITEM (U); e -> ITEM (E); a -> ITEM (A); t -> ITEM (T) | parentLists() (ParentListTag::parse/classify: EventId \| Coordinate \| Name), itemPubKeys() (PTag), itemEvents() (ETag), itemAddresses() (ATag::parse), itemStrings() (HashtagTag); hint providers | Decentralized Lists spec. t here is a list VALUE, case-preserved, not a hashtag (Quartz README says so), hence ITEM (T) instead of HASHTAG. e items may carry the author at e[3] (itemEvent pads the relay slot): a hint. A 9999 may also declare a list (nonstandard method) and then carries header tags (values only). Cross-cutting: Event.dListParents() reads z on ANY kind (Cross-NIP Compatibility), so PARENT_LIST can come from foreign kinds too. | +| 10023 | `EphemeralChatListEvent` | *none* | – | Amethyst ephemeral-chat room list. group tags are [room name, relay URL] pairs: relay-scoped identifiers, not Nostr entities, so not modelled in v1 (would be SUBSCRIBED (T) if room ids become targets). Private rooms live NIP-44 encrypted in content. | +| 11871 | `AttestorProficiencyEvent` | k -> TAG (T) | kinds() (recommendation.tags.KindTag::parse) | Attestations draft NIP (kinds 11871/31871/31872/31873; spec not fetched). The attestor declares the kinds it can attest. | +| 12473 | `BirdexEvent` | i -> TAG (T) | species() (adjacent n/i pairing, asWebReference()) | Birdstar app kind (no NIP). Each i is the Wikidata URL of a species on the life list; n is a value. Arguably list entries (MEMBER (T)), but i -> TAG per the plain-tag rule. | +| 20000 | `GeohashChatEvent` | g -> TAG (T); t[teleport] -> HASHTAG (T) | geohash() (GeoHashTag::parse), isTeleported() (TeleportTag::match) | Bitchat location channels (ephemeral). g is the exact channel cell (single tag, no mip-map). t=teleport is a flag, not a topic; HASHTAG per the t rule (could equally be dropped). n (nickname) is a value. Authors are per-geohash derived keys, unlinkable to the main npub by design. | +| 20001 | `GeohashPresenceEvent` | g -> TAG (T) | geohash() (GeoHashTag::parse) | Bitchat presence (ephemeral). Kind 20001 is also buzz PresenceUpdateEvent; EventFactory disambiguates by the presence of a g tag, so links() must only apply when the class is actually GeohashPresenceEvent. | +| 21001 | `OfferEvent` | p -> RECIPIENT (U); e -> REQUEST (E) | recipientPubKey() (PTag::parseKey), requestId() (ETag::parseId) | CLINK Offers (shocknet/clink specs/clink-offers.md). Ephemeral; content NIP-44 to the p. Same kind for request, response and receipt. | +| 21002 | `DebitEvent` | p -> RECIPIENT (U); e -> REQUEST (E) | recipientPubKey() (PTag::parseKey), requestId() (ETag::parseId) | CLINK Debits (shocknet/clink specs/clink-debits.md). Ephemeral, NIP-44 content. | +| 21003 | `ManageEvent` | p -> RECIPIENT (U); e -> REQUEST (E) | recipientPubKey() (PTag::parseKey), requestId() (ETag::parseId) | CLINK Manage (shocknet/clink specs/clink-manage.md). Ephemeral, NIP-44 content. | +| 23333 | `EphemeralChatEvent` | *none* | – | Ephemeral chat: d = room name and relay = relay URL (RoomTag, RelayTag); neither is a Nostr entity, so not modelled in v1. Note d is used on an ephemeral kind as a room label, not an address. | +| 23903 | `WakeUpEvent` | e -> ABOUT (E); p -> ABOUT_AUTHOR (U); k -> TAG (T) | eventIds() (ETag::parseId), authorKeys() (PTag::parseKey), kinds() (KindTag) | Amethyst experimental push/wake kind (ephemeral, no spec). notifies() returns true for everyone, so p must not be read as RECIPIENT. | +| 30040 | `PublicationIndexEvent` | a -> MEMBER (A); e -> MEMBER (E); p -> MENTION (U); t -> HASHTAG (T); A/E -> SOURCE (A,E) | sections() / PublicationSectionRef.fromTags (a and e, in order), linkedAddressIds() (ATag), linkedPubKeys() (PTag), topics() (hashtags()); A/E: new parser needed | NKBIP-01 (GitCitadel; spec not fetched). MEMBER as in the draft (30040 already listed). Props for MEMBER: order, inline title, level. UNCERTAIN: p semantics unverified (author/contributor pubkey vs mention); author tag is a human name, not a pubkey. EventHintProvider missing: e sections are not in any hint provider. | +| 30041 | `PublicationContentEvent` | T/c -> PUBLICATION (A); wikilink -> WIKILINK (E,T); wikilink[pubkey] -> WIKILINK_AUTHOR (U) | publicationAddress() (T, else c, + own pubkey -> 30040 address), wikilinks() (WikilinkTag::parse) | NKBIP-01. PUBLICATION target is DERIVED (T/c carry only the index d; pubkey assumed = section author), so the link is an inference. AsciiDoc content: no citedNIP19 parsing, so no content MENTIONs today. | +| 30045 | `BookshelfDirectoryEvent` | a -> MEMBER (A); e -> MEMBER (E) | items() (PublicationSectionRef.fromTags), linkedAddressIds() (ATag) | Bookshelf directory (no NIP; already MEMBER in the draft). Hint gap: e entries are not in any EventHintProvider. | +| 30053 | `NNSEvent` | *none* | – | NNS (Nostr name system) record: ip4/ip6/version values only. Bug: neither build() writes a d tag on this addressable kind (every record collapses onto d=""). | +| 30142 | `LearningResourceEvent` | t -> HASHTAG (T) | topics() (hashtags()) | No NIP (edu publishers, schema.org-style flat tags). about:id / learningResourceType:id are vocabulary URIs, encoding:contentUrl a URL: not modelled. | +| 30296 | `InteractiveStoryPrologueEvent` | option -> OPTION (A) | options() (StoryOptionTag::parse) | Interactive stories (no NIP in KDoc). RootScope. StoryOptionTag.parse keeps the relay as a raw string (not normalized). | +| 30297 | `InteractiveStorySceneEvent` | option -> OPTION (A) | options() (StoryOptionTag::parse) | Interactive stories. RootScope. | +| 30298 | `InteractiveStoryReadingStateEvent` | A -> ROOT (A); a -> CURRENT_SCENE (A) | root() (RootSceneTag::parse, uppercase A), currentScene() (ATag::parseAddress) | d = the root story address (a reference in d). BUGS: build() calls rootScene(rootTag), which writes a lowercase a (ATag.toATagArray), and currentScene() then addUnique-replaces it, so Quartz-built events carry no A and root() returns null; build() also swaps storyImage(summary)/storySummary(image). | +| 30392 | `UserTrustedListEvent` | p -> MEMBER (U); a -> ABOUT (A); observer -> OBSERVER (U); source-tag -> SOURCE_TAG (E) | members() (PubKeyMemberTag), aboutAddresses() (ATag), observer() (ObserverTag), sourceTag() (SourceTag) | Tapestry Trusted Lists (+10 of NIP-85 kinds). MEMBER already in the draft; props score (0..100). source-tag also carries the tag's author and slug (hint/provenance; no link proposed). Hint providers ignore observer and source-tag. | +| 30393 | `EventTrustedListEvent` | e -> MEMBER (E); a -> ABOUT (A); p -> ABOUT (U); observer -> OBSERVER (U); source-tag -> SOURCE_TAG (E) | members() (EventMemberTag), aboutAddresses(), aboutPubKeys(), observer(), sourceTag() | Tapestry Trusted Lists. By convention the p is the observer (for #p discovery), duplicating the observer tag; kept as ABOUT (the slot's role). e[3] is a score, not a NIP-10 marker. | +| 30394 | `AddressableTrustedListEvent` | a -> MEMBER (A); p -> ABOUT (U); observer -> OBSERVER (U); source-tag -> SOURCE_TAG (E) | members() (AddressMemberTag), aboutPubKeys(), observer(), sourceTag() | Tapestry Trusted Lists. AddressMemberTag.parseAddressId returns any non-empty a value (no coordinate-shape check). | +| 30395 | `ExternalIdTrustedListEvent` | i -> MEMBER (T); a -> ABOUT (A); p -> ABOUT (U); observer -> OBSERVER (U); source-tag -> SOURCE_TAG (E) | members() (ExternalIdMemberTag), aboutAddresses(), aboutPubKeys(), observer(), sourceTag() | Tapestry Trusted Lists. i members are NIP-73 external ids: MEMBER (T) rather than TAG, since they are list entries (MEMBER needs T added to its targets). | +| 30817 | `NipTextEvent` | a[fork] -> FORK (A); e[fork] -> FORK (E); q -> QUOTE (E,A); a -> MENTION (A); p -> MENTION (U); k -> TAG (T); content nostr: -> MENTION (E,A,U) | forkFromAddress() (ForkTag::parseAddress), forkFromVersion() (MarkedETag.parseForkedEventId), QTag::parseEventId/parseAddressId, ATag::parseAddressId, citedNIP19(), kinds (KindTag) | NIPs-on-Nostr (draft). FORK extends from kind 1 to A targets. BUG: ForkTag.parse / parseValidAddress require kind 34550 (CommunityDefinitionEvent, copy-paste from NIP-72) instead of 30817; forkFromAddress() uses parseAddress, which checks 30817 but not the fork marker, so any a to a 30817 is read as the fork source. Known QTag.parseAddressId bug applies. | +| 31337 | `AudioTrackEvent` | p -> PARTICIPANT (U) | participants() (ParticipantTag::parse) | Zapstr-style audio track (no NIP in KDoc). Zapstr p tags carry a role (Host/Artist) at p[3], which ParticipantTag ignores: should become a prop. c is a type/genre value (not allowlisted); media/cover URLs not modelled. | +| 31871 | `AttestationEvent` | e -> ASSERTION (E); a -> ASSERTION (A); request -> REQUEST (A) | assertionEventId() (ETag), assertionAddrId() (ATag), requestId() / requestAddress() (RequestTag) | Attestations draft (spec not fetched). BUG: RequestTag is copy-pasted from NIP-72 ApprovedAddressTag: parse() returns ApprovedAddressTag and rejects 34550 addresses (meaningless here); linkedAddressIds()/addressHints() ignore the request tag. e carries the attested author at e[3] (hint). | +| 31872 | `AttestationRequestEvent` | e -> ASSERTION (E); a -> ASSERTION (A); p -> ATTESTOR (U) | assertionEventId() (ETag), assertionAddrId() (ATag), linkedPubKeys() (PTag); attestorPubKeys builder | Attestations draft. Naming bug: assertionPubkey()/assertionPTag() read the p, which the builder fills with ATTESTORS, not the assertion's author. cashu_token is a value (payment), not modelled. | +| 31873 | `AttestorRecommendationEvent` | d -> RECOMMENDED (U); k -> TAG (T) | new parser needed (the attestor pubkey is only in dTag(); builder dTag(attestorPubKey)); kinds() (KindTag) | Attestations draft. Reuses RECOMMENDED (NIP-89) with a new U target: the recommended attestor, props kinds. A pubkey in a d tag is invisible to every hint provider and to #p filters; no accessor validates it is 64-hex. | +| 31987 | `RelayReviewEvent` | *none* | – | Relay review (no merged NIP). The reviewed thing is a relay URL (d, or relay tag): relays are not link targets in v1. If relays become nodes, it would be RATED. | +| 32176 | `BlossomPieceIndexEvent` | r -> TAG (T) | url() (firstValue("r")) | No NIP. x (whole-file hash), b (piece hashes) and blossom (servers) are not modelled; note b here means piece hash, unrelated to Tapestry's b (inherit-from). | +| 32267 | `SoftwareApplicationEvent` | a -> RELEASE (A); t -> HASHTAG (T) | appLinks() (ATag::parse), topics() (HashtagTag) | NIP-82 draft. UNCERTAIN: a semantics not documented in Quartz (appLink builder has no KDoc); zapstore apps a-tag their latest 30063 release, hence RELEASE. repository/url/icon/image are URLs, f platform a value. | +| 33401 | `ExerciseTemplateEvent` | *none* | – | NIP-101e draft exercise template (POWR). Referenced by 1301 EXERCISE links; carries only values (title, format, format_units, equipment, difficulty). | +| 33863 | `FundraiserEvent` | t -> HASHTAG (T) | topics() (hashtags()) | Agora app kind (Ditto, no NIP). w = on-chain donation addresses, goal/deadline values: not modelled. Zaps to this event arrive as ZAPPED from 9735s. | +| 34139 | `MusicPlaylistEvent` | a -> CURATED (A); t -> HASHTAG (T) | trackAddresses() (ATag::parseAddress filtered to kind 36787); hashtags | No NIP in KDoc. A playlist is a published curation set of tracks, like 30004-30006, so CURATED (props: order). UNCERTAIN alternative per rule 7: TRACK. Non-track a tags are preserved by edit() but not interpreted. | +| 34238 | `VideoCollaborationEvent` | a -> COLLABORATED (A); p -> COLLABORATED_AUTHOR (U) | video() (ATag::parseAddress), videoAuthor() (PTag::parseKey) | divine-web / divine-mobile convention (no NIP). d may also be the video coordinate (divine-mobile). Listed as REFERENCE-only in the draft; now classified. UNCERTAIN: if declined answers matter to queries, split per rule 4. | +| 34259 | `EntityRatingEvent` | d -> RATED (E,A,U,T); a -> RATED (A); A -> RATED (A); e -> RATED (E); p -> RATED_AUTHOR (U); k -> TAG (T) | targetIdentifier()/mark() (d with mark prefix), targetAddress() (ATag, RootAddressTag), targetEventId() (ETag), targetAuthor() (PTag), targetKind() (ReplyKindTag) | abh3po/nostr-polls XYZ.md. d target type depends on m (event id -> E, profile -> U, coordinate -> A, hashtag/books/movies/relay -> T; relay ones fall under the not-modelled rule). Props: stars/rating, mark. a and A duplicate the same coordinate: emit one link. | +| 36787 | `MusicTrackEvent` | t -> HASHTAG (T) | hashtags (HashtagTag); builder hashtag("music") | No NIP in KDoc. artist/album are free-text names, url/video/image URLs. edit() mentions zap split tags being preserved but no accessor reads them (NIP-57 zap splits would need a relation decided for all kinds). | +| 38192 | `Ps1SaveEvent` | *none* | – | PS1 memory-card blocks (no NIP). m (memory card id), x (hash), block/state/filename/region/title are values. | +| 39998 | `AddressableListHeaderEvent` | b -> INHERIT_FROM (A); concept-graph -> CONCEPT_GRAPH (A) | inheritFrom() (InheritFromTag::parse), conceptGraph() (ConceptGraphTag::parse, else computed) | Tapestry drafts on Decentralized Lists. CONCEPT_GRAPH is computable when the tag is absent (39999::-concept-graph): only emit it when present. json tag may embed node uuids (addresses) in JSON: not modelled. b-tag-deferred marker is not a link. | +| 39999 | `AddressableListItemEvent` | z -> PARENT_LIST (E,A,T); p -> ITEM (U); e -> ITEM (E); a -> ITEM (A); t -> ITEM (T); b -> INHERIT_FROM (A); n -> ELEMENT_OF (A); s -> SUBSET_OF (A); q -> QUOTE (E,A) | parentLists() (ParentListTag), itemPubKeys()/itemEvents()/itemAddresses()/itemStrings(), inheritFrom() (InheritFromTag), elementOf() (ElementOfTag), subsetOf() (SubsetOfTag); q: new parser needed (CurationCopy only writes it) | Tapestry. q appears on assistant curation copies, pointing back to the original (address and exact version, author at q[3]); QUOTE reused, UNCERTAIN (a COPIED relation would be more precise). Taggings (TagElement, PubKeyTagging, EventTagging, TagPin) overload the item slots (e.g. PubKeyTagging: p = target, a/e = the tag applied) and are only told apart by deployment-configured z namespaces, so links() can only emit the generic ITEM; polarity/curation-method are props. Known QTag.parseAddressId bug breaks the address q. | + +### `nip90Dvms` (40) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 5000 | `DvmTextExtractionRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5000: input is a url (audio/video) or event. Quartz build() writes i[url]; reads inputs(), outputMimeType(), range/alignment params. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5001 | `DvmSummarizationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5001: example mixes i[event] and i[job]. Quartz build() writes i[event] per eventId (inputEvent). NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5002 | `DvmTranslationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5002: i[event] to translate. Quartz build() writes i[event] (inputEvent); param language. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5050 | `DvmTextGenerationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5050 uses input-type 'prompt' (not in NIP-90's list; free text, not modelled). Quartz build() writes i[prompt]; indexes prompt/text inputs for search. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5100 | `DvmImageGenerationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5100: i[text] prompt + optional i[url] source image. Quartz build() writes both (sourceImageUrl -> i[url]). NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5200 | `DvmVideoConversionRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5200: i[url] social media/video link. Quartz build() writes i[url]. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5201 | `DvmVideoTranslationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5201 example has i[url], i[event] and i[job]. Quartz build() writes i[url] only. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5202 | `DvmImageToVideoRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5202: i[url] image. Quartz build() writes i[url] (imageUrl()). NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5250 | `DvmTextToSpeechRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | Quartz build() writes i[text] (not modelled); i[event] possible per NIP-90. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5300 | `DvmContentDiscoveryRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); param[user] -> FOR_USER (U); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people); dvmPubKey() (first p only) and user() = tags.dvmParam("user"), both on the class | DVM spec kinds/5300 lists the user as a `p` param, which collides with NIP-90's `p` = service provider; Quartz (and Amethyst) put the DVM in `p` and the user in param 'user'. UNCERTAIN: other clients may put the user in `p`. relays tag (RelaysTag) not modelled. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5301 | `DvmUserDiscoveryRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); param[user] -> FOR_USER (U); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people); dvmPubKey() (first p only) and user() = tags.dvmParam("user") | DVM spec kinds/5301 is a copy of 5300 (same p-param ambiguity). build() takes only an initializer: Quartz writes nothing itself. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5302 | `DvmContentSearchRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); param[users] -> SEARCH_AUTHOR (U); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people); users() returns the raw param string: new parser needed to decode its JSON-stringified p tags | Quartz build() writes i[text] query (not modelled) and param users as an opaque string. UNCERTAIN: SEARCH_AUTHOR name; the value is a JSON array of p tags in the spec example. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5303 | `DvmPeopleSearchRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | Quartz build() writes i[text] query (not modelled) and max_results. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5400 | `DvmEventCountRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5400: inputs are tag values (i[text]); content is a NIP-01 filter JSON whose ids/authors/#e/#p are a query, not a statement: not modelled. params relay/group not modelled. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5500 | `DvmMalwareScanRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5500: i[url] file to scan. Quartz build() writes i[url] (fileUrl()). NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5900 | `DvmEventTimestampingRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5900: i[event] = event to stamp (eventIdToStamp()). INPUT, not TIMESTAMPED: the request asks for a stamp; the 1040 proof (TIMESTAMPED) comes back as the 6900's RESULT. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5901 | `DvmOpReturnRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5901: i[text] OP_RETURN payload (not modelled). NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5905 | `DvmEventPublishScheduleRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); i[text] embedded event JSON -> SCHEDULED (E); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people); eventJsons() returns the raw JSON strings: new parser needed to read each embedded event's id | DVM spec kinds/5905: request is normally encrypted (i in NIP-04 content, only p visible), so SCHEDULED is rare in public data. UNCERTAIN: SCHEDULED could be dropped since the 6905 RESULT names the same published id. params relays not modelled. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5970 | `DvmEventPowDelegationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5970: i[text] is an UNSIGNED event template (no id until mined): not modelled. param pow not modelled. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 6000 | `DvmTextExtractionResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = extracted text (free text; Quartz parses no nostr: URIs). NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6001 | `DvmSummarizationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = summary text (free text; Quartz parses no nostr: URIs). UNCERTAIN: a summary may cite nostr: URIs; content nostr: -> MENTION would need a new parser. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6002 | `DvmTranslationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = translated text (free text; Quartz parses no nostr: URIs). NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6050 | `DvmTextGenerationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = generated text (free text; Quartz parses no nostr: URIs). NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6100 | `DvmImageGenerationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = image URL: not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6200 | `DvmVideoConversionResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = video URL: not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6201 | `DvmVideoTranslationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = video URL: not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6202 | `DvmImageToVideoResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = video URL: not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6250 | `DvmTextToSpeechResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = audio URL: not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6300 | `DvmContentDiscoveryResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content e/a tags -> RESULT (E,A) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); innerTags() (parses content as a tag array, keeps e/a values) | DVM spec kinds/5300 output: content = JSON-stringified list of e/a tags. Quartz wart: innerTags() returns List mixing event ids and address strings (Amethyst re-splits with splitInnerTags) and drops relay hints; a typed parser is needed. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6301 | `DvmUserDiscoveryResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content p tags -> RESULT (U) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); innerTags() (content tag array, p values only) | DVM spec kinds/5301 prose says output tags 'SHOULD be a or e' (copy of 5300) but its example returns p; Quartz reads p only. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6302 | `DvmContentSearchResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content e/a tags -> RESULT (E,A) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); innerTags() (content tag array, e/a values) | DVM spec kinds/5302 output: content = JSON-stringified e/a tags. Same innerTags() mixed-type wart as 6300. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6303 | `DvmPeopleSearchResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content p tags -> RESULT (U) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); innerTags() (content tag array, p values) | DVM spec kinds/5303 output: content = JSON-stringified p tags. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6400 | `DvmEventCountResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = a number or a grouped-count JSON (count()): not a reference. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6500 | `DvmMalwareScanResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = 'CLEAN' or scan report text: not a reference. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6900 | `DvmEventTimestampingResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content event id -> RESULT (E) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); otsEventId() (content) | DVM spec kinds/5900 output: content MUST be the id of the kind 1040 OTS event (which itself links TIMESTAMPED to the stamped event). Quartz does not check it is 64-hex. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6901 | `DvmOpReturnResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = bitcoin txid (transactionId()): not a Nostr entity, not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6905 | `DvmEventPublishScheduleResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content event id -> RESULT (E) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); publishedEventId() (content) | DVM spec kinds/5905 output: 'Event ID that was published'. Spec example is copy-pasted from 5900 (says 1040 / kind 6900). Quartz does not check 64-hex. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6970 | `DvmEventPowDelegationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content mined event JSON -> RESULT (E) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); new parser needed: the class exposes no content accessor; parse the JSON and take its id | DVM spec kinds/5970 output: 'Mined event json with nonce and calculated id'. UNCERTAIN: RESULT to an embedded event that may never be published. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 7000 | `DvmStatusEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U) | new parser needed: the class exposes only status() and firstAmount(); build from generic tags.taggedEventIds() / tags.taggedUserIds() | NIP-90 job feedback. status (code, extra-info) and amount (msats, bolt11) are props, not links: status could ride on JOB_REQUEST (prop status). Content may hold a partial result (free text; for 5300 feeds an e/a tag list could appear: UNCERTAIN). With 'encrypted' the content is NIP-04. Amethyst follows #e = requestId. | +| 11998 | `DvmHeartbeatEvent` | *none* | status(), expiration(); dTag() | Experimental DVM heartbeat (no NIP). Tags d (the DVM's NIP-89 d), status (free text), expiration: none points at another entity. Its d mirrors Address(31990, author, d), a derived link no tag states (not proposed). Replaceable-range kind on BaseAddressableEvent by design (d splits the client-side address). | + +### `nip51Lists` (32) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10000 | `MuteListEvent` | p -> MUTE (U); e -> MUTE (E); t -> MUTE (T); word -> MUTE (T) | publicMutes() / MuteTag.parse (UserTag, EventTag, HashtagTag, WordTag in nip51Lists/muteList/tags); tags.mutedUserIds(), mutedThreadIds(), mutedHashtags(), mutedWords() | NIP-51 mute list (p pubkeys, t hashtags, word lowercase strings, e threads). PRIVATE entries: yes (NIP-44 content, privateMutes()); the graph sees public tags only. t targets the same Tag(t) node HASHTAG uses; 'word' is not in the T allowlist (i/r/g/k/l/L/t): needs allowlisting as Tag(word). Quartz: implements PubKeyHintProvider but no EventHintProvider although it holds e threads. | +| 10001 | `PinListEvent` | e -> PIN (E) | pinnedEvents() / EventBookmark.parse; linkedEventIds() (EventHintProvider) | NIP-51 pinned notes (e kind 1). PRIVATE entries: no (BaseReplaceableEvent, never decrypts). Quartz reads e only; an a tag would be ignored (NIP-51 lists e only). | +| 10003 | `BookmarkListEvent` | e -> BOOKMARK (E); a -> BOOKMARK (A) | publicBookmarks() / BookmarkIdTag.parse (EventBookmark, AddressBookmark); linkedEventIds(), linkedAddressIds() | NIP-51 bookmarks (e kind 1, a kind 30023). PRIVATE entries: yes (PrivateReplaceableTagArrayEvent, privateBookmarks()). EventBookmark also reads an author pubkey hint from positions 2-4: a hint, not a link (no BOOKMARK_AUTHOR proposed). Quartz: linkedAddressIds() uses parseAddressId, which returns the raw a value unvalidated (parseValidAddress exists). | +| 10006 | `BlockedRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | NIP-51 blocked relays (relay tags). Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 10009 | `SimpleGroupListEvent` | group -> SUBSCRIBED (T) | publicGroups() / GroupTag.parse (nip51Lists/simpleGroupList) | NIP-51 simple groups: 'NIP-29 groups the user is in' (group = id + relay URL + name), filed with 10004/10005 as SUBSCRIBED. The group is not an event/address/user (its 39000 metadata is signed by an unknown relay key), so the target is Tag(group, "'" per NIP-29's identifier form): needs 'group' added to the T allowlist. UNCERTAIN: target encoding. The r tags NIP-51 also lists are relay URLs (not modelled; Quartz does not read them). PRIVATE entries: yes. Quartz: no dTag() override (see 10006). | +| 10012 | `FavoriteRelayListEvent` | a -> FAVORITE (A) | publicRelaySets() = tags.relaySetPointers() (AddressBookmark filtered to kind 30002); relay tags via publicRelays() | NIP-51 relay feeds: relay tags (not modelled) and a to kind 30002 relay sets. PRIVATE entries: yes (privateTags; no private relay-set accessor). Quartz: no dTag() override (see 10006). | +| 10015 | `InterestListEvent` | t -> SUBSCRIBED (T); a -> SUBSCRIBED (A) | publicHashtags() (HashtagTag.parse); publicInterestSets() = tags.interestSetPointers() (AddressBookmark filtered to kind 30015) | NIP-51 interests: t hashtags and a to kind 30015 interest sets (draft: SUBSCRIBED). t target is the same Tag(t) node HASHTAG uses. PRIVATE entries: yes (privateTags, privateInterestSets()). Quartz: no dTag() override (see 10006). | +| 10017 | `GitAuthorListEvent` | p -> SUBSCRIBED (U) | publicAuthors() / GitAuthorTag.parse; linkedPubKeys() (PubKeyHintProvider) | NIP-51 git authors: 'code (people who produce NIP-34 events) follow list', p with relay hint + petname like NIP-02. UNCERTAIN: the draft files 10017 under MEMBER; rule 4 (FOLLOW is kind 3, every other follow-like list is SUBSCRIBED) and 10020's identical shape argue for SUBSCRIBED. Petname could ride as a prop. PRIVATE entries: yes. Quartz: no dTag() override (see 10006). | +| 10018 | `GitRepositoryListEvent` | a -> SUBSCRIBED (A) | publicRepositories() / AddressBookmark.parse; linkedAddressIds() (AddressHintProvider) | NIP-51 git repositories: 'NIP-34 followed repositories' (a kind 30617), a follow-like list, so SUBSCRIBED (not in the draft yet; REPOSITORY is a patch's repo). PRIVATE entries: yes. Quartz: linkedAddressIds() returns unvalidated a values; no dTag() override (see 10006). | +| 10020 | `MediaFollowListEvent` | p -> SUBSCRIBED (U) | publicFollows() / UserTag.parse; linkedPubKeys() (PubKeyHintProvider) | NIP-51 media follows (draft: SUBSCRIBED). PRIVATE entries: yes (privateFollows()). Quartz: UserTag drops the NIP-02 petname; no dTag() override (see 10006). | +| 10021 | `FavoriteFollowSetsListEvent` | a -> FAVORITE (A) | publicFavoriteFollowSets() = tags.favoriteFollowSetBookmarks() (AddressBookmark filtered to kind 30000) | NIP-51 kind 10021. Not in the draft. Quartz skips a tags of other kinds. PRIVATE entries: yes (privateFavoriteFollowSets()). dTag() correctly pinned to "". No hint provider implemented. | +| 10081 | `GeohashListEvent` | g -> SUBSCRIBED (T) | publicGeohashes() = tags.geohashList() (GeoHashTag.parse, nip01Core/tags/geohash) | Followed locations (geohashes). Not in the NIP-51 table (Amethyst kind). Target is Tag(g) like TAG. PRIVATE entries: yes (decryptPrivateGeohashes()). QUARTZ BUG (privacy): the non-suspend create(publicGeohashes, privateGeohashes, NostrSignerSync) swaps them (privateTagArray = publicGeohashes, publicTagArray = privateGeohashes), so private geohashes are published in clear tags. No dTag() override (see 10006). | +| 10086 | `IndexerRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | Indexer relays (relay tags); not in the NIP-51 table. Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 10087 | `ProxyRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | Proxy relays (relay tags); not in the NIP-51 table. Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 10088 | `BroadcastRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | Broadcast relays (relay tags); not in the NIP-51 table. Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 10089 | `TrustedRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | Trusted relays (relay tags); not in the NIP-51 table. Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 10090 | `FavoriteAlgoFeedsListEvent` | a -> FAVORITE (A) | publicFavoriteAlgoFeeds() / AddressBookmark.parse; tags.favoriteAlgoFeedsList() | Not in the NIP-51 table (Amethyst kind). a points at feed DVM announcements (kind 31990); Quartz does not filter by kind. PRIVATE entries: yes (privateFavoriteAlgoFeeds()). No dTag() override (see 10006). | +| 10101 | `GoodWikiAuthorListEvent` | p -> RECOMMENDED (U) | publicAuthors() / UserTag.parse; linkedPubKeys() (PubKeyHintProvider) | NIP-51 good wiki authors: 'NIP-54 user recommended wiki authors'. UNCERTAIN: the draft files 10101 under MEMBER; NIP-51's own word is 'recommended', which reuses RECOMMENDED (extends its targets from A to U). PRIVATE entries: yes. No dTag() override (see 10006). | +| 10102 | `GoodWikiRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | NIP-51 good wiki relays (relay tags). Kind 10102 in Quartz; the draft's note that it claims kind 1010 does not match the current class (KIND = 10102). Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 30000 | `FollowSetEvent` | p -> MEMBER (U); [d=mute] p/e/t/word -> MUTE (U,E,T) | users() = tags.users() (UserTag.parse); linkedPubKeys(); publicMembers() parses MuteTag (p/e/t/word) | NIP-51 follow sets (draft: MEMBER). The deprecated d='mute' form is a mute list (NIP-51 'use instead kind 10000'), which is why Quartz parses MuteTag here: those entries should be MUTE. PRIVATE entries: yes (privateMembers()). | +| 30001 | `OldBookmarkListEvent` | e -> BOOKMARK (E); a -> BOOKMARK (A); [d=pin] e -> PIN (E); [d=communities] a -> SUBSCRIBED (A) | publicBookmarks() / BookmarkIdTag.parse; linkedEventIds(), linkedAddressIds() | Deprecated NIP-51 kind 30001 (d='bookmark' -> 10003, d='pin' -> 10001, d='communities' -> 10004). Quartz treats every 30001 as bookmarks regardless of d; the semantic method should branch on d. PRIVATE entries: yes. linkedAddressIds() unvalidated (see 10003). | +| 30002 | `RelaySetEvent` | *none* | relays() / RelayTag.parse | NIP-51 relay sets (relay tags only): not modelled. PRIVATE entries: yes. | +| 30003 | `BookmarkSetEvent` | e -> BOOKMARK (E); a -> BOOKMARK (A) | publicBookmarks() / BookmarkIdTag.parse; linkedEventIds(), linkedAddressIds() | NIP-51 bookmark sets (draft: BOOKMARK). PRIVATE entries: yes (PrivateTagArrayEvent; no privateBookmarks() accessor on this class, only privateTags()). linkedAddressIds() unvalidated (see 10003). | +| 30004 | `ArticleCurationSetEvent` | a -> CURATED (A); e -> CURATED (E) | publicItems() / BookmarkIdTag.parse; linkedEventIds(), linkedAddressIds() | NIP-51 curation set (a kind 30023, e kind 1) (draft: CURATED). PRIVATE entries: yes (PrivateTagArrayEvent). linkedAddressIds() unvalidated. | +| 30005 | `VideoCurationSetEvent` | e -> CURATED (E); a -> CURATED (A) | publicItems() / BookmarkIdTag.parse; linkedEventIds(), linkedAddressIds() | NIP-51 lists e (kind 21 videos) only; Quartz also accepts a (addressable videos). PRIVATE entries: yes. | +| 30006 | `PictureCurationSetEvent` | e -> CURATED (E); a -> CURATED (A) | publicItems() / BookmarkIdTag.parse; linkedEventIds() (EventHintProvider only) | NIP-51 lists e (kind 20 pictures) only. Quartz inconsistency: publicItems() accepts a too, but the class implements no AddressHintProvider. PRIVATE entries: yes. | +| 30007 | `KindMuteSetEvent` | p -> MUTE (U) | publicMutedUsers() / UserTag.parse; linkedPubKeys() (PubKeyHintProvider) | NIP-51 kind mute sets: 'mute pubkeys by kinds', d MUST be the kind string: the muted kind should ride as a prop (e.g. muted_kind = d) since kind stays off relation names. PRIVATE entries: yes. | +| 30015 | `InterestSetEvent` | t -> MEMBER (T) | publicHashtags() (HashtagTag.parse) | NIP-51 interest sets: 'interest topics represented by a bunch of hashtags'. A named set, so MEMBER as for follow sets (the draft names 30015 nowhere; 10015's pointer to it is SUBSCRIBED). t target is the Tag(t) node HASHTAG uses. PRIVATE entries: yes (privateHashtags()). | +| 30063 | `ReleaseArtifactSetEvent` | e -> CURATED (E); a -> APPLICATION (A); i -> TAG (T) | items() / BookmarkIdTag.parse; assets() (NIP-82 AssetTag e); appId() (NIP-82 i); linkedEventIds(), linkedAddressIds() | Kind shared by NIP-51 release artifact set and NIP-82 software release (isNip82SoftwareRelease()). e = artifacts (NIP-51 kind 1063; NIP-82 kind 3063 assets). UNCERTAIN: draft says CURATED; a release's files are not a curation, a dedicated ARTIFACT (NIP-51 'release artifact') may read better. NIP-82 i = app id (TAG); c channel / version not modelled. PRIVATE entries: no (BaseAddressableEvent, though NIP-51 content may hold them). | +| 30267 | `AppCurationSetEvent` | a -> CURATED (A) | apps() / AddressBookmark.parse; linkedAddressIds() (AddressHintProvider) | NIP-51 app curation sets (a kind 32267 software applications) (draft: CURATED). PRIVATE entries: no in Quartz (BaseAddressableEvent). | +| 39089 | `StarterPackEvent` | p -> MEMBER (U); t -> HASHTAG (T) | follows() / followIds() (UserTag.parse, nip51Lists/starterPack/TagArrayExt.kt); hashtags() (HashtagTag.parse); linkedPubKeys() | NIP-51 starter packs (draft: MEMBER). Quartz also reads t as topics. PRIVATE entries: no (BaseAddressableEvent). | +| 39092 | `MediaStarterPackEvent` | p -> MEMBER (U) | follows() / followIds() (UserTag.parse); linkedPubKeys() (PubKeyHintProvider) | NIP-51 media starter packs (draft: MEMBER). PRIVATE entries: no (BaseAddressableEvent). | + +### `nip29RelayGroups` (16) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9000 | `GroupPutUserEvent` | h -> GROUP (T\|A); p -> ADDED_USER (U) | groupId() (GroupIdTag), userPubKeys() (PTag::parseKey; roles are p[2..], read via GroupAdminTag::parse) | NIP-29 put-user, with roles riding in the p tag. The Buzz role tag is also written, as a props source. previous holds 8-char event-id prefixes, which cannot resolve to event ids and are not modelled. UNCERTAIN: PUT_USER (literal NIP word) vs ADDED_USER shared with NIP-43. | +| 9001 | `GroupRemoveUserEvent` | h -> GROUP (T\|A); p -> REMOVED_USER (U) | groupId(), userPubKeys() (PTag::parseKey) | NIP-29 remove-user. previous is not modelled (see 9000). | +| 9002 | `GroupEditMetadataEvent` | h -> GROUP (T\|A); parent -> PARENT (T\|A group); child -> CHILD (T\|A group); t -> HASHTAG (T); g -> TAG (T) | groupId(), parent() (ParentTag::parse), children() (ChildTag::parse), hashtags(), geohashes() | NIP-29 edit-metadata. The parent and child values are group ids on the same relay, so they share GROUP's target representation. previous is not modelled. | +| 9005 | `GroupDeleteEventEvent` | h -> GROUP (T\|A); e -> DELETED (E) | groupId(), deletedEventIds() (mapValueTagged('e')) | NIP-29 delete-event: a moderator deletion, which NIP-09's owner-only rule does not govern. DELETED is the NIP's action word. UNCERTAIN: a NIP-09 enforcer querying DELETED must filter on source kind 5, so rule 4 may argue for a separate MODERATOR_DELETED. deletedEventIds() does not validate 64-hex. previous is not modelled. | +| 9007 | `CreateGroupEvent` | h -> GROUP (T\|A) | groupId() (GroupIdTag::parse) | NIP-29 create-group. GROUP target proposal: NIP-29 says a group is referenced by the naddr of its kind 39000 (pubkey = relay NIP-11 self, d = id), so target Address 39000:: when the self key is known (always for a relay-side store; the 39xxx events carry it as author), else Tag('h', id) with the relay in props, or a new LinkTarget.Group(relay, id). A bare Tag('h', id) merges forks and migrations across relays, which NIP-29 says share the same id. The name, about, visibility and channel_type tags (Buzz) are not links. | +| 9008 | `DeleteGroupEvent` | h -> GROUP (T\|A) | groupId() | NIP-29 delete-group. The kind carries the action, so the h stays GROUP rather than DELETED (rule 2). | +| 9009 | `GroupCreateInviteEvent` | h -> GROUP (T\|A) | groupId() | NIP-29 create-invite. The code tag is a secret-ish value and is not modelled. | +| 9010 | `GroupUpdatePinListEvent` | h -> GROUP (T\|A); e -> PIN (E); a -> PIN (A) | groupId(), pins()/pinnedEventIds()/pinnedAddresses() (GroupPin, EventPin, AddressPin) | NIP-29 update-pin-list. It carries the full ordered list, so the order is a prop. The draft's PIN is E-only and must be widened to E, A (both 9010 and 39005 pin addresses). | +| 9021 | `GroupJoinRequestEvent` | h -> GROUP (T\|A) | groupId() | NIP-29 join request. The code (invite) is not modelled. | +| 9022 | `GroupLeaveRequestEvent` | h -> GROUP (T\|A) | groupId() | NIP-29 leave request. | +| 39000 | `GroupMetadataEvent` | parent -> PARENT (A); child -> CHILD (A); t -> HASHTAG (T); g -> TAG (T) | parent() (ParentTag::parse), children() (ChildTag::parse) -> Address 39000::, hashtags(), geohashes() | NIP-29 group metadata, signed by the relay. The group node IS this event's ADDRESS (39000::), which is the NIP's own group reference. Subgroup parent and child are on the same relay, so their addresses are exact: 39000::. t also carries the Buzz channel types (stream/forum/dm/workflow) as values, so those HASHTAGs are not topics (a quirk). | +| 39001 | `GroupAdminsEvent` | d (derived) -> GROUP (A); p -> ADMIN (U) | Address 39000::dTag() (groupId() = dTag()), admins() (GroupAdminTag::parse) | NIP-29 group admins. Relay-signed with d = group id, so the group is exactly Address 39000::. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). | +| 39002 | `GroupMembersEvent` | d (derived) -> GROUP (A); p -> MEMBER (U) | Address 39000::dTag() (groupId() = dTag()), members() (PTag::parseKey) | NIP-29 group members. It is not exhaustive (per the NIP). Relay-signed with d = group id, so the group is exactly Address 39000::. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). | +| 39003 | `GroupRolesEvent` | d (derived) -> GROUP (A) | Address 39000::dTag() (groupId() = dTag()), roles() (RoleTag::parse) has no references | NIP-29 group roles. The role names are values. Relay-signed with d = group id, so the group is exactly Address 39000::. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). | +| 39004 | `GroupParticipantsEvent` | d (derived) -> GROUP (A); participant -> PARTICIPANT (U) | Address 39000::dTag() (groupId() = dTag()), participants() (mapValueTagged('participant')) | NIP-29 LiveKit participants. participants() does not validate 64-hex. Relay-signed with d = group id, so the group is exactly Address 39000::. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). | +| 39005 | `GroupPinnedEvent` | d (derived) -> GROUP (A); e -> PIN (E); a -> PIN (A) | Address 39000::dTag() (groupId() = dTag()), pins()/pinnedEventIds()/pinnedAddresses() | NIP-29 group pinned events, ordered (order as a prop). PIN needs A added (see 9010). Relay-signed with d = group id, so the group is exactly Address 39000::. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). | + +### `nip34Git` (12) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1617 | `GitPatchEvent` | a -> REPOSITORY (A); p[= repo owner] -> REPOSITORY_OWNER (U); p[other] -> MENTION (U); e[reply] -> PARENT (E); e[root] -> ROOT (E); t -> HASHTAG (T); r -> TAG (T) | repositoryAddress()/repository(), PTag::parseKey (+ compare with the a pubkey), MarkedETag parse (linkedEventIds), isRoot()/isRootRevision() (HashtagTag), earliestUniqueCommit() | NIP-34. e[reply] points at the previous patch in the series, or at the original root patch for a revision. UNCERTAIN: NIP-34 text shows only reply, but ngit also writes root markers on series. The p roles are told apart only by comparison with the a pubkey, so this needs a new parser. t values are the markers 'root' and 'root-revision'. r holds the euc and commit ids. commit, parent-commit and committer are git data, not modelled. The content is a patch, so there is no nostr: parsing. | +| 1618 | `GitPullRequestEvent` | a -> REPOSITORY (A); p[= repo owner] -> REPOSITORY_OWNER (U); p[other] -> MENTION (U); e -> REVISED (E); t -> HASHTAG (T); r -> TAG (T) | repositoryAddress(), PTag::parseKey, rootPatchId() (ETag::parseId), labels() (hashtags), earliestUniqueCommit() | NIP-34. UNCERTAIN: the placeholder is , so ROOT is an alternative, but the PR is not in that patch's thread. It supersedes it. t holds labels. c (tip commit), merge-base, branch-name and clone are not modelled. subject is not a link. | +| 1619 | `GitPullRequestUpdateEvent` | E -> ROOT (E); P -> ROOT_AUTHOR (U); a -> REPOSITORY (A); p[= repo owner] -> REPOSITORY_OWNER (U); p[other] -> MENTION (U); r -> TAG (T) | parentPullRequestId() (RootEventTag::parseKey), parentPullRequestAuthor() (RootAuthorTag::parseKey), repositoryAddress(), PTag::parseKey | NIP-34 PR update uses NIP-22 E/P for the PR, so ROOT and ROOT_AUTHOR follow the draft. c, clone and merge-base are not modelled. Quartz names the getter parentPullRequestId although the tag is the root E. The naming is fine, but note it for the golden test. | +| 1621 | `GitIssueEvent` | a -> REPOSITORY (A); p[= repo owner] -> REPOSITORY_OWNER (U); p[other] -> MENTION (U); q -> QUOTE (E,A); t -> HASHTAG (T); content nostr: -> MENTION (E,A,U) | repositoryAddress()/repository(), PTag::parseKey, QTag::parseEventId/parseAddressId, topics() (hashtags), citedNIP19() | NIP-34 issue. REPOSITORY, MENTION and QUOTE are already listed for 1621 in the draft. subject is not a link. Known QTag.parseAddressId bug. | +| 1622 | `GitReplyEvent` | a -> REPOSITORY (A); e[root] -> ROOT (E); e[reply] -> PARENT (E); p -> MENTION (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U) | repository(), rootIssueOrPatch() (MarkedETag::parseRootId), BaseThreadedEvent.reply(), PTag::parseKey, QTag, citedNIP19() | Legacy NIP-34 reply (deprecated in Quartz; NIP-34 now says to use NIP-22 kind 1111). The root is the issue or patch. It is in the draft's ROOT, PARENT and MENTION. | +| 1630 | `GitStatusOpenEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); a -> REPOSITORY (A); p[= e[root] author] -> ROOT_AUTHOR (U); p[= repo owner] -> REPOSITORY_OWNER (U); p[= e[reply] author] -> PARENT_AUTHOR (U); p[other] -> MENTION (U); r -> TAG (T) | GitStatusEvent.rootEventId()/replyEventId(), repositoryAddress(), PTag::parseKey (+ comparison with MarkedETag author and the a pubkey), referenceCommits() | NIP-34 status: e root = the issue, PR or root patch, and e reply = the accepted revision root, as in the draft. The NIP-34 p list is repository-owner, root-event-author and revision-author, with no markers. Telling them apart requires comparing each p with the e author field (Quartz writes it at position 4) and with the a pubkey. That is a new parser, and whatever cannot be resolved stays MENTION. | +| 1631 | `GitStatusAppliedEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); a -> REPOSITORY (A); p[= e[root] author] -> ROOT_AUTHOR (U); p[= repo owner] -> REPOSITORY_OWNER (U); p[= e[reply] author] -> PARENT_AUTHOR (U); p[other] -> MENTION (U); r -> TAG (T); q -> APPLIED (E) | GitStatusEvent.rootEventId()/replyEventId(), repositoryAddress(), PTag::parseKey (+ comparison with MarkedETag author and the a pubkey), referenceCommits(), appliedPatchIds() (QTag::parseEventId) | q holds the applied or merged patch ids. merge-commit and applied-as-commits are git data, not modelled (their commits also appear as r -> TAG). Quartz gap: GitStatusEvent.linkedEventIds() reads only e, so the q ids are missing from the hint provider. NIP-34 status: e root = the issue, PR or root patch, and e reply = the accepted revision root, as in the draft. The NIP-34 p list is repository-owner, root-event-author and revision-author, with no markers. Telling them apart requires comparing each p with the e author field (Quartz writes it at position 4) and with the a pubkey. That is a new parser, and whatever cannot be resolved stays MENTION. | +| 1632 | `GitStatusClosedEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); a -> REPOSITORY (A); p[= e[root] author] -> ROOT_AUTHOR (U); p[= repo owner] -> REPOSITORY_OWNER (U); p[= e[reply] author] -> PARENT_AUTHOR (U); p[other] -> MENTION (U); r -> TAG (T) | GitStatusEvent.rootEventId()/replyEventId(), repositoryAddress(), PTag::parseKey (+ comparison with MarkedETag author and the a pubkey), referenceCommits() | NIP-34 status: e root = the issue, PR or root patch, and e reply = the accepted revision root, as in the draft. The NIP-34 p list is repository-owner, root-event-author and revision-author, with no markers. Telling them apart requires comparing each p with the e author field (Quartz writes it at position 4) and with the a pubkey. That is a new parser, and whatever cannot be resolved stays MENTION. | +| 1633 | `GitStatusDraftEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); a -> REPOSITORY (A); p[= e[root] author] -> ROOT_AUTHOR (U); p[= repo owner] -> REPOSITORY_OWNER (U); p[= e[reply] author] -> PARENT_AUTHOR (U); p[other] -> MENTION (U); r -> TAG (T) | GitStatusEvent.rootEventId()/replyEventId(), repositoryAddress(), PTag::parseKey (+ comparison with MarkedETag author and the a pubkey), referenceCommits() | NIP-34 status: e root = the issue, PR or root patch, and e reply = the accepted revision root, as in the draft. The NIP-34 p list is repository-owner, root-event-author and revision-author, with no markers. Telling them apart requires comparing each p with the e author field (Quartz writes it at position 4) and with the a pubkey. That is a new parser, and whatever cannot be resolved stays MENTION. | +| 10317 | `UserGraspListEvent` | *none* | – | NIP-34 grasp list. The g tags here are grasp SERVER URLs, not geohashes, and servers are not modelled. Cross-cutting trap: a generic g -> TAG (geohash) rule would mislabel these, so g must be read per kind. | +| 30617 | `GitRepositoryEvent` | maintainers -> MAINTAINER (U); t -> HASHTAG (T); r[euc] -> TAG (T); u -> FORK (A) | maintainers() (MaintainersTag::parse), hashtags(), earliestUniqueCommit() (EucTag::parse); new parser needed for u | NIP-34. u ('30617::\|', 'indicate repository is a subordinate fork') is not parsed by Quartz. It is FORK (A) when the value is an address, and not modelled when it is a git URL. This extends the draft's FORK from E to E, A. t includes the 'personal-fork' marker. web, clone and relays are not modelled. | +| 30618 | `GitRepositoryStateEvent` | d (derived) -> REPOSITORY (A) | new parser needed (Address 30617::) | NIP-34: 'd matches the identifier in the corresponding repository announcement', so the repository address is derived from the author plus d, much as the 39xxx GROUP link is. The refs and HEAD are git data, not modelled. UNCERTAIN: whether derived links belong in links() or in the graph layer. | + +### `marmot` (8) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 444 | `WelcomeEvent` | e -> KEY_PACKAGE (E); h -> GROUP (T) | keyPackageEventId() (KeyPackageEventTag::parse), nostrGroupId() | Marmot MIP-02. This is an unsigned rumor inside a NIP-59 gift wrap (1059 -> 13 -> 444), so a relay-side graph never sees it. Links apply only after unwrapping. h is the Marmot nostr_group_id (random 32-byte hex, not relay-scoped, unlike NIP-29), so Tag('h', id) is a sound target here. UNCERTAIN: whether Marmot and NIP-29 should share GROUP or get separate relations. relays is not modelled. Quartz reads h inline in nostrGroupId() instead of mip03 GroupIdTag (minor). | +| 445 | `GroupEvent` | h -> GROUP (T) | groupId() (mip03 GroupIdTag::parse) | Marmot MIP-03. The pubkey is ephemeral per event, so AUTHOR is meaningless here: flag it so the graph does not grow one throwaway User per message. The content is encrypted MLS, and the inner kind 9/7 rumors have their own links. The target is Tag('h', nostr_group_id), a global random id. | +| 446 | `NotificationRequestEvent` | *none* | – | Marmot MIP-05 trigger. It has only a v (version) tag and an ephemeral pubkey. The content is encrypted token chunks addressed to a notification server via gift wrap, so there is nothing to link. | +| 447 | `TokenRequestEvent` | content entries[member_id] -> MEMBER (U); content entries[server_pubkey] -> NOTIFICATION_SERVER (U) | new parser needed (entries() = PushGossip.decodeTokens -> PushTokenEntry.memberIdHex/serverPubKeyHex) | Marmot MIP-05. This is an unsigned inner app payload inside kind 445. It is never relay-visible and is readable only by group members. UNCERTAIN whether to model it at all. Otherwise it would be status none in practice. For a self-update, member_id is the sender. Empty content is a request and has no links. | +| 448 | `TokenListEvent` | content entries[member_id] -> MEMBER (U); content entries[server_pubkey] -> NOTIFICATION_SERVER (U) | new parser needed (entries() = PushGossip.decodeTokens) | Marmot MIP-05. This is an unsigned inner payload, never relay-visible (UNCERTAIN, as for 447). The entries include OTHER members' records relayed with their owner_sig, so member_id is not the sender. | +| 449 | `TokenRemovalEvent` | content entries[member_id] -> MEMBER (U); content entries[server_pubkey] -> NOTIFICATION_SERVER (U) | new parser needed (entries() = PushGossip.decodeRemovals) | Marmot MIP-05 removal (tombstones). This is an unsigned inner payload, never relay-visible (UNCERTAIN, as for 447). A dedicated REMOVED relation would be overkill for a payload no graph sees. | +| 10051 | `KeyPackageRelayListEvent` | *none* | – | Marmot MIP-00 KeyPackage relay list. It holds only relay tags, which are not modelled. | +| 30443 | `KeyPackageEvent` | i -> TAG (T) | keyPackageRef() (KeyPackageRefTag, tag 'i') | Marmot MIP-00. i holds the KeyPackageRef hex (a lookup key). The MLS parameter tags, client and the relays list are not modelled. | + +### `nip53LiveActivities` (8) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1311 | `LiveActivitiesChatMessageEvent` | a[1st, root marker optional] -> ROOT (A); e -> PARENT (E); p -> MENTION (U); q -> QUOTE (E,A); t -> HASHTAG (T); content nostr: -> MENTION (E,A,U) | activity()/activityAddress() (ATag::parse), BaseThreadedEvent.reply(), PTag::parseKey, QTag::parseEventId/parseAddressId, tags.hashtags(), citedNIP19() | NIP-53: the activity a is the ROOT (the spec example uses the 'root' marker; the text says just 'a'), and e is the direct parent. The activity can be a 30311 or, via roomMessage(), a 30312 space. Bug: unmarkedReplyTos() calls super.markedReplyTos() (copy-paste). Known QTag.parseAddressId bug. | +| 1312 | `LiveActivitiesRaidEvent` | a[root] -> ROOT (A); a[mention] -> RAIDED (A) | fromActivity()/fromAddress(), toActivity()/toAddress() | zap.stream convention, not in NIP-53 master. The root marker is the source stream (the one raiding), so the marker gives ROOT. The mention marker is the target. Rule 7 ('marker wins') would say MENTION, but the draft already chose RAIDED because the target IS the statement. Flag this tension in rule 7. Both are filtered to kind 30311. | +| 1313 | `LiveActivitiesClipEvent` | a -> CLIPPED (A); p -> CLIPPED_AUTHOR (U); r -> TAG (T) | activity()/activityAddress(), host() (PTag::parseKey), videoUrl() (ReferenceTag::parse) | zap.stream convention, not in NIP-53 master. The p is the stream host, which is not necessarily the 30311 signer (a provider may sign), hence CLIPPED_AUTHOR rather than the address AUTHOR. r is the playable video URL. | +| 10112 | `NestsServersEvent` | *none* | – | Nests audio-room server list (server/relay URLs plus auth URLs). The servers are not modelled. | +| 10312 | `MeetingRoomPresenceEvent` | a[root] -> ROOT (A) | interactiveRoom()/linkedAddressIds() (MeetingSpaceTag::parse / parseAddressId) | NIP-53 room presence: ['a', , relay, 'root'], with the ROOT as in the draft. hand, muted, publishing and onstage are flags, not links. Bug: MeetingSpaceTag.assemble writes ['a', addr, relay] WITHOUT the 'root' marker the NIP requires, so the parser must accept an unmarked a. build(root: MeetingRoomEvent) points the presence at a 30313 meeting, while the spec says the room (30312). Both occur. | +| 30311 | `LiveActivitiesEvent` | p -> PARTICIPANT (U); pinned -> PIN (E); goal -> GOAL (E) | participants() (ParticipantTag::parse), pinned() (PinnedEventTag::parse), goalEventId() | NIP-53. Props on PARTICIPANT: role (Host/Speaker/Participant) and proof. UNCERTAIN: rule 4 may justify HOST as its own relation, since the signer is often a provider and the Host p is the actual streamer ('streams by X' queries). t is in the spec but not read by Quartz, so it is not listed. streaming, recording and relays URLs are not modelled. | +| 30312 | `MeetingSpaceEvent` | p -> PARTICIPANT (U) | participants() (ParticipantTag::parse) | NIP-53 space. The p entries are providers with roles (Host/Moderator/Speaker), carried as props. t is in the spec but not read by Quartz. The service, endpoint and relays URLs are not modelled. Style: inline fully-qualified tag names in the class body. | +| 30313 | `MeetingRoomEvent` | a -> PARENT (A); p -> PARTICIPANT (U); pinned -> PIN (E) | interactiveRoom() (MeetingSpaceTag::parse), participants(), pinned() | NIP-53 meeting: the a is the parent space (30312), with PARENT as in the draft. pinned is not in NIP-53 for 30313, but Quartz reads it (the draft lists it). | + +### `nip43RelayMembers` (7) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 8000 | `RelayAddMemberEvent` | p -> ADDED_USER (U) | memberPubKeys() (PTag::parseKey) | NIP-43. Signed by the relay NIP-11 self key. UNCERTAIN: whether to unify with NIP-29 9000 or give that its own PUT_USER, since put-user also re-puts roles of existing members. | +| 8001 | `RelayRemoveMemberEvent` | p -> REMOVED_USER (U) | memberPubKeys() (PTag::parseKey) | NIP-43. Signed by the relay self key. | +| 13534 | `RelayMembershipListEvent` | member -> MEMBER (U) | membersWithRoles() (MemberTag::parseMember) | NIP-43 membership list, signed by the relay self key. Props: roles, which are the d-tags of 33534 role events and resolvable to Address 33534:: if roles ever become links. | +| 28934 | `RelayJoinRequestEvent` | *none* | – | NIP-43 join request. Its only data is the claim (invite code), which is not modelled. Ephemeral. | +| 28935 | `RelayInviteRequestEvent` | *none* | – | NIP-43 invite request. It has no tags beyond the initializer. Ephemeral. | +| 28936 | `RelayLeaveRequestEvent` | *none* | – | NIP-43 leave request. It carries only the NIP-70 '-' tag. Ephemeral. | +| 33534 | `RelayRoleEvent` | *none* | – | NIP-43 role definition: d = role id, with label, description, color and order. There are no references. | + +### `nip64Chess` (7) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 30 | `JesterEvent` | e[1st, move events] -> ROOT (E); e[2nd, move events] -> PARENT (E); p -> OPPONENT (U) | startEventId(), headEventId(), opponentPubkey() | Jester protocol (jesterui FLOW.md), not NIP-64. ROOT/PARENT: Jester links moves as [startId, headId], which is the game thread's root and the previous move. On START events (content.kind=0) the single e is JesterProtocol.START_POSITION_HASH, a sha256 of the start FEN rather than an event id. It is not modelled, and a shape-based rule would make it a phantom Event node that every Jester game links to. Quartz quirk: startEventId() returns that hash for start events. The link needs the content kind, so it needs a content parse. p is set only for private challenges and moves. | +| 64 | `ChessGameEvent` | *none* | – | NIP-64: PGN in content, and only alt as a tag. The White/Black PGN headers are free-text names, not pubkeys. | +| 30064 | `LiveChessGameChallengeEvent` | p -> OPPONENT (U) | opponentPubkey() (OpponentTag::parseKey) | Amethyst-only live chess kind (docs/live-chess-implementation-status.md), not NIP-64. With no p, it is an open challenge. d = gameId, a value. | +| 30065 | `LiveChessGameAcceptEvent` | e -> ACCEPTED (E); p -> OPPONENT (U) | challengeEventId() (ChallengeEventTag::parse), opponentPubkey() | Amethyst-only kind. The p is the challenger. The game itself (the challenge address 30064::) is derivable only from the e, whose tag[3] author Quartz writes. | +| 30066 | `LiveChessMoveEvent` | p -> OPPONENT (U) | opponentPubkey() | Amethyst-only kind. game_id and d (gameId-moveN) are values. The game is 30064::, but the challenger may be the author or the opponent, so it is not derivable from the event alone. A GAME (A) relation would need the challenge in hand (UNCERTAIN, not proposed). | +| 30067 | `LiveChessGameEndEvent` | p -> OPPONENT (U); winner -> WINNER (U) | opponentPubkey(), winnerPubkey() (WinnerTag::parse) | Amethyst-only kind. Props: result and termination. Bug-ish: WinnerTag.parse accepts any non-empty string (no 64-hex check), so it needs validation before becoming a User link. | +| 30068 | `LiveChessDrawOfferEvent` | p -> OPPONENT (U) | opponentPubkey() | Amethyst-only kind. d = gameId. | + +### `nip15Marketplace` (6) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1021 | `BidEvent` | e -> AUCTION (E); p -> AUCTION_AUTHOR (U) | auctionId() (ETag::parseId), PTag::parseKey | NIP-15: ['e', ], with content = amount (props: amount). The p is Quartz's addition, not in NIP-15. The auction is a 30020 addressable event referenced by id, so the target is E. | +| 1022 | `BidConfirmationEvent` | e[1st] -> BID (E); e[2nd] -> AUCTION (E); p -> BID_AUTHOR (U) | new parser needed (positional e; Quartz only has linkedEventIds() = all ETag ids), PTag::parseKey | NIP-15: [['e', ], ['e', ]], order-defined. Quartz writes bid then auction but exposes no bidId()/auctionId(). Props: status (accepted/rejected/pending/winner) and duration_extension from content. The p is Quartz's addition. | +| 30017 | `StallEvent` | *none* | – | NIP-15 stall: d plus content JSON (name, currency, shipping). There are no references. | +| 30018 | `ProductEvent` | content stall_id -> STALL (A); t -> HASHTAG (T) | new parser needed for STALL (productData().stallId -> Address 30017::), categories() (hashtags) | NIP-15. This is a content reference, not a tag. t holds categories. | +| 30019 | `MarketplaceEvent` | content merchants[] -> MERCHANT (U) | new parser needed (marketplaceData().merchants) | NIP-15 marketplace UI/UX. This is a content reference. MERCHANT follows rule 7's list naming; MEMBER is the alternative (UNCERTAIN). | +| 30020 | `AuctionEvent` | content stall_id -> STALL (A); t -> HASHTAG (T) | new parser needed (auctionData().stallId), tags.hashtags() | NIP-15 auction. Bids reference it by EVENT id (1021/1022), not by address. | + +### `nip28PublicChat` (6) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 40 | `ChannelCreateEvent` | a -> MENTION (A) | ATag::parseAddressId (via linkedAddressIds) | NIP-28 defines no tags on kind 40 (metadata and relays live in content JSON; relays are not modelled). UNCERTAIN: Quartz reads arbitrary a tags as address hints, and nothing gives them a meaning. They could also be dropped. Known bug: linkedEventIds() returns the event's own id. The status is effectively none, except for the stray a tags. | +| 41 | `ChannelMetadataEvent` | e[root] -> ROOT (E) | BasePublicChatEvent.channel()/channelId() (MarkedETag.parseRoot ?: parseUnmarkedRoot) | NIP-28: ['e', , relay, 'root']. The channel is the ROOT, as the draft already decides. NIP-28 also allows t (categories) on 41, which Quartz never reads or writes, so it is not listed. If t is later read, t -> HASHTAG (T). | +| 42 | `ChannelMessageEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); p -> MENTION (U); q -> QUOTE (E,A); a -> MENTION (A); content nostr: -> MENTION (E,A,U) | channel()/channelId(), BaseThreadedEvent.reply()/markedReply(), PTag::parseKey, QTag::parseEventId/parseAddressId, ATag::parseAddressId, citedNIP19() | NIP-28 root is the channel and reply is the parent message. The NIP-28 reply example has a p for the replied-to author. Quartz writes it via notify() as a plain p, so it is MENTION per the draft. It could be PARENT_AUTHOR, but no marker distinguishes it (UNCERTAIN). markedReplyTos/unmarkedReplyTos already strip the channel id. Known bug: QTag.parseAddressId rejects every address, so q addresses are lost until it is fixed. | +| 43 | `ChannelHideMessageEvent` | e[root] -> ROOT (E); e[unmarked] -> HIDDEN (E) | channel() (MarkedETag.parseRoot), ETag::parseId for the hidden ids (must exclude the root) | NIP-28 kind 43 carries only ['e', ]. Quartz ALSO writes the channel as a root-marked e. Bugs: (1) eventsToHide() = taggedEventIds() includes the channel root id, so the channel is 'hidden' too. (2) On a spec-conformant 43 (no root), channel() falls back to parseUnmarkedRoot and returns the HIDDEN MESSAGE as the channel. The semantic method must split root from unmarked. | +| 44 | `ChannelMuteUserEvent` | e[root] -> ROOT (E); p -> CHANNEL_MUTED (U) | channel() (MarkedETag.parseRoot), usersToMute() (PTag::parseKey) | NIP-28 kind 44 carries only ['p', pubkey]. The channel root e is Quartz's addition (see 43). CHANNEL_MUTED is already in the draft. | +| 10005 | `PublicChatListEvent` | e -> SUBSCRIBED (E) | channels() (ChannelTag::parse), linkedEventIds() (ChannelTag::parseId) | NIP-51 public chats list, pointing at NIP-28 kind 40 channels. This matches the draft (SUBSCRIBED lists 10005). Private entries are NIP-44 encrypted in content, visible only to the owner, and not linked. ChannelTag reads an optional author at position 2-4, a candidate for props. | + +### `nipACWebRtcCalls` (6) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 25050 | `CallOfferEvent` | p -> RECIPIENT (U) | recipientPubKeys() (PTag::parseKey) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. | +| 25051 | `CallAnswerEvent` | p -> RECIPIENT (U) | recipientPubKeys() (PTag::parseKey) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. | +| 25052 | `CallIceCandidateEvent` | p -> RECIPIENT (U) | PTag::parseKey (no recipientPubKeys() accessor on this class) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. In group calls ICE candidates carry only the peer. | +| 25053 | `CallHangupEvent` | p -> RECIPIENT (U) | recipientPubKeys() (PTag::parseKey) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. | +| 25054 | `CallRejectEvent` | p -> RECIPIENT (U) | recipientPubKeys() (PTag::parseKey) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. | +| 25055 | `CallRenegotiateEvent` | p -> RECIPIENT (U) | recipientPubKeys() (PTag::parseKey) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. | + +### `concord` (5) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 3302 | `ConcordChatEditEvent` | e -> EDITED (E) | editedMessageId() (firstTaggedEvent) | Concord CORD-02 Appendix B edit rumor. channel / epoch / ms binding tags carry Concord-internal channel ids, not Nostr entities: not modelled. Draft row verified. | +| 3308 | `ControlEditionEvent` | *none* | vsk()/eid()/ev()/ep()/vac() (concord/cord04Roles/control/tags) | Concord CORD-02/04 control-plane edition. eid (entity id), ep (prev edition hash), vac (grant id/version/hash) are Concord entity ids/hashes, not Nostr event ids/addresses/pubkeys; content is entity JSON. No Nostr links. | +| 13302 | `ConcordCommunityListEvent` | *none* | decrypt()/decryptDocument() (NIP-44 self-encrypted content, no tags) | Concord CORD-05 joined-communities list; everything (community roots, keys) is in encrypted content; built with emptyArray() tags. | +| 13303 | `ConcordInviteListEvent` | *none* | decrypt() (NIP-44 self-encrypted content, no tags) | Concord CORD-05 invite list; tokens and link-signer keys encrypted; no tags. | +| 33301 | `ConcordInviteBundleEvent` | *none* | versionedSubKind() (VskTag); content NIP-44 encrypted under the link token | Concord CORD-05 invite bundle: d='' and vsk only; no Nostr references visible. | + +### `nip71Video` (5) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 21 | `VideoNormalEvent` | p -> PARTICIPANT (U) [props role=label when present]; p[mention]/a[mention]/e[mention] -> MENTION (U,A,E); p[inspired-by]/a[label]/e[label e.g. audio] -> CREDITED (U,A,E) [props credit=label]; text-track[nevent/address ref] -> TEXT_TRACK (E,A); t -> HASHTAG (T) | participants() (PTag::parse), credits() (VideoCredits.parse: p/a/e with marker labels), textTrack() (TextTrackTag::parse; ref untyped, needs Address/NIP-19 detection), hashtags() | NIP-71: p = 'participant in the video'; text-track = 'link to WebVTT file' but example uses an encoded event and divine.video writes a 39307 address; r (web refs) is in the spec but Quartz does not read it. Credits labels are divine.video convention, not NIP-71. UNCERTAIN: whether role labels like 'Collaborator' stay PARTICIPANT(props role) or become CREDITED. Quartz gap: video classes implement no Event/PubKey/Address hint provider although they carry p/a/e. DRAFT FIX: draft lists 34238 (video collaboration) as REFERENCE-only; the collaborator p/credit convention here overlaps it. DRAFT FIX: PARTICIPANT kinds should add 21, 22, 34235, 34236. | +| 22 | `VideoShortEvent` | p -> PARTICIPANT (U) [props role=label when present]; p[mention]/a[mention]/e[mention] -> MENTION (U,A,E); p[inspired-by]/a[label]/e[label e.g. audio] -> CREDITED (U,A,E) [props credit=label]; text-track[nevent/address ref] -> TEXT_TRACK (E,A); t -> HASHTAG (T) | participants() (PTag::parse), credits() (VideoCredits.parse: p/a/e with marker labels), textTrack() (TextTrackTag::parse; ref untyped, needs Address/NIP-19 detection), hashtags() | Same tags as kind 21 (RegularVideoEvent). NIP-71. | +| 34235 | `AddressableNormalVideoEvent` | p -> PARTICIPANT (U) [props role=label when present]; p[mention]/a[mention]/e[mention] -> MENTION (U,A,E); p[inspired-by]/a[label]/e[label e.g. audio] -> CREDITED (U,A,E) [props credit=label]; text-track[nevent/address ref] -> TEXT_TRACK (E,A); t -> HASHTAG (T) | participants() (PTag::parse), credits() (VideoCredits.parse: p/a/e with marker labels), textTrack() (TextTrackTag::parse; ref untyped, needs Address/NIP-19 detection), hashtags() | Same tags as kind 21 (AddressableVideoEvent). NIP-71 addressable video. | +| 34236 | `AddressableShortVideoEvent` | p -> PARTICIPANT (U) [props role=label when present]; p[mention]/a[mention]/e[mention] -> MENTION (U,A,E); p[inspired-by]/a[label]/e[label e.g. audio] -> CREDITED (U,A,E) [props credit=label]; text-track[nevent/address ref] -> TEXT_TRACK (E,A); t -> HASHTAG (T) | participants() (PTag::parse), credits() (VideoCredits.parse: p/a/e with marker labels), textTrack() (TextTrackTag::parse; ref untyped, needs Address/NIP-19 detection), hashtags() | Same tags as kind 21 (AddressableVideoEvent). NIP-71 addressable short video. | +| 39307 | `TextTrackEvent` | a -> VIDEO (A); l -> TAG (T) | video() (ATag::parseAddress), language() (LanguageTag, l) | divine.video convention (not in NIP-71): addressable timed-text track referenced from a video's text-track tag. url is the hosted WebVTT (not modelled). | + +### `nip85TrustedAssertions` (5) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10040 | `TrustProviderListEvent` | slot 1 pubkey -> SERVICE_PROVIDER (U) [props service] | serviceProviders() (ServiceProviderTag::parse) | NIP-85 'Declaring Trusted Service Providers'. Draft SERVICE_PROVIDER row confirmed (one link per entry). Encrypted entries in content are invisible to the graph. | +| 30382 | `UserAssertionEvent` | d -> SUBJECT (U) [props rank, followers, hops, ...]; t -> HASHTAG (T) | aboutUser() (dTag), rank()/followerCount()/hops()/... for props, topics() (TopicTag, t) | NIP-85 kind 30382. Draft SUBJECT row confirmed. p with the same value as d is only a relay hint (no extra link). Encrypted contact-card fields (petname/summary) not modelled. | +| 30383 | `EventAssertionEvent` | d -> SUBJECT (E) [props rank, comment_cnt, ...] | aboutEvent() (dTag), rank()/commentCount()/... for props | NIP-85 kind 30383. Draft SUBJECT row confirmed; e equal to d is a relay hint only. | +| 30384 | `AddressableAssertionEvent` | d -> SUBJECT (A) [props rank, comment_cnt, ...] | aboutAddress() (dTag), rank()/... for props | NIP-85 kind 30384. Draft SUBJECT row confirmed; a equal to d is a relay hint only. | +| 30385 | `ExternalIdAssertionEvent` | d -> SUBJECT (T) [NIP-73 id; props rank, comment_cnt, reaction_cnt]; k -> TAG (T) | aboutExternalId() (dTag), rank()/commentCount()/reactionCount(); k: KindTag (not read by the class) | NIP-85 kind 30385 'NIP-73 identifier' subject; 'NIP-73 k tags should be added'. DRAFT FIX: SUBJECT row lists only 30382-30384 and targets U,E,A; add 30385 and target T (Tag name i). | + +### `cyberspace` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 3330 | `SnoShardEvent` | content palette (nevent/naddr) -> PALETTE (E,A) | SnoParser.parse -> SnoPayload.paletteRef (SnoPaletteRef.Event bech32) via readPaletteRef | Cyberspace DECK-0003 §3.2 bag item. Usually sealed (blank content). C coordinate tag is a cyberspace coordinate: not modelled. Pinned to the named event (reader MUST NOT follow forward) - the E target matters for nevent. | +| 11333 | `SnoAvatarEvent` | content palette (nevent/naddr) -> PALETTE (E,A) | SnoParser.parse -> SnoPaletteRef.Event | Cyberspace v2 §8.10 avatar (replaceable). name tag and nonce/PoW: values, not modelled. Blank content = default avatar (no link). | +| 33330 | `CyberspaceBagEvent` | *none* | lookupId() (d), height() (h), hint(), payload() (encrypted tag) | Cyberspace v2 §7.6 bag: items are AES-GCM encrypted inside the `encrypted` tag; d = region lookup id, h = height, version: values. Items once opened are their own events (not links). No Nostr references in cleartext. | +| 33331 | `SnoObjectEvent` | content palette (nevent/naddr) -> PALETTE (E,A) | SnoParser.parse -> SnoPaletteRef.Event | Cyberspace DECK-0003 §3.1 standalone object; name tag is a value. | + +### `nip47WalletConnect` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 13194 | `NwcInfoEvent` | *none* | capabilities() (content), EncryptionTag/NotificationsTag/ExtensionsTag | NIP-47 info event: capabilities, encryption schemes, notification types; no references. | +| 23194 | `NwcRequestEvent` | p -> RECIPIENT (U) | walletServicePubKey() (first p) | NIP-47: p = 'the public key of the wallet service'. Chose RECIPIENT (the addressee an encrypted message is p-tagged and encrypted to, rule 2) over the NIP's role word. UNCERTAIN: alt WALLET_SERVICE (U) if wallet-service graphs are wanted. Ephemeral kind - rarely stored. | +| 23195 | `NwcResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U) | requestId() (first e), requestAuthor() (first p) | NIP-47. UNCERTAIN: p could equally be RECIPIENT (it is the encryption addressee); REQUEST_AUTHOR chosen because it is always the request's author and matches rule 3. Ephemeral kind. | +| 23197 | `NwcNotificationEvent` | p -> RECIPIENT (U) | clientPubKey() (first p) | NIP-47 notification (legacy 23196 same shape): p = client pubkey, encrypted to it. Ephemeral. | + +### `nip52Calendar` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 31922 | `CalendarDateSlotEvent` | p -> PARTICIPANT (U); a -> CALENDAR (A); t -> HASHTAG (T); g -> TAG (T); r -> TAG (T) | participants() (PTag), hashtags(), geohash(), references(); a -> new parser needed | NIP-52. Props on PARTICIPANT: role (p slot 3). location/start/end values. Draft PARTICIPANT row verified; DRAFT FIX: add CALENDAR for the inclusion-request a. | +| 31923 | `CalendarTimeSlotEvent` | p -> PARTICIPANT (U); a -> CALENDAR (A); t -> HASHTAG (T); g -> TAG (T); r -> TAG (T) | participants() (PTag), hashtags(), geohash(), references(); a -> new parser needed | NIP-52. D day-index, start/end/tzid values. Props role on PARTICIPANT. | +| 31924 | `CalendarCollectionEvent` | a -> MEMBER (A) | calendarEventAddresses() (taggedAddresses) / ATag::parseAddressId | NIP-52 calendar: a = 31922/31923 events it includes. Draft MEMBER row verified. | +| 31925 | `CalendarRSVPEvent` | a -> CALENDAR_EVENT (A); e -> CALENDAR_EVENT (E); p -> CALENDAR_EVENT_AUTHOR (U) | calendarEventAddress() (firstTaggedAddress), calendarEventId() (firstTaggedEvent), calendarEventAuthor() (PTag) | NIP-52 RSVP. Props status (accepted/declined/tentative) and fb. Draft CALENDAR_EVENT row verified; DRAFT FIX: add the author relation. | + +### `nip54Wiki` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 818 | `WikiMergeRequestEvent` | a -> DESTINATION (A); p -> DESTINATION_AUTHOR (U); e[source] -> SOURCE (E); unmarked e -> BASE_VERSION (E) | targetArticle() (ATag::parseAddress), destinationAuthor() (PTag::parseKey), mergeSource() (e with source\|fork marker), baseVersion() (unmarked e) | NIP-54 Merge Requests. DRAFT FIX: remove 818 from the REFERENCE-until-classified list. Quartz also accepts `fork` as the source marker. UNCERTAIN: SOURCE is a very generic name in a cross-kind vocabulary (alt: MERGE_SOURCE); BASE_VERSION alt: BASED_ON. | +| 819 | `WikiMergeAcceptanceEvent` | e[result] -> RESULT (E); e[request] -> REQUEST (E); p -> REQUEST_AUTHOR (U) | result()/request() (markedEvent by marker), requester() (PTag::parseKey) | Kind 819 is NOT in NIP-54 on nostr-protocol/nips master (NIP-54 says the destination accepts/rejects via NIP-25 reactions to the 818); Quartz-only / proposal. UNCERTAIN until specified. DRAFT FIX: remove 819 from the REFERENCE list. | +| 30818 | `WikiArticleEvent` | a[fork] -> FORK (A); e[fork] -> FORK (E); a[defer] -> DEFER (A); e[defer] -> DEFER (E); other a/e -> MENTION (E,A); p -> MENTION (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U); t -> HASHTAG (T) | forkFromAddress() (ForkTag::parseAddress), forkFromVersion() (MarkedETag::parseForkedEventId), ATag/MarkedETag/PTag/QTag, citedNIP19(); defer -> new parser needed | NIP-54. DRAFT FIX: draft lists 30818 under PARENT, but NIP-54 defines no parent/reply for articles - its a/e are fork (and defer) references; move 30818 to FORK (and FORK needs A and E targets). Content is Asciidoc/Markdown with wikilinks to d-tags ([[...]]), which are slugs, not addresses: not modelled. Known: QTag.parseAddressId rejects every address. | +| 30819 | `WikiRedirectEvent` | a -> REDIRECT (A) | target() (ATag::parseAddress) | NIP-54 redirects; d = normalized from-slug. Draft row verified. | + +### `nip58Badges` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 8 | `BadgeAwardEvent` | a -> BADGE_DEFINITION (A); p -> AWARDED (U) | awardDefinition() (taggedAddresses), awardeeIds() (taggedUserIds) | NIP-58: single a (30009) + one p per awardee. Hint providers also read e tags, which NIP-58 does not define for kind 8 (ignore). Draft rows verified. | +| 10008 | `ProfileBadgesEvent` | a (paired) -> BADGE_DEFINITION (A); e (paired) -> BADGE_AWARD (E); a to kind 30008 -> BADGE_SET (A) | acceptedBadges() (AcceptedBadge.parseAll pairs); badgeAwardDefinitions() (taggedAddresses); BADGE_SET split -> new parser needed | NIP-58 (10008 is a NIP-51 standard list). Quartz: badgeAwardDefinitions() returns every a tag, so a 30008 badge-set pointer reads as a badge definition (bug for the relation). Hint providers read p tags NIP-58 does not define here. | +| 30008 | `AcceptedBadgeSetEvent` | a (paired) -> BADGE_DEFINITION (A); e (paired) -> BADGE_AWARD (E) | acceptedBadges() (AcceptedBadge.parseAll), badgeAwardEvents(), badgeAwardDefinitions() | NIP-58 Badge Set (NIP-51 set); d=profile_badges is the legacy profile-badges form (treat as 10008). title/image/description values. Draft rows verified. | +| 30009 | `BadgeDefinitionEvent` | *none* | badgeName/badgeImage/badgeThumbs/badgeDescription | NIP-58 badge definition: name, image and thumb URLs only. (ADDRESS/AUTHOR only.) | + +### `nip60Cashu` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 7374 | `CashuMintQuoteEvent` | *none* | – | NIP-60 quote: content is the encrypted quote id; tags expiration and mint URL (not modelled). | +| 7375 | `CashuTokenEvent` | *none* | – | NIP-60 token: everything (mint, proofs, del token ids) is NIP-44 encrypted in content; no public tags. The encrypted del list would be DESTROYED links but is invisible to the graph. | +| 7376 | `CashuSpendingHistoryEvent` | e[redeemed] -> REDEEMED (E); p -> REDEEMED_AUTHOR (U); e[created] -> CREATED (E); e[destroyed] -> DESTROYED (E) | redeemedNutzaps() / redeemedReferences() (TokenReference::parseFromTag), PTag::parseKey; created/destroyed: TokenReference on public tags (usually encrypted) | NIP-60 says created/destroyed e tags SHOULD be encrypted and only redeemed stays public, so CREATED/DESTROYED are rare in the graph (encrypted tags never reach it). UNCERTAIN: p could instead reuse ZAP_SENDER (NIP-61 calls it the 'nutzap sender'), but rule 3 favours REDEEMED_AUTHOR. | +| 17375 | `CashuWalletEvent` | *none* | – | NIP-60 wallet: privkey and mint tags are NIP-44 encrypted in content; no public references. | + +### `nip72ModCommunities` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 4550 | `CommunityPostApprovalEvent` | a[34550] -> COMMUNITY (A); e -> APPROVED (E); a[non-34550] -> APPROVED (A); p -> APPROVED_AUTHOR (U); k -> TAG (T) | communityAddresses() (CommunityTag), approvedEvents() (ApprovedEventTag::parseId), approvedAddresses() (ApprovedAddressTag), PTag::parseKey; k: KindTag | NIP-72 approval. Draft COMMUNITY/APPROVED rows confirmed. Content embeds the approved post JSON (containedPost()) - same id as e, not a separate link. | +| 10004 | `CommunityListEvent` | a[34550] -> SUBSCRIBED (A) | publicCommunities() / communityIds() (CommunityTag) | NIP-51 Communities list: 'NIP-72 communities the user belongs to'. Draft SUBSCRIBED row confirmed (MEMBER would match 'belongs to', but the draft chose SUBSCRIBED for follow-like lists; keep). Private (encrypted) entries never reach the graph. | +| 34550 | `CommunityDefinitionEvent` | p[moderator] -> MODERATOR (U); e/q/a -> MENTION (E,A) | moderators()/moderatorKeys() (ModeratorTag), ETag/QTag/ATag hint providers | NIP-72: p with role 'moderator'; relay tags (URLs) not modelled. Draft MODERATOR row confirmed. Quartz: ModeratorTag.parse accepts any p regardless of the role marker. UNCERTAIN: NIP-72 defines no e/q/a on 34550 yet the hint providers read them; MENTION assumed - confirm or drop. | +| 34551 | `CommunityRulesEvent` | a[34550] -> COMMUNITY (A); p[allow] -> ALLOWED (U) [props role]; p[deny] -> DENIED (U) [props role]; wot -> WOT_ROOT (U) [props depth]; k -> TAG (T) | communityAddress() (ATag), pubkeyRules() (PubkeyRuleTag::parse), wotGates() (WotTag::parse), kindRules() (KindRuleTag) | NIP-9B/9A 'Verifiable Community Rules' (unmerged upstream, 404; read from Quartz KDoc). UNCERTAIN: ALLOWED/DENIED could be one relation with props.policy, but deny vs allow is the filter every query applies (rule 4). | + +### `nipCCGeocaching` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 7516 | `GeocacheFoundLogEvent` | a[37516] -> FOUND (A) | geocache() / geocacheId() (GeocacheTag = ATag, filtered to kind 37516 or legacy kind) | NIP-CC. Props: verified (hasVerificationAttached()). The verification tag embeds a full 7517 JSON (embeddedVerification()) - not a link by id. image URLs not modelled. Non-found logs are NIP-22 1111s (ROOT/PARENT to the 37516). DRAFT FIX: draft lists geocaching among REFERENCE-only experimental kinds; now classified. | +| 7517 | `GeocacheVerificationEvent` | a[:] finder -> FINDER (U); a[:] cache -> VERIFIED (A) | finder() / verifiedCache() (FinderCacheTag::parseFinder / parseCache) | NIP-CC. The a tag is a non-standard composite ':', so a shape-based ATag parser would misread it. Signed by the cache's verification key, not the finder (so AUTHOR = the key named by 37516's verification tag). | +| 37516 | `GeocacheListingEvent` | F -> WINNER (U); verification -> VERIFIER (U); t -> HASHTAG (T); g -> TAG (T) | firstToFindWinner() (FirstToFindWinnerTag, F), verificationKey() (VerificationKeyTag), cacheType()/isArchived() (t), geohashes() | NIP-CC. t here is the cache type / 'archived', not a free hashtag (HASHTAG per the rule, flagged). r are relay URLs for logs (NOT web refs) - not modelled; must not become TAG r. n, D, T, S, hint, mission, image not modelled. UNCERTAIN: VERIFIER is a dedicated key, not a person's identity. | +| 37517 | `GeocacheCurationListEvent` | a[37516] -> CURATED (A); g -> TAG (T) | curatedGeocaches() / curatedAddresses() (ATag), geohashes() | NIP-CC curation list. Draft CURATED row (37517) confirmed. | + +### `nipF4Podcasts` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 54 | `PodcastEpisodeEvent` | *none* | – | NIP-F4 kind 54: title, image, description, audio (URLs). Authored by the podcast's own key, so AUTHOR is the show. | +| 10054 | `FavoritePodcastsListEvent` | p -> FAVORITE (U) | publicFavorites() (UserTag::parse) | NIP-51 / NIP-F4. NIP-51 also allows url (RSS feed URLs) - class ignores them, not modelled. Quartz gap: no PubKeyHintProvider. UNCERTAIN: alternative is BOOKMARK with a U target (NIP-51 literally says 'bookmark') or SUBSCRIBED (NIP-F4: 'publicly advertise to listening to'). | +| 10064 | `AuthoredPodcastsEvent` | p -> AUTHORED (U) | authoredKeys() / linkedPubKeys() (UserTag::parseKey) | NIP-F4 'Authored Podcasts' (spec text says kind 10164 once but the example and NIP-51 say 10064). DRAFT FIX: draft MEMBER row lists 10064; this is not a membership set but an authorship claim, which a query must join with 10154's PODCAST_AUTHOR (both directions must agree). | +| 10154 | `PodcastMetadataEvent` | p -> PODCAST_AUTHOR (U) [props role host/cohost/editor] | claimedAuthors() (AuthorTag::parse) | NIP-F4: the claim 'shouldn't be blindly trusted' until matched by the author's 10064 (AUTHORED). website/image URLs not modelled. Quartz: AuthorTag drops unknown roles (role null) and no PubKeyHintProvider. UNCERTAIN: could reuse PARTICIPANT(props role) as NIP-53 does for Host. | + +### `nip17Dm` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 14 | `ChatMessageEvent` | p -> RECIPIENT (U); e -> PARENT (E); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U); zap -> ZAP_SPLIT (U) | recipientsPubKey() (BaseDMGroupEvent, PTag); replyTo() (ETag::parseId); q and content citations -> new parser needed (citedNIP19 lives on BaseNoteEvent, not BaseDMGroupEvent); zapSplitSetup() | NIP-17: p = receivers, e = 'the direct parent message this post is replying to', q MAY cite NIP-21 in content. Draft rows verified; DRAFT FIX: QUOTE and MENTION kinds should include 14. subject tag is a value, not modelled. Rumor kind: normally only reaches a store unwrapped. | +| 15 | `ChatMessageEncryptedFileHeaderEvent` | p -> RECIPIENT (U); e[reply] -> PARENT (E) | recipientsPubKey() (BaseDMGroupEvent); replyTo() (ETag::parseId) | NIP-17 file message. DRAFT FIX: PARENT kinds should list 15. x/ox are blob hashes of an encrypted file, content is the file URL: not modelled. No hint provider for the e tag (Quartz gap, like kind 4). | +| 10050 | `DmRelayListEvent` | *none* | RelayTag::parse (relays()) | NIP-17 DM inbox relays: relay URLs only: not modelled. | + +### `nip57Zaps` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9733 | `PrivateZapEvent` | e -> ZAPPED (E); a -> ZAPPED (A); p -> ZAP_RECIPIENT (U); k -> TAG (T) | ETag::parseId, ATag::parseAddressId, PTag::parseKey (hint providers); KindTag | NIP-57 private zap: the decrypted inner event of an anon tag, built by PrivateZapRequestBuilder from the zap request's tags minus `anon`, so it carries the same e/a/p/k. Draft rows verified. | +| 9734 | `ZapRequestEvent` | e -> ZAPPED (E); a -> ZAPPED (A); p -> ZAP_RECIPIENT (U); k -> TAG (T) | zappedPost() (ETag), ATag::parseAddress, zappedAuthor() (PTag), KindTag; amount tag for props msats | NIP-57 Appendix A/D: exactly one p, 0 or 1 e, optional a, k. Props msats from `amount`. relays/lnurl/anon/poll_option and NIP-29 h: not modelled. Draft rows verified. | +| 9735 | `ZapReceiptEvent` | e -> ZAPPED (E); a -> ZAPPED (A); p -> ZAP_RECIPIENT (U); P -> ZAP_SENDER (U); description (embedded 9734) -> ZAP_REQUEST (E); k -> TAG (T) | zappedPost(), ATag::parseAddress, zappedAuthor(); P -> new parser needed (Quartz reads only zappedRequestAuthor() = zapRequest?.pubKey); zapRequest (containedPost()) | NIP-57 Appendix E. Props msats from bolt11 (amount). ZAP_SENDER should come from P (NIP-57: 'P tag from the pubkey of the zap request (zap sender)'), falling back to the embedded request's pubkey; for anonymous zaps it is a throwaway key. Known upstream: ZapReceiptEvent omits the zap sender from its hint providers. UNCERTAIN: ZAP_REQUEST targets an event that is normally never published to relays. | + +### `nip59Giftwrap` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 13 | `SealEvent` | *none* | n/a (content is NIP-44 encrypted rumor; tags normally empty, optional expiration) | NIP-59. The rumor inside is its own event once unsealed; no link from the seal (innerEventId is local runtime state). | +| 1059 | `GiftWrapEvent` | p -> RECIPIENT (U) | recipientPubKey() (firstTagValue p) / PTag::parseKey | NIP-59/NIP-17. Signed by a throwaway key; content encrypted (inner event not linked). Draft row verified. | +| 21059 | `EphemeralGiftWrapEvent` | p -> RECIPIENT (U) | recipientPubKey() (inherited from GiftWrapEvent) | NIP-59 ephemeral gift wrap (CEP-19 uses it too). Draft row verified. | + +### `nip5dNapplets` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 5129 | `NappletSnapshotEvent` | a -> SNAPSHOTTED (A); A -> ORIGIN (A); app -> APP (A) | new parser needed (NappletManifest reads only path/server/requires/x/title/description/source/icon) | NIP-5D napplets are not on nostr-protocol/nips master (spec fetch 404); per NappletManifest they 'carry the same NIP-5A tag set', so the 5128 snapshot rules are applied by analogy. UNCERTAIN: whole row; Quartz build() writes none of a/A/app. path hashes, server (blossom) and source URLs: not modelled (source may be a NIP-34 nostr:// git URL - a future REPOSITORY candidate). | +| 15129 | `RootNappletEvent` | a -> COPIED (A); A -> ORIGIN (A); app -> APP (A) | new parser needed (NappletManifest) | NIP-5D root napplet; 'carries the NIP-5A tag set' (NappletManifest). requires = NAP capability domains (values, not modelled). UNCERTAIN: NIP-5D not on nips master; applies NIP-5A rules by analogy; Quartz writes none of these tags. | +| 35129 | `NamedNappletEvent` | a -> COPIED (A); A -> ORIGIN (A); app -> APP (A) | new parser needed (NappletManifest) | NIP-5D named napplet; NIP-5A tag set by analogy. UNCERTAIN: NIP-5D not on nips master. | + +### `nip87Ecash` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 38000 | `MintRecommendationEvent` | a[38172/38173] -> RECOMMENDED (A) [props platform cashu/fedimint]; k -> TAG (T) | mintEventAddresses() (raw a values - no Address validation), mintEventKind() (k) | NIP-87 recommendation. DRAFT FIX: RECOMMENDED kinds should add 38000. u values are mint URLs / fedimint invite codes - not modelled. Quartz bug: 38000, 38172, 38173 extend Event, not BaseAddressableEvent, though they are addressable (d-tagged, 3xxxx); mintEventAddresses() returns unvalidated strings. | +| 38172 | `CashuMintEvent` | *none* | – | NIP-87 cashu mint: d is the MINT's pubkey (not a Nostr user), u mint URL, nuts, n. Not modelled. Quartz: extends Event, not BaseAddressableEvent. | +| 38173 | `FedimintEvent` | *none* | – | NIP-87 fedimint: d federation id, u invite codes, modules, n. Quartz: extends Event, not BaseAddressableEvent. | + +### `nipB1Bolt12Zaps` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9736 | `Bolt12ZapEvent` | p -> ZAP_RECIPIENT (U); P -> ZAP_SENDER (U); e -> ZAPPED (E); a -> ZAPPED (A); k -> TAG (T) | recipient() (PTag), payer() (PayerTag, P), zappedEvent() (ETag), zappedAddress() (ATag), zappedKind(), amount() (msats), zapIntent (embedded 9737) | NIP-B1 not merged upstream (404); read from Quartz KDoc. DRAFT FIX: ZAP_SENDER kinds should add 9736 (the P payer tag, NIP-57's word). Props msats = amount(). description embeds the 9737 intent (its id is not a tag; not modelled). Anonymous zaps have no P. | +| 9737 | `Bolt12ZapIntentEvent` | p -> ZAP_RECIPIENT (U); e -> ZAPPED (E); a -> ZAPPED (A); k -> TAG (T) | recipient() (PTag), zappedEvent() (ETag), zappedAddress() (ATag), zappedKind(), amount() | NIP-B1 (unmerged). Draft lists 9737 under ZAPPED. UNCERTAIN: an intent is not a payment ('never counted on its own'), so ZAPPED/ZAP_RECIPIENT counts must filter on source kind 9736 - same situation as 9734 requests; the signer is the would-be sender (AUTHOR). | +| 10058 | `Bolt12OfferListEvent` | *none* | – | NIP-B1 (unmerged): offer tags (BOLT12 offers) only. | + +### `contextvm` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 11316 | `CvmServerAnnouncementEvent` | *none* | DiscoverySurface.parse(tags) (name/about/picture/website/support_* flags only) | ContextVM CEP-6 (+CEP-23/35). Discovery tags are self-description values; `p`/`e` are routing tags and CvmTags.ROUTING excludes them from the surface; not expected on an announcement. UNCERTAIN: CEP-17 `r` relay tags if present are relay URLs (not modelled). Kind number from CvmKinds.SERVER_ANNOUNCEMENT = 11316. | +| 11317 | `CvmToolsListEvent` | i -> TAG (T); k -> TAG (T) | CommonToolSchema.parseExternalIds(tags) (i); k written by CommonToolSchema.externalKindTag() | ContextVM CEP-6 + CEP-15 common tool schemas: NIP-73-style `[i, , ]` + `[k, io.contextvm/common-schema]` on the announcement. Content is the tools JSON (no nostr refs). UNCERTAIN: CEP-15 says 'one per announcement event' without naming 11316 vs 11317; Quartz's builder is not bound to a class, so the same i/k may also appear on 11316. | + +### `nip18Reposts` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 6 | `RepostEvent` | e -> REPOSTED (E); a -> REPOSTED (A); p -> REPOSTED_AUTHOR (U); k -> TAG (T) | boostedEventId()/boostedAddress() (last e/a), originalAuthorKeys() (PTag), boostedKind() (KindTag) | NIP-18. Content embeds the reposted event JSON (containedPost()) = the same id as e; not a separate link. Kind 6 per NIP-18 is only for kind 1 but Quartz writes `a` for addressables. Draft rows verified. Note boostedEventId takes the LAST e while linkedEventIds lists all; extra e tags (non-spec) would get no meaning - treat non-last e/p as MENTION. | +| 16 | `GenericRepostEvent` | e -> REPOSTED (E); a -> REPOSTED (A); p -> REPOSTED_AUTHOR (U); k -> TAG (T) | boostedEventId()/boostedAddress(), originalAuthorKeys(), boostedKind() | NIP-18 generic repost; a for replaceables, content JSON when a is absent (same id as e, not a separate link). Draft rows verified. | + +### `nip25Reactions` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 7 | `ReactionEvent` | last e -> REACTED (E); a -> REACTED (A); last p -> REACTED_AUTHOR (U); earlier e -> MENTION (E); earlier p -> MENTION (U); k -> TAG (T); emoji[emoji-set-address] -> EMOJI_SET (A) | ETag::parseId, ATag::parseAddress, PTag::parseKey (need last-of, see note), KindTag; EmojiUrlTag (slot 3 new parser) | NIP-25: 'the target event id should be last of the e tags' and 'the target event pubkey should be last of the p tags' (extra e/p are legacy thread copies - MENTION). Quartz bug: originalPost() / originalAuthor() return ALL e ids / p keys, not the last, while the draft cites them for REACTED/REACTED_AUTHOR. DRAFT FIX: cite lastNotNullOfOrNull(ETag::parseId)/(PTag::parseKey), not originalPost()/originalAuthor(). | +| 17 | `ExternalReactionEvent` | i -> REACTED (T); k -> TAG (T); emoji[emoji-set-address] -> EMOJI_SET (A) | externalIds() (ExternalTargetTag::parse), externalKinds() (ReplyKindTag::parse); EmojiUrlTag slot 3 new parser | NIP-25 external content reactions with NIP-73 k+i; several i pairs possible (show + episode), each a REACTED. The i hint (URL) is not a target. Draft row verified. | + +### `nip30CustomEmoji` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10030 | `EmojiListEvent` | a -> MEMBER (A); emoji[emoji-set-address] -> EMOJI_SET (A) | emojiPackIds() (ATag::parseAddressId); emoji tags: EmojiUrlTag (slot 3 new parser) | NIP-51 'Emojis: user preferred emojis and pointers to emoji sets' (a = kind 30030). Draft lists 10030 under MEMBER. UNCERTAIN / possible DRAFT FIX: the a entries are sets the user USES (a selection), which reads closer to SUBSCRIBED than to membership; rule 7 would name it after the list ('emoji sets'). Loose emoji tags (URLs) not modelled. | +| 30030 | `EmojiPackEvent` | emoji[emoji-set-address] -> EMOJI_SET (A) | tags.emojis() (EmojiUrlTag; slot 3 new parser); private emojis in NIP-44 content | NIP-51 emoji set / NIP-30. The emoji tags themselves are shortcode+URL (not modelled); only the optional 4th slot (the set an emoji came from, NIP-30) is a link. UNCERTAIN: may point at the pack itself - skip self-links. | + +### `nip35Torrents` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 2003 | `TorrentEvent` | i -> TAG (T); t -> HASHTAG (T); q -> QUOTE (E,A); p -> MENTION (U); r -> TAG (T) | HashtagTag/hashtags(); QTag; PTag; i -> new parser needed (TorrentEvent has no i accessor) | NIP-35: i = tcat/newznab/imdb/tmdb/... ids, t = categories. Quartz build() turns nostr: URIs in the description into q (note/nevent/naddr) and p (npub/nprofile via NPub.toQuoteTagArray = PTag) and URLs into r. x/btih info hash, file, tracker: not modelled. Content nostr: is not parsed on read (no citedNIP19 on this class). DRAFT FIX: remove 2003 from the unclassified list. | +| 2004 | `TorrentCommentEvent` | e[root] (or first) -> ROOT (E); e[reply] (or last) -> PARENT (E); middle unmarked e -> MENTION (E); p equal to parent author -> PARENT_AUTHOR (U); other p -> MENTION (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U) | torrent() / torrentIds() (MarkedETag::parseRoot, fallback first ETag), BaseThreadedEvent.reply()/markedReply(), PTag, QTag, citedNIP19() | NIP-35: 'works exactly like a kind 1 and should follow NIP-10'; the root is the 2003 torrent. Deprecated in Quartz (replaced by NIP-22). DRAFT FIX: ROOT, QUOTE and MENTION kinds should list 2004 (draft has it only under PARENT). | + +### `nip37Drafts` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10013 | `PrivateOutboxRelayListEvent` | *none* | RelayTag::parse (publicRelays()); private relays in NIP-44 content | NIP-37 private-outbox relay list: relay URLs only: not modelled. | +| 31234 | `DraftWrapEvent` | k -> TAG (T); e[root] -> ROOT (E); e[reply] -> PARENT (E); a -> ROOT (A) | KindTag (kind(draft.kind)); exposed tags from ExposeInDraft.exposeInDraft() (ChannelMessageEvent: e root/reply; LiveActivitiesChatMessageEvent: a activity + e reply) - MarkedETag/ATag | NIP-37. The draft itself is NIP-44 encrypted (no content links). Quartz copies the draft's thread anchors (channel, live activity, reply) into public tags so a draft shows in context; they carry the inner kind's meaning (the k tag says which). UNCERTAIN: whether exposed anchors of an unpublished draft should be graph links at all, or get DRAFT_-prefixed relations; kept as ROOT/PARENT per rule 2. | + +### `nip5aStaticWebsites` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 15128 | `RootSiteEvent` | a -> COPIED (A); A -> ORIGIN (A); app -> APP (A) | new parser needed (class reads only path/server/title/description/source/icon) | NIP-5A. path (blob sha256), x aggregate hash, server (blossom), source (URL or NIP-34 nostr:// git URL): not modelled. UNCERTAIN: COPIED/ORIGIN could reuse FORK/ROOT (rule 2: a copy is a fork; NIP-22 uses uppercase for the root) - chose the NIP's words per rule 7. | +| 35128 | `NamedSiteEvent` | a -> COPIED (A); A -> ORIGIN (A); app -> APP (A) | new parser needed (class reads only path/server/title/description/source/icon) | NIP-5A named site (d = identifier). Same notes as 15128. | + +### `nip61Nutzaps` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9321 | `NutzapEvent` | e -> ZAPPED (E); p -> ZAP_RECIPIENT (U); k -> TAG (T) | ETag::parseId, PTag::parseKey (no zappedEvent()/recipient() accessors), claimedSatsTotal() for props | NIP-61: 'p is the Nostr identity public key of nutzap recipient', 'e is the event that is being nutzapped'. Draft ZAPPED/ZAP_RECIPIENT rows confirmed; props msats = claimedSatsTotal*1000 (sender-claimed). u = mint URL, not modelled. The sender is the AUTHOR. NIP-61 has no a (addressable) target. | +| 10019 | `NutzapInfoEvent` | *none* | – | NIP-61: relay (URLs), mint (URLs), pubkey = the P2PK key, which 'MUST NOT' be the user's Nostr key - not a User node; not modelled. | + +### `nip66RelayMonitor` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10166 | `RelayMonitorEvent` | g -> TAG (T) | geohashes() | NIP-66 monitor announcement: frequency, timeout, c (checks), g. No E/A/U references. | +| 30166 | `RelayDiscoveryEvent` | t -> HASHTAG (T); g -> TAG (T); k -> TAG (T) | topics() (hashtags), geohashes(), acceptedKinds() (AcceptedKindTag, k) | NIP-66: d is the relay URL (not modelled); n, N, R, T, rtt-* are relay attributes. l (language) is in the spec example but not read by the class. | + +### `nip78AppData` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 78 | `AppDataEvent` | *none* | – | NIP-78: tags are arbitrary, app-private and non-interoperable; class reads only d. DRAFT FIX: draft Coverage lists 'app data and handlers (78, 30078, 31990)' among kinds carrying references; 78/30078 carry none by spec. | +| 30078 | `AppSpecificDataEvent` | *none* | – | NIP-78: arbitrary app-private tags; class reads only d. DRAFT FIX: see 78 - listed in Coverage as carrying references, but carries none by spec. | + +### `nip88Polls` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1018 | `PollResponseEvent` | e -> POLL (E); p -> POLL_AUTHOR (U) | poll() / PollTag::parseId, PTag::parseKey (written by notifyAuthor()) | NIP-88: 'an e tag with the poll event it is referencing, followed by one or more response tags'. Draft POLL row confirmed. Props on POLL: responses (response tag option ids). Quartz: p is an Amethyst convention, not NIP-88. | +| 1068 | `PollEvent` | *none* | – | NIP-88 poll: option, relay (URLs, not modelled), polltype, endsAt. No references. | + +### `nip89AppHandlers` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 31989 | `AppRecommendationEvent` | a[31990] -> RECOMMENDED (A) [props platform] | recommendationAddresses() (RecommendationTag::parseAddressId) | NIP-89. Draft RECOMMENDED row confirmed. d is the recommended kind number (value, not in the allowlist). | +| 31990 | `AppDefinitionEvent` | a -> SITE_MANIFEST (A); latest -> SITE_MANIFEST (A) [props release=latest]; next -> SITE_MANIFEST (A) [props release=next]; client -> CLIENT (A); k -> TAG (T); t -> HASHTAG (T) | relatedAddresses() (ATag), client() (ClientTag), supportedKinds() (KindTag), categories() (hashtags); latest/next: new parser needed | NIP-89 handler information. Platform links (web/ios/android URLs) not modelled. UNCERTAIN: whether latest vs next deserve two relations (rule 4) - props chosen. CLIENT applies to every kind (Quartz nip89AppHandlers/clientTag); it belongs in the shared default, not per class. Draft Coverage lists 31990 as unclassified. | + +### `nipA0VoiceMessages` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1222 | `VoiceEvent` | *none* | – | NIP-A0: content is an audio URL; t/g MAY be included per other NIPs but the class does not read them (would be HASHTAG/TAG if added). | +| 1244 | `VoiceReplyEvent` | E/A[root scope] -> ROOT (E,A); P -> ROOT_AUTHOR (U); e -> PARENT (E); p -> PARENT_AUTHOR (U); K/k -> TAG (T) | replyingTo()/markedReplyTos() (ReplyEventTag::parseKey, e), replyAuthorKeys() (ReplyAuthorTag::parseKey, p), directKinds() (k); root scope E/A/P: new parser needed (nip22Comments tag parsers exist) | NIP-A0: kind 1244 'MUST follow the structure of NIP-22'. Draft ROOT/PARENT/ROOT_AUTHOR/PARENT_AUTHOR rows for 1244 match the spec. Quartz bug: VoiceReplyEvent.build writes only e/k/p (parent item) and no E/K/P root scope, and the class reads no root; ReplyEventTag reads only e, so a parent given as an a tag is missed; class implements no hint providers. | + +### `nipB7Blossom` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10063 | `BlossomServersEvent` | *none* | – | NIP-B7: server URLs - not modelled. | +| 24242 | `BlossomAuthorizationEvent` | *none* | – | NIP-B7/BUD auth: t is the VERB (upload/get/delete/list), not a hashtag, and x a blob hash; server/expiration. Emitting HASHTAG for this t would pollute topics - exclude. Short-lived auth token, normally not stored. | + +### `nipXXPodcasting20` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 30054 | `Podcasting20EpisodeEvent` | edit -> EDITED (E); t -> HASHTAG (T) | editsEventId() (EditTag::parse), topics() (hashtags) | Podcasting-2.0 draft (not a NIP; podstr). edit = 'the event id of the original publication when an addressable episode/trailer is updated' - fits draft EDITED. person tags carry names/URLs, not pubkeys (not modelled). Quartz: EditTag.parse does not check 64-hex. | +| 30055 | `Podcasting20TrailerEvent` | *none* | – | Podcasting-2.0 draft trailer: title, url, pubdate, length, type, season - no references. | + +### `nip01Core` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 0 | `MetadataEvent` | i -> TAG (T); emoji[emoji-set-address] -> EMOJI_SET (A) | IdentityClaimTag::parse (i); EmojiUrlTag does not read slot 3 -> new parser needed for EMOJI_SET | NIP-01 / NIP-39 (identity claims mirrored as `i` tags, nips PR 1770 tag-names) / NIP-30. Content is JSON; Quartz does not parse nostr: URIs in `about` (no citedNIP19 on this class) so no content MENTION. Other name/picture/... tags are plain values, not modelled. | + +### `nip02FollowList` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 3 | `ContactListEvent` | p -> FOLLOW (U) | ContactTag::parseKey / parseValid (petname, relay hint) | NIP-02. Content relay map (legacy) is relay URLs: not modelled. Draft row verified. | + +### `nip03Timestamp` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1040 | `OtsEvent` | e -> TIMESTAMPED (E); k -> TAG (T) | TargetEventTag::parseId (digestEventId()); targetKind (KindTag) | NIP-03: e = target event, k = target kind. Draft row verified. | + +### `nip04Dm` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 4 | `EncryptedDmEvent` | p -> RECIPIENT (U); e -> PARENT (E) | PTag::parseKey (recipientPubKey()); MarkedETag::parseId (replyTo()) | NIP-04: p = receiver; e = 'the previous message in a conversation or a message we are explicitly replying to'. DRAFT FIX: PARENT kinds should list 4. Content encrypted; NIP-04 says clients should not rewrite nostr refs into tags. EncryptedDmEvent does not implement EventHintProvider for its e tag (only pubkeys) - minor Quartz gap. | + +### `nip09Deletions` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 5 | `DeletionRequestEvent` | e -> DELETED (E); a -> DELETED (A); p -> DELETED_AUTHOR (U); k -> TAG (T) | ETag::parseId (deleteEventIds()), ATag::parseAddressId (deleteAddressIds()), PTag::parseKey (new accessor), KindTag (kinds()) | NIP-09 defines only e/a/k (the p is Quartz practice). Draft DELETED row verified. | + +### `nip10Notes` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1 | `TextNoteEvent` | e[root] (or first unmarked) -> ROOT (E); e[reply] (or last unmarked) -> PARENT (E); e[mention] or middle unmarked e -> MENTION (E); e[fork] -> FORK (E); a[root] -> ROOT (A); a[reply] -> PARENT (A); a[fork] -> FORK (A); a to kind 34550 -> COMMUNITY (A); other a -> MENTION (A); p equal to the parent's author (e[reply] pubkey slot) -> PARENT_AUTHOR (U); other p -> MENTION (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U); t -> HASHTAG (T); r -> TAG (T); g -> TAG (T); zap -> ZAP_SPLIT (U); emoji[emoji-set-address] -> EMOJI_SET (A) | BaseThreadedEvent.markedRoot/unmarkedRoot/markedReply/unmarkedReply, MarkedETag.parseAllThreadTags/parseForkedEventId (MARKER.ROOT/REPLY/MENTION/FORK); ATag::parseAddress; PTag::parseKey; QTag::parseEventId/parseAddressId; citedNIP19(); Event.hashtags(); zapSplitSetup(); a-marker and PARENT_AUTHOR matching -> new parser needed | NIP-10 (+NIP-18 q, NIP-27 content, NIP-72 legacy community a, NIP-57 zap). DRAFT FIX: PARENT_AUTHOR (and ROOT_AUTHOR from e[root] pubkey slot) should include kind 1 - NIP-10 says the replied-to author is added to p; without it 'replies to my notes' needs a 2-hop join. DRAFT FIX: FORK targets E and A (isAFork accepts a or e with fork marker). NIP-10 no longer defines a `mention` marker; Quartz still parses MARKER.MENTION (legacy). Quartz bug: forkFromAddress() = first ATag::parseAddress regardless of fork marker (a community a-tag reads as the fork source); WikiArticleEvent uses ForkTag correctly. Known: QTag.parseAddressId rejects every address. UNCERTAIN: whether a p that is both parent author and notified thread member emits only PARENT_AUTHOR (proposed) or both. | + +### `nip22Comments` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1111 | `CommentEvent` | E -> ROOT (E); A -> ROOT (A); I -> ROOT (T); K -> TAG (T); P -> ROOT_AUTHOR (U); e -> PARENT (E); a -> PARENT (A); i -> PARENT (T); k -> TAG (T); p equal to the parent's author -> PARENT_AUTHOR (U); other p -> MENTION (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U); t -> HASHTAG (T); zap -> ZAP_SPLIT (U); emoji[emoji-set-address] -> EMOJI_SET (A) | RootEventTag/RootAddressTag/RootIdentifierTag/RootKindTag/RootAuthorTag, ReplyEventTag/ReplyAddressTag/ReplyIdentifierTag/ReplyKindTag/ReplyAuthorTag (nip22Comments/tags), QTag, citedNIP19(), hashtags(), zapSplitSetup() | NIP-22. DRAFT FIX: ROOT and PARENT need target T for the I/i external-identifier scopes (hashtag, geohash, URL comments). NIP-22 also says 'p tags SHOULD be used when mentioning pubkeys in content' so a lowercase p is PARENT_AUTHOR only when it matches the parent (e tag's pubkey slot / replyAuthor()), else MENTION; ReplyAuthorTag currently treats every p as the parent author (Quartz ambiguity). UNCERTAIN: an A root of kind 34550 is a NIP-72 community post - emit COMMUNITY (A) in addition to ROOT? Known: QTag.parseAddressId rejects every address. | + +### `nip23LongContent` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 30023 | `LongFormContentEvent` | q -> QUOTE (E,A); e -> MENTION (E); a -> MENTION (A); p -> MENTION (U); content nostr: -> MENTION (E,A,U); t -> HASHTAG (T); zap -> ZAP_SPLIT (U); emoji[emoji-set-address] -> EMOJI_SET (A) | QTag::parseEventId/parseAddressId, PTag::parseKey, citedNIP19(), topics()/hashtags(), zapSplitSetup(); e/a not read by hint providers -> new parser needed | NIP-23: 'references to other notes, articles or profiles must be made according to NIP-27 ... optionally adding tags for these' - so e/a/p are mention tags. Although it extends BaseThreadedEvent it has no reply semantics (root()/reply() must not be used for it). Known: QTag.parseAddressId rejects every address. Draft rows verified. | + +### `nip32Labeling` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1985 | `LabelEvent` | e -> LABELED (E); a -> LABELED (A); p -> LABELED (U); t -> LABELED (T); r -> LABELED (T); l -> TAG (T); L -> TAG (T) | labeledEvents() (ETag), labeledAddresses() (ATag), labeledPubKeys() (PTag), labeledHashtags() (HashtagTag), labeledRelayUrls() (r), labels()/namespaces() | NIP-32. Props labels (l values with namespace) on each LABELED. NOTE: on 1985 `t` and `r` are label TARGETS, not the event's own topics - they must NOT fall back to HASHTAG/TAG. r may be a relay URL; kept as a T target because it is what is labeled (UNCERTAIN given 'relay URLs not modelled in v1'). With no target tag, the labels apply to the label event itself (no link). Draft row verified. | + +### `nip38UserStatus` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 30315 | `UserStatusEvent` | p -> LINKED (U); e -> LINKED (E); a -> LINKED (A); r -> TAG (T); emoji[emoji-set-address] -> EMOJI_SET (A) | create() writes PTag/ETag/ATag but there are no readers (only firstTaggedUrl() for r) -> new parser needed | NIP-38. d = status type (general/music), expiration: values. DRAFT FIX: remove 30315 from the REFERENCE list. UNCERTAIN: LINKED vs reusing MENTION. | + +### `nip39ExtIdentities` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10011 | `ExternalIdentitiesEvent` | i -> TAG (T) | IdentityClaimTag::parse (claims via replaceClaims) | NIP-39: i = platform:identity with proof. Plain value tag. | + +### `nip42RelayAuth` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 22242 | `RelayAuthEvent` | *none* | relay() (RelayTag), challenge() (ChallengeTag) | NIP-42: relay URL + challenge string only: not modelled. | + +### `nip46RemoteSigner` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 24133 | `NostrConnectEvent` | p -> RECIPIENT (U) | recipientPubKey()/verifiedRecipientPubKey() (first p) | NIP-46: client p-tags remote-signer and vice versa, encrypting to it. DRAFT FIX: RECIPIENT kinds could list 24133, 23194, 23197. Ephemeral. | + +### `nip50Search` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10007 | `SearchRelayListEvent` | *none* | tags.relays() (RelayTag) | NIP-50/NIP-51 search relay list: relay URLs (public + NIP-44 private): not modelled. | + +### `nip56Reports` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1984 | `ReportEvent` | p (report names no e/a/x) -> REPORTED_USER (U); p (report also names e/a/x) -> REPORTED_AUTHOR (U); e -> REPORTED (E); a -> REPORTED (A); x -> REPORTED (T); l -> TAG (T); L -> TAG (T) | ReportedAuthorTag / ReportedEventTag / ReportedAddressTag (typed, DefaultReportTag fallback), HashSha256Tag (x); reportedAuthorsWithOwnType() | NIP-56. Props report/report_raw on all three. server tag = media server URL: not modelled. Split must be by presence of e/a/x, NOT by whether p carries its own type: Quartz's own build() writes the type on both e and p. Draft rows verified. | + +### `nip62RequestToVanish` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 62 | `RequestToVanishEvent` | *none* | – | NIP-62: only relay tags (relay URL or ALL_RELAYS) - not modelled. The vanish effect is about the AUTHOR, already the AUTHOR link. | + +### `nip65RelayList` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10002 | `AdvertisedRelayListEvent` | *none* | – | NIP-65: r tags are relay URLs (read/write markers) - not modelled; NOT the TAG r (web url) meaning. | + +### `nip68Picture` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 20 | `PictureEvent` | p -> TAGGED (U); imeta annotate-user -> TAGGED (U) [props x,y]; t -> HASHTAG (T); g -> TAG (T) | hashtags(), geohashes(); imetaTags() -> PictureMeta.annotations (UserAnnotationTag); p: new parser needed (class has no p accessor; PTag::parseKey) | NIP-68 also defines m, x (hashes), location, L/l (not read by the class; x/location not modelled). Quartz gap: no PubKeyHintProvider though p tags are spec'd. UNCERTAIN: could merge with PARTICIPANT (NIP-71 video 'participant') if the maintainer prefers one people-in-media relation. | + +### `nip69P2pOrderEvents` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 38383 | `P2POrderEvent` | *none* | – | NIP-69: k is the order type (sell/buy), not a kind - do not emit TAG k; f, s, amt, fa, pm, premium, source (URL), network, layer, name, g (spec; not read by the class), bond, y, z. No E/A/U references. | + +### `nip75ZapGoals` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9041 | `ZapGoalEvent` | e -> FUNDED (E); a -> FUNDED (A); zap -> ZAP_SPLIT (U) [props weight]; p -> MENTION (U); r -> TAG (T); t -> HASHTAG (T) | ETag/ATag::parseId (linked()), PTag::parseKey, topics() (hashtags); zap: Event.zapSplitSetup() (ZapSplitSetupParser, pubkey form only); r: new parser needed (builder writes it via reference()) | NIP-75. Draft lists zap goals (9041) as REFERENCE-only; classified here. NIP-75 defines no p or e tag (Quartz writes e as well as a for addressable targets, and reads p): UNCERTAIN p meaning, MENTION chosen. BENEFICIARY is cross-cutting: any event with NIP-57 zap tags. | + +### `nip7DThreads` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 11 | `ThreadEvent` | *none* | – | NIP-7D: only title (and nostrord's subject). Replies are NIP-22 1111s pointing at it. NIP-29 h group tag is not an E/A/U target. Class extends Event (not BaseNoteEvent) so no nostr: content parsing; if content citations are wanted later it would be MENTION via content (new parser). | + +### `nip84Highlights` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9802 | `HighlightEvent` | e -> HIGHLIGHTED (E); a -> HIGHLIGHTED (A); i -> HIGHLIGHTED (T); r[source or unmarked] -> HIGHLIGHTED (T); p[author or no role, editor] -> HIGHLIGHTED_AUTHOR (U) [props role]; p[mention] -> MENTION (U); r[mention] -> TAG (T); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U) | inPostVersion()/inPostAddress() (first e/a), inExternalIds() (ReplyIdentifierTag, i), inReference() (r, source/mention markers), author() (p author role), PTag::parseKey, QTag, citedNIP19() | NIP-84: source via a/e, i (NIP-73), r ('may contain a URL or text'); p tags 'the original authors' with optional role (author, editor); in quote highlights p/r 'mention' marker. DRAFT FIX: HIGHLIGHTED targets should include T (i/r sources), as REACTED does for kind 17. Quartz: author() only reads the author role; editor p tags fall through to linkedPubKeys (role lost); q parsing is Amethyst-side (NIP-84 does not define q). | + +### `nip94FileMetadata` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1063 | `FileMetadataEvent` | i -> TAG (T) [torrent infohash] | torrentInfoHash() (TorrentInfoHash::parse, tag i) | NIP-94: url, m, x, ox, size, dim, magnet, i, blurhash, thumb, image, summary, alt - no E/A/U references. x/ox are blob hashes (not in the TAG allowlist). DRAFT FIX: draft Coverage lists file metadata (1063) as carrying references; only the i value tag. | + +### `nip96FileStorage` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10096 | `FileServersEvent` | *none* | – | NIP-96: server URLs - not modelled. | + +### `nip98HttpAuth` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 27235 | `HTTPAuthorizationEvent` | *none* | – | NIP-98: u (URL), method, payload hash - not modelled. | + +### `nip99Classifieds` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 30402 | `ClassifiedsEvent` | e -> MENTION (E); a -> MENTION (A); p -> MENTION (U); t -> HASHTAG (T); content nostr: -> MENTION (E,A,U) | ETag/ATag/PTag::parseKey\|parseId (hint providers), categories() (hashtags); content nostr:: new parser needed (class is BaseAddressableEvent, no citedNIP19) | NIP-99: the example's e/a tags are the events the markdown content cites (NIP-27 style), so MENTION. g is in the spec but not read by the class. Draft lists classifieds (30402) as REFERENCE-only; classified here. UNCERTAIN: NIP-99 gives e/a/p no explicit role. | + +### `nipA3PaymentTargets` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10133 | `PaymentTargetsEvent` | *none* | – | NIP-A3: payto payment targets (lightning/bitcoin/etc.) - not Nostr entities. | + +### `nipA4PublicMessages` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 24 | `PublicMessageEvent` | p -> RECIPIENT (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U) | ReceiverTag::parseKey (p), citedNIP19() (eventIds/addressIds/pubKeys); q: new parser needed in this class (QTag::parseEventId/parseAddressId exist) | NIP-A4: 'p tags identify one or more receivers'; 'e tags must not be used' (Quartz strips them); q MAY cite events used in content. Draft lists 24 under both RECIPIENT and MENTION: correct only if MENTION means the content nostr: URIs - the p tags are RECIPIENT, never MENTION. Quartz gap: q tags not read by hint providers. | + +### `nipB0WebBookmarks` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 39701 | `WebBookmarkEvent` | d[url] -> BOOKMARK (T) [Tag name r]; t -> HASHTAG (T) | url() (dTagToUrl(dTag())), hashtags() | NIP-B0: 'The d tag is just their URI'. DRAFT FIX: BOOKMARK targets should include T (a web URL) for 39701. UNCERTAIN: URL values are TAG-shaped; if URLs stay out of the graph in v1, this becomes none apart from HASHTAG. Replies are NIP-22 1111s. | + +### `nipBCOnchainZaps` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 8333 | `OnchainZapEvent` | p -> ZAP_RECIPIENT (U); e -> ZAPPED (E); a -> ZAPPED (A); i -> TAG (T) [bitcoin txid]; k -> TAG (T) | recipient() (PTag), zappedEvent() (ETag), zappedAddress() (ATag), txid() (BitcoinTxIdTag, i), claimedAmountInSats() | NIP-BC is not merged upstream (404); read from Quartz KDoc. Draft ZAPPED/ZAP_RECIPIENT rows confirmed. Props msats = claimedAmountInSats*1000, sender-claimed until verified on chain. The sender is the AUTHOR (no P tag). Builder writes both a and e for addressable targets (two ZAPPED links to the same content). | + +### `nipC0CodeSnippets` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1337 | `CodeSnippetEvent` | repo[30617 address] -> REPOSITORY (A); l -> TAG (T) | TagArray.repo() (RepoTag::parse returns the raw string; needs Address.parse to tell a 30617 coordinate from a URL - not exposed on the class), language() (l) | NIP-C0: repo 'MUST be either a standard URL or ... the address of a NIP-34 Git repository announcement'. A repo URL is not modelled. REPOSITORY (draft: 1617, 1618, 1621) extends to 1337. Also name, extension, description, runtime, license, dep (no references). | + +### `nipC7Chats` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9 | `ChatEvent` | q[last, the reply] -> PARENT (E); q[slot 3 pubkey of the parent] -> PARENT_AUTHOR (U); q[other] -> QUOTE (E,A); p -> MENTION (U); content nostr: -> MENTION (E,A,U) | replyingTo() (last q), quotedEvents() (QEventTag::parse incl. author slot), QTag::parseEventId/parseAddressId, PTag::parseKey, citedNIP19() (BaseNoteEvent) | NIP-C7: 'A reply to a kind 9 ... quotes the parent using a q tag'; other kinds MAY be quoted per NIP-18. Draft PARENT row (kind 9 via q) confirmed. UNCERTAIN: by tags alone a kind 9 that only quotes (not replies) is indistinguishable from a reply; Quartz takes the LAST q as parent and returns tag[1] even when it is an address (replyingTo() does not check shape). PARENT_AUTHOR from q[3] follows rule 3; NIP-C7 puts the parent's pubkey in the q tag, so no p is needed. | diff --git a/quartz/plans/2026-09-29-graph-link-vocabulary.md b/quartz/plans/2026-09-29-graph-link-vocabulary.md new file mode 100644 index 0000000000..b902ff34e1 --- /dev/null +++ b/quartz/plans/2026-09-29-graph-link-vocabulary.md @@ -0,0 +1,379 @@ +# A link vocabulary: what each kind's references MEAN + +Status: **draft for review** (2026-09-29). Nothing is implemented yet. Decided in review: links +always start at an event (no user-to-user shortcuts); the author of acted-on content gets its OWN +relation; the kind stays on the source event only; names follow Nostr's own words (rule 7); +**no fallback** — every class states the meaning of every reference it carries (rule 5). The +per-class table for all 410 Quartz classes is the +[appendix](2026-09-29-graph-link-vocabulary-appendix.md). + +## Why + +Quartz already knows which values in an event are references: the hint providers +(`EventHintProvider`, `PubKeyHintProvider`, `AddressHintProvider`) name the linked ids, kind by +kind. They do not say what a link MEANS. They return `List`, so a consumer cannot tell a +reply's parent from its thread root, a reaction's target from a `p` it notifies, or a report +about a person from a report about their note. + +Every consumer that needs the meaning re-derives it: +- Amethyst, in its feed filters; +- neo4j-eventstore, the graph projection of the relay's store. Its `RoleTable`, `LinkRules` and + report extractors are per-kind interpretation written one repository downstream of the kinds. + +That graph named its relationships mechanically, `_` (`p_3`, `e_1111`, `z_39999`). The +names are complete without curation, but they push the per-kind knowledge onto every query +author: to find a comment's parent you must know kind 1111 puts it in `e`, and a newer kind may +put an address in `z` or `c`. The knowledge is needed either way. It belongs next to the kind, +written once, where the tags are already parsed — the pattern `SearchFieldExtractor` / +`IndexableFields` already follows for search. + +## The model + +A **link** is one statement an event makes about something else: + +```kotlin +@JvmInline value class Relation(val name: String) // an open vocabulary: constants below, extensible + +sealed interface LinkTarget { + data class Event(val id: HexKey) : LinkTarget + data class User(val pubkey: HexKey) : LinkTarget + data class Address(val value: String) : LinkTarget // kind:pubkey:d + data class Tag(val name: String, val value: String) : LinkTarget // a topic, url, label value… +} + +data class Link( + val relation: Relation, + val target: LinkTarget, + val via: String? = null, // "content" for a nostr: URI in the text, else the tag name + val props: Map? = null, // relation-specific values (a report's type, a rank) +) + +interface LinkProvider { fun links(): List } +``` + +Rules the vocabulary follows: + +1. **Every link starts at the event that makes the statement.** The event is the provenance: its + author, its time, and the version that superseded it all hang off it. The one exception is + `AUTHOR` from an address to its pubkey, which no event states. +2. **One relation per role, across kinds.** `PARENT` is a kind 1 reply's parent, a NIP-22 + comment's parent item, a git reply's and a chat reply's. The source event's `kind` says which; + a query that cares filters on it (`(c:Event {kind: 1111})-[:PARENT]->(x)`). Kinds are not + repeated in the name. +3. **The author of acted-on content gets a relation of its own.** A reaction points at the note + through `REACTED` and at the note's author through `REACTED_AUTHOR`. "Reactions to my notes" + and "reactions to anything by me" stay one hop, and each is its own constant-time count. +4. **Split a relation when queries separate its meanings on the same target type.** Counting a + relation per node is constant-time in Neo4j, but filtering on a property reads every edge. + So a distinction that is filtered all the time becomes two relations: `REPORTED_USER` (a + complaint about the person) is not `REPORTED_AUTHOR` (the author of reported content), and + `FOLLOW` (the kind 3 social graph) is not `SUBSCRIBED` (every other follow-like list). +5. **No fallback: every class says what its references mean.** Each of the 410 classes + `EventFactory` types implements `links()` (or is declared link-free), and a test holds it: a + new kind cannot land without that decision. There is no generic "reference" relation and no + rule that guesses from a value's shape. The per-class review showed why guessing is unsafe: + a 64-hex `e` in a chess start event is a board hash, the 30174 `d` is a blinded HMAC, `t` is + an auth verb in 24242 and `r` holds relay URLs in 10002. A tag that appears on every kind + (`client`, `zap`, the emoji tag's set address) is emitted once by `Event`, not per class. +6. **Values that qualify a link ride on it** (`props`): a report's type, an assertion's rank, a + zap request's amount. They are what a query filters on after choosing the relation. +7. **Names are Nostr's own words for the slot.** A relation names what the TARGET is to the + event: its `AUTHOR`, its `ROOT`, its `PARENT`, the `ZAP_RECIPIENT`. Where a NIP has a word for + the slot, that word is the name: NIP-10's markers (`root`), NIP-22's "root scope" and + "parent item" (`ROOT`, `PARENT`, `ROOT_AUTHOR`, `PARENT_AUTHOR`), NIP-57's "sender" and + "recipient", NIP-85's "subject", NIP-58's "badge definition" and "badge award", NIP-18's + "quote". Where a NIP uses a marker, the marker wins over a friendlier noun: a NIP-28 channel + message's channel is its `ROOT`, as NIP-28 tags it. Where a NIP has no word, or only a + generic one ("target"), the name is the past participle of the NIP's action: `REACTED`, + `REPOSTED`, `REPORTED`, `DELETED`, `TIMESTAMPED`. Lists name their entries the way the list + names them: a follow list holds `FOLLOW`s, a bookmark list `BOOKMARK`s. Casing is + UPPER_SNAKE, the Cypher convention, which also keeps relations apart from properties + (`r.report`). + - `PARENT`, not NIP-10's `reply` marker: `REPLY_AUTHOR` would read as the author of the + reply, and `(c)-[:REPLY]->(p)` as if `p` were the reply. + +## The vocabulary + +Targets: **E** event, **A** address, **U** user, **T** tag value. "Kinds" lists the Quartz classes +the relation comes from today; each row is a golden test when implemented. + +### Authorship and identity + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `AUTHOR` | U | The event's signer; from an address, its pubkey (the only link no event states) | every kind; every address | +| `ADDRESS` | A | The addressable event's own address (NIP-01) | 30000–39999 | + +### Conversation + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `ROOT` | E, A | The root: NIP-10 `root` (`root()`), NIP-22 root scope (`E`/`A`), and every NIP that reuses the `root` marker — a NIP-28 message's channel (41, 42), a NIP-53 chat's activity (1311) and a presence's room (10312), a NIP-34 status's or PR update's patch/issue/PR (1630–1633, 1619 `E`) | 1, 1111, 1244, 1622, 41, 42, 1311, 10312, 1619, 1630–1633 | +| `PARENT` | E, A | The direct parent: NIP-10 `replyingTo()`, NIP-22 parent item (`e`/`a`), NIP-53's parent space (30313 → 30312), a NIP-34 status's accepted revision. Kind 9 (NIP-C7) puts its parent in a **`q`** tag — the case that shows why tag letters cannot be the schema | 1, 1111, 1244, 1622, 2004, 30818, 14, 42, 1311, 9, 30313, 1630–1633 | +| `ROOT_AUTHOR` | U | The root scope's author (NIP-22 `P`) | 1111, 1244 | +| `PARENT_AUTHOR` | U | The parent item's author (NIP-22 `p`) | 1111, 1244 | +| `MENTION` | E, A, U | Named in passing: a `p` that notifies, a NIP-10 `mention` marker, a `nostr:` URI in the text (NIP-27, `via: content`) | 1, 1111, 9, 24, 42, 1311, 1621, 1622, 9802, 30023, 30817, 30818, … | +| `QUOTE` | E, A | A NIP-18 `q` (except kind 9, where `q` is the parent) | 1, 42, 1111, 1311, 1621, 30023, … | +| `FORK` | E | The event a note forks (the `fork` marker) | 1 | +| `EDITED` | E | The event this one edits | 1010 (TextNoteModification), 3302 | +| `RECIPIENT` | U | A direct or gift-wrapped message's recipients | 4, 14, 15, 24, 1059, 21059 | +| `COMMUNITY` | A | A NIP-72 community a post is submitted to (and an approval's community) | posts tagging a 34550, 4550 | +| `REPOSITORY` | A | A NIP-34 patch's, PR's or issue's repository | 1617, 1618, 1621 | + +### Reactions, reposts, zaps + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `REACTED` | E, A, T | The reacted-to content (the last `e`/`a`, `originalPost()`); kind 17 reacts to a URL / external id (T) | 7, 17 | +| `REACTED_AUTHOR` | U | Its author (`originalAuthor()`) | 7 | +| `REPOSTED` | E, A | The reposted content (`boostedEventId()` / `boostedAddress()`) | 6, 16 | +| `REPOSTED_AUTHOR` | U | Its author | 6, 16 | +| `ZAPPED` | E, A | The zapped content. Props: `msats` | 9734, 9735, 9733, 9321, 8333, 9736, 9737 | +| `ZAP_RECIPIENT` | U | Who is paid (NIP-57 `p`, the "recipient"). Props: `msats` | same | +| `ZAP_SENDER` | U | Who paid (NIP-57 `P`, the "sender": the embedded request's author) | 9735 | +| `HIGHLIGHTED` | E, A | The highlighted source | 9802 | +| `HIGHLIGHTED_AUTHOR` | U | Its author | 9802 | +| `RATED` | E, A, U | The rated entity | 34259 | + +### Moderation + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `DELETED` | E, A | NIP-09 deletion request targets | 5 | +| `REPORTED_USER` | U | A report about the PERSON: it names no event, address or blob | 1984 | +| `REPORTED` | E, A, T | Reported content (T: a blob hash) | 1984 | +| `REPORTED_AUTHOR` | U | The author of reported content | 1984 | +| `LABELED` | E, A, U, T | NIP-32 label targets. Props: `labels` (the `l` values, with namespace) | 1985 | +| `MUTE` | U, E, T | A mute list's entries: people, threads, words/hashtags | 10000, 30007 | +| `HIDDEN` | E | A NIP-28 "hide message" | 43 | +| `CHANNEL_MUTED` | U | A NIP-28 "mute user": channel moderation, not a personal mute | 44 | +| `APPROVED` | E, A | A NIP-72 approval's post | 4550 | +| `MODERATOR` | U | A community's moderators | 34550 | + +Report props (all three report relations): `report` (the category, Quartz's `ReportType` code), +`report_raw` (the type as written, lowercased). Splitting the relations replaces the `scope` +property of the current graph schema: "user-wide reports of X" is +`COUNT { (x)<-[:REPORTED_USER]-() }`, constant-time. + +### Social graph and lists + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `FOLLOW` | U | A kind 3 follow list's entries — the social graph | 3 | +| `SUBSCRIBED` | U, E, A, T | Every other "follow this" list: media follows, communities, public chats, interests (hashtags and interest sets) | 10020, 10004, 10005, 10015 | +| `MEMBER` | U, E, A | Membership in a named set or directory: follow sets, starter packs, author lists, trusted lists, calendars, publications, emoji sets | 30000, 39089, 39092, 10017, 10101, 10064, 30392–30395, 31924, 30040, 30045, 10030 | +| `RECOMMENDED` | A | A NIP-89 recommendation's app handler | 31989 | +| `BOOKMARK` | E, A | Bookmark lists' and sets' entries | 10003, 30001, 30003 | +| `CURATED` | E, A | Published curation sets' entries | 30004, 30005, 30006, 30063, 30267, 37517 | +| `PIN` | E | Pinned to a profile or a live stream | 10001, 30311 / 30313 (`pinned`) | + +### Badges (NIP-58) + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `AWARDED` | U | A badge award's recipients ("each pubkey the issuer wishes to award") | 8 | +| `BADGE_DEFINITION` | A | The badge definition an award or a profile refers to | 8, 30008, 10008 | +| `BADGE_AWARD` | E | The badge award a profile displays | 30008, 10008 | + +### Trust (NIP-85) + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `SUBJECT` | U, E, A | The assertion's subject (`d`). Props: `rank`, `followers`, … | 30382, 30383, 30384 | +| `SERVICE_PROVIDER` | U | A 10040's provider for one assertion. Props: `service` (`30382:rank`) — one link per entry | 10040 | + +### Events, calendars, live activities, markets + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `PARTICIPANT` | U | Listed participants / speakers / hosts | 30311, 30312, 30313, 31922, 31923 | +| `CALENDAR_EVENT` | A, E | A calendar RSVP's calendar event | 31925 | +| `RAIDED` | A | A live-activity raid's target | 1312 | +| `CLIPPED` | A | A clip's stream | 1313 | +| `CLIPPED_AUTHOR` | U | The clipped stream's host | 1313 | +| `POLL` | E | A poll response's poll | 1018 | +| `AUCTION` | E | A bid's (and a bid confirmation's) auction | 1021, 1022 | +| `BID` | E | The bid a confirmation confirms | 1022 | +| `TIMESTAMPED` | E | An OpenTimestamps proof's target (NIP-03 says "target", too generic to name a relation) | 1040 | +| `REDIRECT` | A | A wiki redirect's destination | 30819 | + +### Topics and plain tags + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `HASHTAG` | T | A `t` tag | any | +| `TAG` | T | Any other allowlisted value tag: `i` (external id), `k`, `l`/`L`, `r` (url), `g` (geohash). The target's name says which | any | + +### Every kind: tags any event may carry + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `CLIENT` | A | The NIP-89 `client` tag's handler address (3rd slot) | any | +| `ZAP_SPLIT` | U | A NIP-57 Appendix G `zap` tag: a split setting, not a payment (rule 4 keeps it apart from `ZAP_RECIPIENT`). Props: `weight` | any | +| `EMOJI_SET` | A | The optional 4th slot of a NIP-30 `emoji` tag: the 30030 set it comes from | any | + +### Relations the per-class review adds + +The review of all 410 classes needed **115 relations** beyond the tables above, for kinds the +tables did not reach (NIP-29 groups, NIP-90 DVMs, NIP-34 git roles, NIP-54 wiki merges, NIP-60 +cashu, NIP-71 video credits, buzz, marmot, experimental kinds…). They are listed with their +kinds and justification at the top of the [appendix](2026-09-29-graph-link-vocabulary-appendix.md) +and need the same review these tables had before they are final. + +## Reading it back + +```cypher +// a whole reply tree +MATCH (:Event {id: $root})<-[:PARENT*]-(r) RETURN r + +// reactions to my posts by people I follow +MATCH (me:User {pubkey: $me})<-[:AUTHOR]-(:Event {kind: 3})-[:FOLLOW]->(f), + (f)<-[:AUTHOR]-(r)-[:REACTED_AUTHOR]->(me) +RETURN r + +// user-wide reports against X, by category +MATCH (:User {pubkey: $x})<-[r:REPORTED_USER]-() RETURN r.report, count(*) + +// who zapped whom, from one sender +MATCH (:User {pubkey: $x})<-[:ZAP_SENDER]-(z)-[:ZAP_RECIPIENT]->(u) RETURN u, sum(z.msats) +``` + +## Open questions for review + +Decided: +- **No user-to-user shortcuts.** `FOLLOW` runs from the kind 3 event, like every other list. There + are more than twenty people lists, and a shortcut for one invites one for each. +- **The author of acted-on content has its own relation** (rule 3). +- **`kind` stays on the source event only.** A property on billions of links would cost tens of GB, + and the source node is one hop away. A relation whose counts are needed per kind is split + instead (as `FOLLOW` is). +- **Names follow Nostr's words** (rule 7), with `PARENT` for the direct parent. + +Open: + +1. **Where it lives.** `nip01Core/links/` (the interface, the value classes, the relation + constants) plus one `links()` per class, beside its tags. `Event` contributes only the + every-kind tags (`client`, `zap`, emoji sets). The hint providers could later be derived from + `links()`, which carry the same ids plus their meaning. +2. **Vocabulary stability.** Adding a relation or classifying a kind is additive. Renaming or + re-splitting one breaks graph queries, so this review is the cheap moment. + +## What changes downstream + +- **neo4j-eventstore:** the relationship type is the relation name, and the link's `props` are + its properties. Its `RoleTable`, most of `LinkRules` and the report logic move here. It keeps + the curated node values (names, reaction symbol, title), the nsec rule, and the key bounds. + Graph schema 2.0; nothing is in production yet. +- **Amethyst:** feed filters can read the same links instead of re-deriving roles (optional, + incremental). + +## Upstream fixes found on the way + +These were already catalogued in neo4j-eventstore's `docs/appendix-providers.md`: +- `ListEntityExt.pubKeys()` maps an `nsec` to its hex (a private key) as a "linked pubkey"; +- `QTag.parseAddressId` rejects every address; +- `ChannelCreateEvent.linkedEventIds()` returns its own id; +- `ZapReceiptEvent` omits the zap sender. + +Found by the per-class review (details in the appendix rows): +- **Kind collision at 20001:** `GeohashPresenceEvent` and buzz `PresenceUpdateEvent`; + `EventFactory` tells them apart by the `g` tag. (An earlier draft claimed a collision at 1010; + that was a prefix-matching mistake: `GoodWikiRelayListEvent` is 10102.) +- **Privacy:** `GeohashListEvent.create(…)` (the `NostrSignerSync` variant) swaps public and + private geohashes, publishing the private ones in clear tags. +- **Addresses:** 15 NIP-51 lists in 10000–19999 extend `PrivateTagArrayEvent`, which builds the + address from `d`, without overriding `dTag()`; a stray `d` tag splits their address. +- **Wrong target:** `ChannelHideMessageEvent.eventsToHide()` includes the channel root; on a + spec-conforming 43, `channel()` returns the hidden message. `ForkTag.parse` (30817) requires + kind 34550; the attestation `RequestTag` returns `ApprovedAddressTag`. +- **Copy-paste:** `LiveActivitiesChatMessageEvent.unmarkedReplyTos()` calls + `markedReplyTos()`; the 30298 reading state's `build()` overwrites its root and swaps summary + and image. +- **Missing NIP-22 scopes:** `VoiceReplyEvent` (1244) writes only `e`/`k`/`p`, though NIP-A0 says + it MUST follow NIP-22. +- **Missing validation:** `WinnerTag`, `AgentTag`, `ReplacedByTag`, `ConsentTag`, + `AddressMemberTag`, `EditTag`, `MarkedETag.parseAllThreadTags` and several list accessors + accept values that are not 64-hex ids or valid addresses. +- **Hint-provider gaps:** the video classes, `PictureEvent`, `VoiceReplyEvent`, the podcast lists + and every buzz class implement none, although their tags are references. + +## Coverage + +`EventFactory` types **410** classes. The [appendix](2026-09-29-graph-link-vocabulary-appendix.md) +classifies every one of them from its code and its NIP: **340** carry references, **70** carry +none. The first estimate below came from a text scan and is kept for the record; the appendix +supersedes it. +- **~150 are classified above.** That covers the NIPs the graph already interprets. +- **~110 carry no references.** Settings, metadata, relay and server lists, key packages, + ephemeral auth. They need nothing beyond `AUTHOR` (and `ADDRESS`). +- **~150 carry references and are not classified yet.** Only 12 of them implement a hint + provider; the rest reach the graph only through the shape half of rule 5. The largest groups: + - `buzz/` (~65 kinds: streams, workflows, jobs, huddles, forums, DMs, moderation); + - NIP-29 groups (9000–9010, 39000–39005); + - NIP-43 and buzz relay membership; + - NIP-47 wallet connect and NIP-46 remote signer traffic; + - WebRTC calls (25050–25055); + - NIP-71 videos (21, 22, 34235, 34236); + - file metadata (1063, 1065); + - chess (64, Jester); + - clink, cashu, contextvm, marmot; + - app data and handlers (78, 30078, 31990); + - music playlists, interactive stories, attestations, workouts, geocaching, list items + (9999 / 39999), torrents (2003). + +**Enforced, not hoped for:** a Quartz test walks every `EventFactory` kind and fails unless the +class implements `links()` or is explicitly link-free. A new kind then cannot land without a +decision about its links. + +## Corrections from the per-class review + +The review checked the tables above against the code and the NIP texts. To apply before +implementing (each is detailed in its appendix row): +- `REACTED` / `REACTED_AUTHOR` take the **last** `e` / `p` (NIP-25); earlier ones are `MENTION`. + Quartz's `originalPost()` / `originalAuthor()` return all of them, so they cannot be the source. +- A lowercase `p` on kinds 1 and 1111 is `PARENT_AUTHOR` only when it matches the parent's + author; otherwise it is `MENTION`. `ROOT` / `PARENT` also take **T** (NIP-22 `I`/`i` scopes). +- `PARENT` does not apply to 30818 (NIP-54 articles have none); `FORK` takes E and A. +- Kind 24's `p` tags are `RECIPIENT` only; `MENTION` there comes from content alone. +- Kind 1985's `t` / `r` are label targets (`LABELED`), not `HASHTAG` / `TAG`. +- A Buzz-style lone `reply` marker is a direct reply: both `ROOT` and `PARENT`. +- The unclassified list shrinks to nothing: every kind is now in the appendix. + +## Open decisions the review surfaced + +1. **The 115 new relations** (appendix, top table): same review as the tables had. Unified + already where groups coined synonyms: `ADDED_USER` / `REMOVED_USER`, `REQUEST` / + `REQUEST_AUTHOR` (NIP-90's "customer"), `ZAP_SPLIT` (NIP-75's "beneficiary"). Still to + decide: `APP` vs `APPLICATION`, `AUTHORED` (10064) beside `AUTHOR`, whether + `SERVICE_PROVIDER` spans NIP-85 and NIP-90 or splits (rule 4). +2. **Decided: a group is its `h` value** (a **T** target). Known limit, unsolved: NIP-29 ids are + only unique per relay, so two relays' groups with one id merge into one node. A group's own + metadata (39000–39005) is signed by its relay's key, which could scope it; a message carries + only `h`, so there is nothing to scope it by. Marmot's `h` is a random global id and has no + such limit. +3. **Decided: URLs and external ids are valid T targets** (kind 17 reactions, highlight + sources, web bookmarks 39701, NIP-22 `I` scopes, NIP-73 ids). +4. **Value tags need a per-class opt-in.** The same letter means different things by kind, so + `HASHTAG` / `TAG` come from each class's `links()`, never from a global allowlist. +5. **Decided: no links derived from an event's own `d`** (30618 → its repository, 39001–39005 → + the group, 30177 → its agent). They restate the event's `ADDRESS`; the graph can join on it. +6. **Decided: references inside content JSON are left out for now** (buzz 40099 / 40902 / + 44100, DVM results, 30175–30177, marketplace stalls). The appendix rows keep them, marked, + for later. +7. **Private list entries** (NIP-44 encrypted NIP-51 items, encrypted DVM requests) are invisible + to any public index. Stated once, not per row. + + +## Plan + +1. This review: the vocabulary, the model, the open questions. The per-class + [appendix](2026-09-29-graph-link-vocabulary-appendix.md) is done; the open decisions above + and the 115 new relation names remain. +2. Quartz: `nip01Core/links/`, the every-kind tags on `Event`, and the coverage test; then + `links()` class by class from the appendix, starting with the NIPs the graph already + interprets (10, 18, 22, 25, 56, 57, 85, 51, 58, 72, 09), each with a golden test. The Quartz + bugs above land with the classes they affect. +3. neo4j-eventstore: derive from `links()`, schema 2.0, rewrite `docs/schema.md` and the reference + queries. +4. The rest of the appendix, until the coverage test passes for all 410 classes. Kinds with an + unmerged or missing spec (`UNCERTAIN` rows) are decided with their maintainers. diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt index e553dbe8dd..2e0f6b455a 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt @@ -74,7 +74,7 @@ actual data class Address actual constructor( actual fun isOfKind( addressId: String, kind: String, - ) = addressId.startsWith(kind) && addressId[kind.length] == ':' + ) = addressId.length > kind.length && addressId.startsWith(kind) && addressId[kind.length] == ':' } // ----------- diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/tags/AgentTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/tags/AgentTag.kt index 6f98db051a..da8d777962 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/tags/AgentTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/tags/AgentTag.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.buzz.amTurnMetrics.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.nip01Core.core.isValid import com.vitorpamplona.quartz.utils.ensure /** @@ -38,7 +39,7 @@ object AgentTag { fun parse(tag: Tag): HexKey? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].isValid()) { return null } return tag[1] } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ConsentTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ConsentTag.kt index 5ce2886126..d70bc74f78 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ConsentTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ConsentTag.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.buzz.iaIdentityArchival.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.nip01Core.core.isValid import com.vitorpamplona.quartz.utils.ensure /** @@ -48,7 +49,8 @@ object ConsentTag { ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - ensure(tag[2].isNotEmpty()) { return null } + // The actor is a pubkey. + ensure(tag[2].isValid()) { return null } return Consent(tag[1], tag[2]) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ReplacedByTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ReplacedByTag.kt index 3ef3541d1e..a2fb2608f0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ReplacedByTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ReplacedByTag.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.buzz.iaIdentityArchival.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.nip01Core.core.isValid import com.vitorpamplona.quartz.utils.ensure /** @@ -40,7 +41,7 @@ object ReplacedByTag { fun parse(tag: Array): HexKey? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].isValid()) { return null } return tag[1] } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/moderation/tags/ReportTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/moderation/tags/ReportTag.kt index 72adcb644e..bab101f517 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/moderation/tags/ReportTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/moderation/tags/ReportTag.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.buzz.moderation.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.nip01Core.core.isValid import com.vitorpamplona.quartz.utils.ensure /** @@ -41,7 +42,7 @@ object ReportTag { fun parse(tag: Array): HexKey? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].isValid()) { return null } return tag[1] } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index 68d9275e3d..a69154843a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -707,4 +707,27 @@ object ConcordCommunityList { excludedAtEpoch = excludedAtEpoch, residue = residue, ) + + /** + * Copy of this entry holding [privateChannels] — e.g. after a Direct Invite catch-up delivered a + * Private Channel key (CORD-05 §6). Every other field, the base included, untouched. + */ + fun ConcordCommunityListEntry.withPrivateChannels(privateChannels: List) = + ConcordCommunityListEntry( + id = id, + owner = owner, + ownerSalt = ownerSalt, + root = root, + rootEpoch = rootEpoch, + controlPk = controlPk, + controlRoot = controlRoot, + heldRoots = heldRoots, + privateChannels = privateChannels, + relays = relays, + name = name, + addedAt = addedAt, + inviteRef = inviteRef, + excludedAtEpoch = excludedAtEpoch, + residue = residue, + ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt index 34771f3f23..5222e39d52 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt @@ -31,6 +31,8 @@ import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord04Roles.asFloor +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey /** A channel id paired with its current folded definition. */ @@ -57,7 +59,68 @@ data class ConcordCommunityState( val roles: Map, val authority: AuthorityResolver, val dissolved: Boolean, + /** + * Each creator's honored Invite Registry (CORD-05 §5, `vsk 8`): creator pubkey → the link-signer + * pubkeys (lowercase) of their live public links. A creator is present only while their registry + * head is honored — well-formed at their own coordinate, authored while holding `CREATE_INVITE` + * (or by the owner), citing their Grant — so a creator who loses the bit drops out. + */ + val inviteRegistries: Map> = emptyMap(), ) { + /** The aggregate active-set of live public links: every honored registry's link signers (CORD-05 §5). */ + val liveInviteLinks: Set by lazy { inviteRegistries.values.flatMapTo(HashSet()) { it } } + + /** + * The community's Public/Private mode (CORD-05 §5): Public while any live link exists in the + * aggregate registry set, Private otherwise. A Public ban is the Banlist alone; only a Private + * ban Refounds (CORD-06 §3). + */ + val isPublic: Boolean get() = liveInviteLinks.isNotEmpty() + + /** + * [isPublic] with [excludingCreators]' registries left out — the mode a ban of those members + * lands in, since a banned creator's registry stops being honored (Armada `isCommunityPublic`). + */ + fun isPublic(excludingCreators: Collection): Boolean { + if (excludingCreators.isEmpty()) return isPublic + val excluded = excludingCreators.mapTo(HashSet()) { it.lowercase() } + return inviteRegistries.any { (creator, links) -> creator !in excluded && links.isNotEmpty() } + } + + /** + * Whether any live link belongs to someone other than [viewer] (and [excludingCreators]) — + * links a rotation by [viewer] would strand, since only a link's creator can refresh its bundle + * (Armada `hasForeignLiveLinks`). + */ + fun hasForeignLiveLinks( + viewer: HexKey, + excludingCreators: Collection = emptyList(), + ): Boolean { + val excluded = excludingCreators.mapTo(HashSet()) { it.lowercase() } + viewer.lowercase() + return inviteRegistries.any { (creator, links) -> creator !in excluded && links.isNotEmpty() } + } + + /** + * Whether banning [targets] must Refound (CORD-06 §3): only a ban from a **Private** community + * does; a Public ban is the Banlist alone, because anyone holding a live link can fetch the + * rotated root straight back out of its bundle. Judged with the targets' own registries left + * out, since the ban stops honoring them. + */ + fun banRequiresRefounding(targets: Collection): Boolean = !isPublic(targets) + + /** [creator]'s honored registry (their live link signers), empty when they publish none. */ + fun registryOf(creator: HexKey): List = inviteRegistries[creator.lowercase()] ?: emptyList() + + /** + * Whether retiring [linkSigners] would flip the community Private (CORD-05 §2): it is Public + * now and no live link would remain. Retiring the last live link is a Refounding (CORD-06). + */ + fun retiringWouldPrivatize(linkSigners: Collection): Boolean { + if (!isPublic) return false + val retiring = linkSigners.mapTo(HashSet()) { it.lowercase() } + return liveInviteLinks.all { it in retiring } + } + /** * This state with [dissolved] set from the community's dissolution plane * ([ConcordDissolution.isDissolved]). One-way by the caller's contract: there is no un-dissolve. @@ -250,6 +313,16 @@ data class ConcordCommunityState( // the dissolved plane sets [dissolved] via [withDissolved]. val dissolved = false + // Invite Registries (CORD-05 §5): one entity per creator at invite_links_locator(community_id, + // creator), honored while its author holds CREATE_INVITE. The gate (AuthorityResolver.admits) + // pins the coordinate to the author and requires a JSON array, so a registry at someone + // else's coordinate or a malformed one never lands; entries are kept only when they are 64-hex. + val inviteRegistries = HashMap>() + for (head in foldGatedBy(ControlEntityKind.INVITE_REGISTRY, ConcordPermissions.CREATE_INVITE).values) { + val links = ConcordInviteRegistry.decodeOrNull(head.content) ?: continue + inviteRegistries[head.author.lowercase()] = links + } + return ConcordCommunityState( ownerPubKey = ownerPubKey.lowercase(), metadata = metadata, @@ -257,6 +330,7 @@ data class ConcordCommunityState( roles = roles, authority = authority, dissolved = dissolved, + inviteRegistries = inviteRegistries, ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearing.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearing.kt index 45ae8bd11b..95ac1bf5a3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearing.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearing.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.concord.cord03Channels +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray @@ -84,6 +86,24 @@ object ConcordDisappearing { return tags.filterNot { it.isNotEmpty() && it[0] == ExpirationTag.TAG_NAME }.toTypedArray() + ExpirationTag.assemble(expiration) } + /** + * The outer tags [rumor]'s kind-1059 wrap must carry (§2): the rumor's own expiration, repeated + * with the same value so NIP-40 relays delete the ciphertext, or nothing when the rumor carries + * none. Hand it to [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope.wrap]'s + * `outerTags`. + */ + fun wrapTagsFor(rumor: Event): TagArray = expirationOf(rumor)?.let { arrayOf(ExpirationTag.assemble(it)) } ?: emptyArray() + + /** One day, the shortest timer a client should offer (§3: it dwarfs any honest clock skew). */ + const val MIN_OFFERED_SECS = 86_400L + + /** + * The timers a client offers staff, in seconds (`0` = off): off, 1 day, 1 week, 30 days, 90 days + * and 1 year — the reference client's presets. Nothing shorter than [MIN_OFFERED_SECS]. + */ + val PRESET_SECS: List = + listOf(0L, MIN_OFFERED_SECS, 7 * MIN_OFFERED_SECS, 30 * MIN_OFFERED_SECS, 90 * MIN_OFFERED_SECS, 365 * MIN_OFFERED_SECS) + /** The rumor's own expiration, the only one a reader judges by (§3). */ fun expirationOf(rumor: Event): Long? = rumor.tags.firstNotNullOfOrNull(ExpirationTag::parse) @@ -112,6 +132,16 @@ object ConcordDisappearing { }, ) + /** + * True when a reader may display timer notice [rumor] (§4): a well-formed 1740 whose author holds + * MANAGE_METADATA in the folded [authority] (the owner always does; a banned member never). Anyone + * can spell the tag; only staff are believed about policy, so everything else is dropped. + */ + fun isBelievedNotice( + rumor: Event, + authority: AuthorityResolver, + ): Boolean = noticeTimerSecs(rumor) != null && authority.hasPermission(rumor.pubKey, ConcordPermissions.MANAGE_METADATA) + /** * The timer a notice announces, in seconds (`0` = turned off), or null when [rumor] isn't a * well-formed notice — a malformed value is dropped, never guessed at. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordTimerNoticeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordTimerNoticeEvent.kt new file mode 100644 index 0000000000..7f04d3a629 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordTimerNoticeEvent.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.concord.cord03Channels + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A Concord **timer notice** (CORD-08 §4, `kind:1740`): the inline "Alice set disappearing messages + * to 30 days" line an actor posts into each channel after changing the community's timer. Empty + * content, the channel binding, and one `["timer", ""]` tag (`0` = turned off). + * + * Informational only — the folded metadata is the authority — and believed only when its author + * holds MANAGE_METADATA in the fold; readers drop it otherwise. A notice never expires (§2). + */ +@Immutable +class ConcordTimerNoticeEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The announced timer in seconds (`0` = off), or null when the tag is missing or malformed. */ + fun timerSecs(): Long? = ConcordDisappearing.noticeTimerSecs(this) + + companion object { + const val KIND = ChannelChat.KIND_TIMER_NOTICE + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt index ab7a521f61..b2f43b77e6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.concord.cord04Roles +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull @@ -290,8 +291,11 @@ data class AuthorityResolver private constructor( ControlEntityKind.ROLE -> ConcordJson.decodeOrNull(edition.content)?.isWellFormedAt(edition.entityIdHex) == true ControlEntityKind.GRANT -> grantAt(edition, communityId) != null ControlEntityKind.BANLIST -> edition.entityIdHex == banlistEidHex && ConcordJson.decodeBanlist(edition.content) != null + // CORD-05 §5: the coordinate binds to the author, so each creator owns exactly their own + // list; the content must be a JSON array (a malformed one falls back to the previous head). ControlEntityKind.INVITE_REGISTRY -> - edition.entityIdHex == ConcordKeyDerivation.inviteLinksCoordinate(communityId, edition.author.hexToByteArray()).toHexKey() + edition.entityIdHex == ConcordKeyDerivation.inviteLinksCoordinate(communityId, edition.author.hexToByteArray()).toHexKey() && + ConcordInviteRegistry.isWellFormed(edition.content) else -> true } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinLists.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinLists.kt index 2c549b9cc5..23a9a23c3f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinLists.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinLists.kt @@ -55,10 +55,16 @@ object ConcordPinLists { if (channelIds.isEmpty()) return emptyMap() val channelByCoordinate = channelIds.associateBy { coordinate(communityIdHex, it) } val lists = editions.filter { it.entityKind == ControlEntityKind.PIN_LIST && it.entityIdHex in channelByCoordinate } - if (lists.isEmpty()) return emptyMap() + val pinFloors = floors.filterKeys { it in channelByCoordinate } + if (lists.isEmpty() && pinFloors.isEmpty()) return emptyMap() + // The same anti-rollback treatment the community fold gives every other entity: the editions + // handed in are one epoch's, so they are the compaction snapshot, and a list that cannot + // connect to its floor keeps the head we already folded rather than jumping. + val snapshot = editions.mapTo(HashSet(editions.size)) { it.rumorId } + val pool = EditionFold.admissible(lists, pinFloors, snapshot = snapshot) return EditionFold // Same gate every other entity folds under: well-formed, owner or a PIN_MESSAGES holder, vac satisfied. - .foldGated(lists, floors, rank = authority::tieBreakRank) { authority.admits(it, ConcordPermissions.PIN_MESSAGES) } + .foldGated(pool, pinFloors, snapshot = snapshot, rank = authority::tieBreakRank) { authority.admits(it, ConcordPermissions.PIN_MESSAGES) } .mapNotNull { (coordinate, head) -> channelByCoordinate[coordinate]?.let { it to head } } .toMap() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt index bbea36f329..316dc9aa9a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt @@ -86,6 +86,12 @@ object ConcordPins { val edited: Boolean, /** The proven Edit's rumor id, when one verified. */ val editRumorId: HexKey?, + /** + * The proven Edit's own send time (`created_at * 1000 + ms`), when one verified. A client + * holding an Edit newer than this MUST mark the pin edited (§7 Edits), and a refresh only + * ever attaches something newer, or it would silently revert the entry. + */ + val editOrderMs: Long?, /** The wire entry, verbatim, for republishing. */ val entry: JsonObject, ) @@ -152,6 +158,18 @@ object ConcordPins { return PinListRead(array.mapNotNull { it as? JsonObject }, sealedUnavailable = false, violating = false) } + /** + * True when [content] is the self-describing **sealed** form (`{"epoch", "sealed"}`), false for + * the public `{"entries"}` form or anything unreadable. A writer needs it to honor the + * private→public rule (§7): a list sealed in a Channel's private era is never mechanically + * re-formed into the public form, which would disclose private-era pins to everyone. + */ + fun isSealedForm(content: String): Boolean { + if (content.encodeToByteArray().size > MAX_CONTENT_BYTES) return false + val root = parse(content) as? JsonObject ?: return false + return root["entries"] == null && root["sealed"] != null + } + // ---- verification ---------------------------------------------------------------------- private class OpenedRumor( @@ -244,6 +262,7 @@ object ConcordPins { wrapHint = (entry["wrap"] as? JsonPrimitive)?.contentOrNull?.takeIf { HEX64.matches(it) }, edited = edit != null, editRumorId = edit?.id, + editOrderMs = edit?.orderMs(), entry = entry, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt index ac0ce7bd16..d3c74c06ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt @@ -24,18 +24,51 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag +import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils /** - * Direct invites (CORD-05): for a known npub, the invite skips the public bundle - * and is delivered as a standard NIP-59 giftwrap — a kind-3313 rumor carrying the - * [CommunityInvite], sealed (kind 13) to the recipient and wrapped (kind 1059) - * with `["p", recipient]` and a `["k", "3313"]` index tag so the recipient can - * query for pending invites without decrypting every giftwrap. + * A Direct Invite opened by its recipient (CORD-05 §6): the bundle plus the seal-verified [sender]. + * + * [invite] is already bounded and owner-proof validated ([ConcordInviteBundle.validate]); expiry is + * NOT enforced here — a parked invite still renders, only joining refuses ([isExpired]). + * [sentAt] is the rumor's `created_at` (unix seconds), the sender's word: fine for ordering, never + * for authority. + */ +class OpenedDirectInvite( + val wrapId: HexKey, + val sender: HexKey, + val invite: CommunityInvite, + val sentAt: Long, +) { + /** True when the bundle's `expires_at` (unix ms) has passed: the preview renders, joining refuses. */ + fun isExpired(nowMs: Long = TimeUtils.nowMillis()): Boolean = ConcordInviteBundle.isExpired(invite, nowMs) +} + +/** + * Direct invites (CORD-05 §6): for a known npub, the invite skips the public bundle + * and is delivered as a *standard* NIP-59 giftwrap — a kind-3313 rumor carrying the + * [CommunityInvite], sealed (kind 13, signed by the inviter's real key) to the recipient and + * wrapped (kind 1059, ephemeral single-use author) with `["p", recipient]` and a `["k", "3313"]` + * index tag so the recipient can query for pending invites without decrypting every giftwrap. + * Not the reversed stream wrap of CORD-01. + * + * Wire details pinned to Armada's `directInvite.ts`: + * - seal and wrap `created_at` are each tweaked into the past by up to [MAX_BACKDATE_SECS] + * (NIP-59), so the wrap leaks only "roughly when"; the rumor keeps the real send time; + * - when the bundle has an `expires_at` (unix ms) the wrap carries the matching NIP-40 + * `["expiration", expires_at / 1000]`, so relays can prune a handoff that can no longer be used; + * - opening requires the rumor's claimed author to equal the seal's author (NIP-59 anti-spoofing), + * and the seal's signature to verify — the seal is what proves who invited. * * It cannot be revoked — the recipient holds the keys the moment it lands. */ @@ -44,46 +77,125 @@ object ConcordDirectInvite { const val TAG_P = "p" const val TAG_K = "k" + /** NIP-59: outer (seal + wrap) timestamps are tweaked into the past by up to two days. */ + const val MAX_BACKDATE_SECS: Long = 2 * 24 * 60 * 60L + private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) + /** [now] minus a uniformly random `0 until` [MAX_BACKDATE_SECS] seconds (NIP-59's timestamp tweak). */ + fun tweakedPast(now: Long = TimeUtils.now()): Long = now - RandomInstance.int(MAX_BACKDATE_SECS.toInt()) + /** * Builds a giftwrapped direct invite from [senderSigner] to [recipientPubKey]. - * Returns the kind-1059 wrap to publish to the recipient's inbox relays. + * Returns the kind-1059 wrap to publish to the recipient's inbox relays (their kind-10050 DM + * relays, else their NIP-65 read relays). [createdAt] is the rumor's real send time; the seal and + * the wrap are each backdated from it independently ([tweakedPast]). */ suspend fun build( senderSigner: NostrSigner, recipientPubKey: HexKey, invite: CommunityInvite, - createdAt: Long, + createdAt: Long = TimeUtils.now(), ): GiftWrapEvent { val rumor = RumorAssembler.assembleRumor(senderSigner.pubKey, createdAt, KIND, emptyArray(), json(invite)) - val seal = SealEvent.create(rumor, recipientPubKey, senderSigner, createdAt = createdAt) + val seal = SealEvent.create(rumor, recipientPubKey, senderSigner, createdAt = tweakedPast(createdAt)) - // Wrap with a random ephemeral key, adding the ["k","3313"] index tag. + // Wrap with a random single-use key, adding the ["k","3313"] index tag and, when the bundle + // expires, the NIP-40 expiration matching it. val wrapSigner = NostrSignerInternal(KeyPair()) val content = wrapSigner.nip44Encrypt(seal.toJson(), recipientPubKey) + val tags = + listOfNotNull( + arrayOf(TAG_P, recipientPubKey), + arrayOf(TAG_K, KIND.toString()), + invite.expiresAt?.let { arrayOf(ExpirationTag.TAG_NAME, (it / 1000).toString()) }, + ).toTypedArray() return wrapSigner.sign( - createdAt = createdAt, + createdAt = tweakedPast(createdAt), kind = GiftWrapEvent.KIND, - tags = arrayOf(arrayOf(TAG_P, recipientPubKey), arrayOf(TAG_K, KIND.toString())), + tags = tags, content = content, ) } + /** + * True when [wrap]'s NIP-40 `expiration` (unix seconds) is at or before [nowSecs]: an expired + * handoff is never decrypted or surfaced. + */ + fun isWrapExpired( + wrap: Event, + nowSecs: Long = TimeUtils.now(), + ): Boolean = wrap.tags.isExpirationBefore(nowSecs) + + /** + * The `since` to query invite wraps from, given the newest wrap `created_at` already seen: + * rewound by [MAX_BACKDATE_SECS] because wraps are backdated (a wrap published after the last + * sweep can carry an older timestamp). Null on a cold inbox — fetch everything. + */ + fun inboxSince(newestWrapCreatedAt: Long?): Long? = newestWrapCreatedAt?.takeIf { it > MAX_BACKDATE_SECS }?.let { it - MAX_BACKDATE_SECS } + + /** + * Opens a direct-invite giftwrap addressed to [recipientSigner]. Null — never a throw — unless + * every layer checks out: a kind-1059 wrap that decrypts to a kind-13 seal with a valid + * signature, whose rumor claims the seal's author (anti-spoofing), is kind 3313 (the rumor kind + * is the authority, not the outer `k` hint), and carries a [CommunityInvite] that passes the §1 + * bounds and the owner proof ([ConcordInviteBundle.validate]). + */ + suspend fun open( + wrap: Event, + recipientSigner: NostrSigner, + ): OpenedDirectInvite? { + if (wrap.kind != GiftWrapEvent.KIND) return null + val seal = + try { + Event.fromJson(recipientSigner.nip44Decrypt(wrap.content, wrap.pubKey)) + } catch (_: Exception) { + return null + } + return openSeal(wrap.id, seal, recipientSigner) + } + + /** + * [open] from the kind-13 [seal] down, for a pipeline that already peeled the wrap [wrapId] + * (e.g. the general NIP-17 giftwrap inbox, which honours an untagged invite all the same). + */ + suspend fun openSeal( + wrapId: HexKey, + seal: Event, + recipientSigner: NostrSigner, + ): OpenedDirectInvite? { + if (seal !is SealEvent) return null + return try { + if (!seal.verify()) return null + val rumor = Rumor.fromJson(recipientSigner.nip44Decrypt(seal.content, seal.pubKey)) + // NIP-59 anti-spoofing: the rumor's claimed author must be the seal's signer. The generic + // unseal path overwrites the rumor's pubkey with the seal's, which hides a mismatch; here + // a mismatch is a forgery and the whole invite is refused. + val claimed = rumor.pubKey ?: return null + if (!claimed.equals(seal.pubKey, ignoreCase = true)) return null + if (rumor.kind != KIND) return null + // Bounded like a fetched bundle (CORD-05 §6: "the §1 bounds apply"), and validated + // exactly as one: the community_id must self-certify the owner. + val content = rumor.content ?: return null + val invite = + ConcordJson + .decodeOrNull(content) + ?.let { ConcordInviteBundle.bound(it) } + ?.takeIf { ConcordInviteBundle.validate(it) } + ?: return null + OpenedDirectInvite(wrapId, seal.pubKey.lowercase(), invite, rumor.createdAt ?: seal.createdAt) + } catch (_: Exception) { + null + } + } + /** * Opens a direct-invite giftwrap addressed to [recipientSigner] and returns the - * [CommunityInvite], or null if it isn't a valid direct invite for this user. - * Callers should still [ConcordInviteBundle.validate] the result. + * [CommunityInvite], or null if it isn't a valid direct invite for this user. See [open], which + * also returns the verified sender. */ suspend fun parse( wrap: GiftWrapEvent, recipientSigner: NostrSigner, - ): CommunityInvite? { - val seal = wrap.unwrapOrNull(recipientSigner) ?: return null - if (seal !is SealEvent) return null - val rumor = seal.unsealOrNull(recipientSigner) ?: return null - if (rumor.kind != KIND) return null - // Bounded like a fetched bundle (CORD-05 §6: "the §1 bounds apply"). - return ConcordJson.decodeOrNull(rumor.content)?.let { ConcordInviteBundle.bound(it) } - } + ): CommunityInvite? = open(wrap, recipientSigner)?.invite } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistry.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistry.kt new file mode 100644 index 0000000000..9d4cd3612c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistry.kt @@ -0,0 +1,174 @@ +/* + * 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.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive + +/** + * The Invite Registry (CORD-05 §5, `vsk 8`): a creator's member-facing list of their live link + * coordinates, published as a Control Plane edition at `invite_links_locator(community_id, creator)` + * (CORD-02 A.6), so each creator owns exactly their own list and nobody can forge entries into + * anyone else's. + * + * Its content is a bare JSON array of **link-signer pubkeys** (the authors of the kind-33301 + * bundles, whose `d` is empty, §2) — locators only, never tokens, URLs or signing secrets: + * ```jsonc + * ["", ""] + * ``` + * + * Members fold every creator's registry (honored only while its author holds `CREATE_INVITE`) + * into one aggregate active-set, and that set is the community's **Public/Private source of + * truth**: non-empty means Public, empty means Private (see + * [com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState.isPublic]). + */ +object ConcordInviteRegistry { + private val LINK_SIGNER = Regex("^[0-9a-fA-F]{64}$") + + /** Strict JSON: the reference client reads the content with `JSON.parse`, which refuses what a lenient parser would accept. */ + private val strict = Json + + /** The registry coordinate (entity id) of [creator] in [communityId]. */ + fun coordinate( + communityId: ByteArray, + creator: HexKey, + ): ByteArray = ConcordKeyDerivation.inviteLinksCoordinate(communityId, creator.hexToByteArray()) + + /** [coordinate] as hex. */ + fun coordinateHex( + communityId: ByteArray, + creator: HexKey, + ): HexKey = coordinate(communityId, creator).toHexKey() + + /** Whether [value] is a link-signer entry a reader keeps: a 64-hex x-only pubkey. */ + fun isLinkSigner(value: String): Boolean = LINK_SIGNER.matches(value) + + /** + * The registry content for [linkSigners]: lowercase, de-duplicated and sorted, so two devices of + * one creator holding the same set write the same bytes. Anything that is not a link-signer + * pubkey is dropped rather than published, since every reader would drop it anyway. + */ + fun encode(linkSigners: Collection): String { + val clean = + linkSigners + .filter(::isLinkSigner) + .map { it.lowercase() } + .distinct() + .sorted() + return strict.encodeToString(JsonArray.serializer(), JsonArray(clean.map { JsonPrimitive(it) })) + } + + /** + * Whether [content] is a well-formed registry: a JSON array, whatever it holds (Armada's + * `Array.isArray(JSON.parse(content))`). A malformed edition is not honored, so the entity falls + * back to the creator's previous authorized edition. + */ + fun isWellFormed(content: String): Boolean = parseArrayOrNull(content) != null + + /** + * The link signers [content] lists, lowercase and de-duplicated, keeping only string entries + * that are 64-hex pubkeys; null when [content] is not a JSON array at all. + */ + fun decodeOrNull(content: String): List? { + val array = parseArrayOrNull(content) ?: return null + return array + .mapNotNull { element -> (element as? JsonPrimitive)?.takeIf { it.isString }?.content } + .filter(::isLinkSigner) + .map { it.lowercase() } + .distinct() + } + + private fun parseArrayOrNull(content: String): JsonArray? = + try { + strict.parseToJsonElement(content) as? JsonArray + } catch (_: Exception) { + null + } + + /** + * The link signers [creator]'s next registry edition lists (CORD-05 §5, "a Registry edit + * accompanies every mint and every retire"): the registry they currently publish ([published], + * their honored head), plus every link their Invite List [list] still holds for [communityIdHex], + * plus [minted]; minus [retired], and minus every link the list records as tombstoned or past its + * `expires_at` at [nowSecs] — an elapsed link can no longer be joined, so it must stop keeping the + * community Public. A null [list] (unreadable) contributes nothing and prunes nothing. + * + * The Invite List half heals a registry that fell behind: a link minted before any registry was + * published (or by a device whose registry edit never landed) is re-listed on the next edit. + */ + fun nextLinks( + published: Collection, + list: ConcordInviteListDocument?, + communityIdHex: HexKey, + nowSecs: Long, + minted: Collection = emptyList(), + retired: Collection = emptyList(), + ): List { + val dead = retired.mapTo(HashSet()) { it.lowercase() } + val live = LinkedHashSet() + published.forEach { live += it.lowercase() } + if (list != null) { + val tombstoned = list.tombstones.mapTo(HashSet()) { it.token } + for (entry in list.entries) { + if (!entry.communityId.equals(communityIdHex, ignoreCase = true)) continue + val signer = runCatching { entry.signerPubKeyHex().lowercase() }.getOrNull() ?: continue + if (entry.token in tombstoned || entry.isExpired(nowSecs)) dead += signer else live += signer + } + } + minted.forEach { live += it.lowercase() } + return live.filter { it !in dead && isLinkSigner(it) }.sorted() + } + + /** + * An unsigned registry edition rumor for [creator] listing [linkSigners] (CORD-05 §5). Chain it + * onto the creator's current authorized head ([version] = head + 1, [prevHash] = its hash; a + * first registry is version 1 with no prev) and cite the Grant the creator acts under + * ([authorityCitation], null for the owner) like any authority edition (CORD-04 §5, `vac`). + */ + fun rumor( + creator: HexKey, + communityId: ByteArray, + linkSigners: Collection, + version: Long, + prevHash: ByteArray?, + createdAt: Long, + authorityCitation: AuthorityCitation? = null, + ): Event = + ControlEditionBuilder.rumor( + authorPubKey = creator, + entityKind = ControlEntityKind.INVITE_REGISTRY, + entityId = coordinate(communityId, creator), + version = version, + prevHash = prevHash, + content = encode(linkSigners), + createdAt = createdAt, + authorityCitation = authorityCitation, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt new file mode 100644 index 0000000000..c0cb4df4d9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt @@ -0,0 +1,141 @@ +/* + * 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.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withPrivateChannels +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Which Private Channel keys an invite bundle may carry (CORD-05 §1, CORD-03 §1, CORD-04 §2), and + * what a bundle for an already-joined community may contribute. Pinned to Armada's + * `channelAccess.ts` (`isEntitled`, `vendableChannels`) and `directInvite.ts` (`catchUpChannelIds`). + * + * The Roles scoped to a channel (`scope: {kind:"channel", channel_id}`) ARE its access list. Read + * access is enforced by key possession alone; this decides who a key is delivered TO. + */ +object ConcordInviteVend { + private const val SCOPE_CHANNEL = "channel" + + /** The live Role ids conferring read access to [channelIdHex] (Roles scoped to that channel). */ + fun channelRoleIds( + authority: AuthorityResolver, + channelIdHex: HexKey, + ): Set = + authority + .roles() + .filter { (_, role) -> !role.deleted && role.scope?.kind == SCOPE_CHANNEL && role.scope.channelId.equals(channelIdHex, ignoreCase = true) } + .keys + + /** + * Is [memberHex] entitled to Private Channel [channelIdHex]'s key? The owner always is + * (CORD-04 §2); anyone else must hold a Role scoped to that channel. + */ + fun isEntitled( + authority: AuthorityResolver, + memberHex: HexKey, + channelIdHex: HexKey, + ): Boolean { + if (authority.isOwner(memberHex)) return true + val held = authority.rolesOf(memberHex) + if (held.isEmpty()) return false + return channelRoleIds(authority, channelIdHex).any { it in held } + } + + /** + * The held Private Channel keys a bundle may carry for its audience (CORD-05 §1): + * - a **link** ([memberHex] null) has no recipient and holds no Role, so it gets none; + * - a **member** (a Direct Invite's recipient) gets exactly the channels their Roles entitle + * them to ([isEntitled]) — that CORD-05 §6 can't *prevent* an unentitled whisper doesn't make + * one right. + * + * Keyless listings are never vended. + */ + fun vendableChannels( + held: List, + authority: AuthorityResolver, + memberHex: HexKey?, + ): List { + if (memberHex == null) return emptyList() + return held.filter { it.key.isNotBlank() && isEntitled(authority, memberHex, it.channelId) } + } + + /** The [held] keys as bundle channel grants (lowercase hex, as Armada writes them). */ + fun toInviteChannels(held: List): List = held.map { InviteChannel(it.channelId.lowercase(), it.key.lowercase(), it.epoch, it.name) } + + /** + * The Private Channel ids (lowercase hex) a [bundle] for an already-joined community would NEWLY + * contribute to [held] — empty when it is not a catch-up. Armada `catchUpChannelIds`. + * + * A catch-up may never move the base: nothing binds `community_root` to `community_id` + * (CORD-02 §1/§2), so a hostile bundle carrying a real id/owner/salt could otherwise relocate + * the member onto attacker-read streams. So it counts only on the SAME `community_root`, + * `root_epoch` and `control_pk` (swapping `control_pk` alone would eclipse the member onto an + * attacker's Control Plane); the base advances only by a CORD-06 rekey. + */ + fun catchUpChannelIds( + held: ConcordCommunityListEntry?, + bundle: CommunityInvite, + ): List { + if (held == null) return emptyList() + if (!bundle.communityId.equals(held.id, ignoreCase = true)) return emptyList() + if (!bundle.communityRoot.equals(held.root, ignoreCase = true)) return emptyList() + if (bundle.rootEpoch != held.rootEpoch) return emptyList() + if (!sameOptionalHex(bundle.controlPk, held.controlPk)) return emptyList() + val heldEpochs = held.privateChannels.filter { it.key.isNotBlank() }.associate { it.channelId.lowercase() to it.epoch } + return bundle.channels + .filter { HEX64.matches(it.id) && HEX64.matches(it.key) } + .filter { c -> + val heldEpoch = heldEpochs[c.id.lowercase()] + heldEpoch == null || c.epoch > heldEpoch + }.map { it.id.lowercase() } + .distinct() + } + + /** + * [held] with the Private Channel keys [bundle] newly contributes ([catchUpChannelIds]) merged + * in — a newer epoch replaces the held one — or null when the bundle contributes nothing. The + * base, epoch, control keys and every other field stay exactly as held. + */ + fun adoptCatchUp( + held: ConcordCommunityListEntry, + bundle: CommunityInvite, + ): ConcordCommunityListEntry? { + val newIds = catchUpChannelIds(held, bundle).toSet() + if (newIds.isEmpty()) return null + val delivered = + bundle.channels + .filter { it.id.lowercase() in newIds && HEX64.matches(it.key) } + .groupBy { it.id.lowercase() } + .map { (id, grants) -> grants.maxBy { it.epoch }.let { PrivateChannelKey(id, it.key.lowercase(), it.epoch, it.name) } } + val kept = held.privateChannels.filterNot { it.channelId.lowercase() in newIds } + return held.withPrivateChannels(kept + delivered) + } + + private val HEX64 = Regex("^[0-9a-fA-F]{64}$") + + private fun sameOptionalHex( + a: String?, + b: String?, + ): Boolean = a?.lowercase() == b?.lowercase() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt index a3e9ee9172..4b47df18ad 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt @@ -97,13 +97,18 @@ object ConcordStreamEnvelope { * address, signed by the stream key and encrypted under its conversation key. * Adds a fresh ephemeral `["p", …]` tag. Use [KIND_WRAP_EPHEMERAL] via * [ephemeral] for transient traffic (typing, voice presence). + * + * [outerTags] are appended after the `p` tag. The only sanctioned one is the CORD-08 §2 + * `["expiration", …]` that a disappearing Chat rumor's wrap repeats for NIP-40 relays + * ([com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing.wrapTagsFor]). */ fun wrapSeal( seal: Event, stream: GroupKey, ephemeral: Boolean = false, createdAt: Long = TimeUtils.now(), - ): Event = wrapSeal(seal, stream, stream.conversationKey, ephemeral, createdAt) + outerTags: Array> = EMPTY_TAGS, + ): Event = wrapSeal(seal, stream, stream.conversationKey, ephemeral, createdAt, outerTags) /** * Write-restricted variant (CORD-01, Write-Restricted Streams): the wrap is @@ -118,12 +123,13 @@ object ConcordStreamEnvelope { readConversationKey: ByteArray, ephemeral: Boolean = false, createdAt: Long = TimeUtils.now(), + outerTags: Array> = EMPTY_TAGS, ): Event { val streamSigner = NostrSignerSync(KeyPair(privKey = signerKey.secretKey)) val content = encryptChecked(seal.toJson(), readConversationKey) val ephemeralP = KeyPair().pubKey.toHexKey() val kind = if (ephemeral) KIND_WRAP_EPHEMERAL else KIND_WRAP - return streamSigner.signNormal(createdAt, kind, arrayOf(arrayOf("p", ephemeralP)), content) + return streamSigner.signNormal(createdAt, kind, arrayOf(arrayOf("p", ephemeralP)) + outerTags, content) } /** @@ -142,7 +148,7 @@ object ConcordStreamEnvelope { return wrapSeal(seal, signer, keys.readKey.conversationKey, ephemeral, createdAt) } - /** Convenience: [seal] then [wrapSeal] in one call. */ + /** Convenience: [seal] then [wrapSeal] in one call. [outerTags] ride the wrap after its `p` tag. */ suspend fun wrap( rumor: Event, stream: GroupKey, @@ -150,7 +156,8 @@ object ConcordStreamEnvelope { encrypted: Boolean, ephemeral: Boolean = false, createdAt: Long = TimeUtils.now(), - ): Event = wrapSeal(seal(rumor, stream, authorSigner, encrypted), stream, ephemeral, createdAt) + outerTags: Array> = EMPTY_TAGS, + ): Event = wrapSeal(seal(rumor, stream, authorSigner, encrypted), stream, ephemeral, createdAt, outerTags) /** * Convenience for the Control Plane: seals under [keys]' read key (an encrypted diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt index 50aaf0ef06..fe9ad1148e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.experimental.attestations.attestation import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.RequestTag import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent @@ -65,9 +66,10 @@ class AttestationEvent( override fun linkedEventIds(): List = tags.mapNotNull(ETag::parseId) - override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + // The attested assertion is an `a`; the request it answers (kind 31872) is a `request` tag. + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(RequestTag::parseAsHint) - override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + tags.mapNotNull(RequestTag::parseAddressId) fun status() = tags.status() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt index d22000d12a..839bb4e404 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt @@ -20,14 +20,14 @@ */ package com.vitorpamplona.quartz.experimental.attestations.attestation.tags +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.CoordinateShape import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip72ModCommunities.approval.tags.ApprovedAddressTag -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -43,8 +43,15 @@ class RequestTag( companion object { const val TAG_NAME = "request" + private val REQUEST_KIND_STR = AttestationRequestEvent.KIND.toString() - fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && !Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR) + // The raw-string readers hand the value on as an address id (hints, gatherers), so they + // need the whole `31872:<64-hex pubkey>:` shape, not just the kind prefix: checked + // without allocating, as the parsers that build an Address get it from Address.parse. + private fun isRequestCoordinate(value: String) = Address.isOfKind(value, REQUEST_KIND_STR) && CoordinateShape.matches(value) + + // The request an attestation answers is always a kind 31872 attestation request. + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], REQUEST_KIND_STR) fun isTagged( tag: Array, @@ -53,7 +60,7 @@ class RequestTag( fun isTagged( tag: Array, - address: ApprovedAddressTag, + address: RequestTag, ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag() fun isIn( @@ -61,20 +68,20 @@ class RequestTag( addressIds: Set, ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds - fun parse(tag: Array): ApprovedAddressTag? { + fun parse(tag: Array): RequestTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + ensure(Address.isOfKind(tag[1], REQUEST_KIND_STR)) { return null } val address = Address.parse(tag[1]) ?: return null val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } - return ApprovedAddressTag(address, relayHint) + return RequestTag(address, relayHint) } fun parseValidAddress(tag: Array): String? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + ensure(Address.isOfKind(tag[1], REQUEST_KIND_STR)) { return null } return Address.parse(tag[1])?.toValue() } @@ -83,22 +90,21 @@ class RequestTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } val address = Address.parse(tag[1]) ?: return null - ensure(address.kind != CommunityDefinitionEvent.KIND) { return null } + ensure(address.kind == AttestationRequestEvent.KIND) { return null } return address } fun parseAddressId(tag: Array): String? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + ensure(isRequestCoordinate(tag[1])) { return null } return tag[1] } fun parseAsHint(tag: Array): AddressHint? { ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } - ensure(tag[1].contains(':')) { return null } + ensure(isRequestCoordinate(tag[1])) { return null } ensure(tag[2].isNotEmpty()) { return null } val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt index 70eae12f2b..b374e16143 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt @@ -86,8 +86,13 @@ class AttestationRequestEvent( fun assertionETag() = tags.firstNotNullOfOrNull(ETag::parse) + /** The attestors this request asks (its `p` tags, written by [buildEvent]'s `attestorPubKeys`). */ + fun attestorPubKeys() = tags.mapNotNull(PTag::parseKey) + + @Deprecated("Returns the first ATTESTOR, not the assertion's author", ReplaceWith("attestorPubKeys().firstOrNull()")) fun assertionPubkey() = tags.firstNotNullOfOrNull(PTag::parseKey) + @Deprecated("Returns the first ATTESTOR's tag, not the assertion's author") fun assertionPTag() = tags.firstNotNullOfOrNull(PTag::parse) companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/citations/ExternalCitationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/citations/ExternalCitationEvent.kt index 890bb9afd6..159a6ff00a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/citations/ExternalCitationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/citations/ExternalCitationEvent.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.experimental.citations.tags.CitationTags 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.core.isValid import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag @@ -50,7 +51,7 @@ class ExternalCitationEvent( fun url() = value(CitationTags.URL) ?: value("url") /** The id of a NIP-03 kind-1040 timestamp attesting when the page was seen. */ - fun openTimestamp() = value(CitationTags.OPEN_TIMESTAMP) + fun openTimestamp() = value(CitationTags.OPEN_TIMESTAMP)?.takeIf { it.isValid() } override fun displayTitle(): String? = title() ?: url() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decoupling/setup/EncryptionKeyListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decoupling/setup/EncryptionKeyListEvent.kt index 2a5d6f6a73..dec4aec02e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decoupling/setup/EncryptionKeyListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decoupling/setup/EncryptionKeyListEvent.kt @@ -40,6 +40,10 @@ class EncryptionKeyListEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun keys() = tags.mapNotNull(KeyTag::parse) + // Kind 10044 is replaceable: NIP-01 fixes its address to `kind:pubkey:`, so a stray `d` + // tag must not split one user's key list into several addresses. + override fun dTag(): String = "" + companion object { const val KIND = 10044 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddress.kt new file mode 100644 index 0000000000..435d7a11af --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddress.kt @@ -0,0 +1,36 @@ +/* + * 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.experimental.forks + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag + +/** + * The address an `a` tag marked `fork` points at: `["a",
, , "fork"]`, the + * version a note, a NIP text or a wiki article (NIP-54 "Forks") was forked from — of any kind. + * Only the marked tag counts: an event also carries unmarked `a` tags (a community, a mention), + * and those are not its origin. + */ +fun parseForkedAddress(tag: Array): Address? { + if (tag.size < 4 || tag[0] != "a") return null + if (tag[3] != MarkedETag.MARKER.FORK.code) return null + return Address.parse(tag[1]) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt index 32a71a23c3..826a37bfce 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt @@ -28,6 +28,7 @@ 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.core.builder +import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -56,7 +57,14 @@ class InteractiveStoryReadingStateEvent( fun status() = tags.firstNotNullOfOrNull(StatusTag::parse) - fun root() = tags.firstNotNullOfOrNull(RootSceneTag::parse) + /** + * The story this state tracks. Reading states written before `rootScene` emitted the `A` + * tag carry no root tag at all (the lowercase `a` it wrote was replaced by the current + * scene's), but `build` always set the d-tag to the root's address, so that is the fallback. + */ + fun root() = + tags.firstNotNullOfOrNull(RootSceneTag::parse) + ?: Address.parse(dTag())?.let { RootSceneTag(it.kind, it.pubKeyHex, it.dTag, null) } fun currentScene() = tags.firstNotNullOfOrNull(ATag::parseAddress) @@ -97,6 +105,10 @@ class InteractiveStoryReadingStateEvent( val updatedTags = base.tags.builder { + // Heal a state written without its root tag (see [root]). + if (base.tags.none { it.has(1) && it[0] == RootSceneTag.TAG_NAME } && Address.parse(rootTag) != null) { + add(RootSceneTag.assemble(rootTag, null)) + } currentScene(sceneTag) status(status) } @@ -128,8 +140,8 @@ class InteractiveStoryReadingStateEvent( status(status) root.event.title()?.let { storyTitle(it) } - root.event.summary()?.let { storyImage(it) } - root.event.image()?.let { storySummary(it) } + root.event.summary()?.let { storySummary(it) } + root.event.image()?.let { storyImage(it) } initializer() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/TagArrayBuilderExt.kt index b916f3283a..3da412a1fc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/TagArrayBuilderExt.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.experimental.interactiveStories import com.vitorpamplona.quartz.experimental.interactiveStories.tags.ReadStatusTag +import com.vitorpamplona.quartz.experimental.interactiveStories.tags.RootSceneTag import com.vitorpamplona.quartz.experimental.interactiveStories.tags.StoryOptionTag import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag @@ -51,7 +52,9 @@ fun TagArrayBuilder.storyImage(imageUrl: Stri fun TagArrayBuilder.storyImages(imageUrls: List) = addAll(imageUrls.map { ImageTag.assemble(it) }) -fun TagArrayBuilder.rootScene(scene: ATag) = addUnique(scene.toATagArray()) +// The root is the uppercase `A` (RootSceneTag): written as a lowercase `a`, the current scene +// (also an `a`) replaced it and root() found nothing. +fun TagArrayBuilder.rootScene(scene: ATag) = addUnique(RootSceneTag.assemble(scene.toTag(), scene.relay)) fun TagArrayBuilder.currentScene(scene: ATag) = addUnique(scene.toATagArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt index b8c53abcf1..0fb12f35a2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.quartz.experimental.nipsOnNostr import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.forks.IForkableEvent +import com.vitorpamplona.quartz.experimental.forks.parseForkedAddress import com.vitorpamplona.quartz.experimental.forks.parseForkedEventId -import com.vitorpamplona.quartz.experimental.nipsOnNostr.tags.ForkTag import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -113,7 +113,7 @@ class NipTextEvent( override fun isAFork() = tags.any { it.size > 3 && (it[0] == "a" || it[0] == "e") && it[3] == "fork" } - override fun forkFromAddress() = tags.firstNotNullOfOrNull(ForkTag::parseAddress) + override fun forkFromAddress() = tags.firstNotNullOfOrNull(::parseForkedAddress) override fun forkFromVersion() = tags.firstNotNullOfOrNull(MarkedETag::parseForkedEventId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/ForkTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/ForkTag.kt deleted file mode 100644 index 699078cdea..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/ForkTag.kt +++ /dev/null @@ -1,145 +0,0 @@ -/* - * 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.experimental.nipsOnNostr.tags - -import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent -import com.vitorpamplona.quartz.utils.arrayOfNotNull -import com.vitorpamplona.quartz.utils.ensure - -class ForkTag( - val address: Address, - val relayHint: NormalizedRelayUrl? = null, -) { - fun toTag() = Address.assemble(address.kind, address.pubKeyHex, address.dTag) - - fun toTagArray() = assemble(address, relayHint) - - fun toTagIdOnly() = assemble(address, null) - - companion object { - const val TAG_NAME = "a" - - fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], NipTextEvent.KIND_STR) - - fun isTagged( - tag: Array, - addressId: String, - ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == addressId - - fun isTagged( - tag: Array, - address: ForkTag, - ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag() - - fun isIn( - tag: Array, - addressIds: Set, - ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds - - fun parse(tag: Array): ForkTag? { - ensure(tag.has(1)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure( - Address.Companion.isOfKind( - tag[1], - CommunityDefinitionEvent.Companion.KIND_STR, - ), - ) { return null } - - val address = Address.Companion.parse(tag[1]) ?: return null - val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.Companion.normalizeOrNull(it) } - return ForkTag(address, relayHint) - } - - fun parseValidAddress(tag: Array): String? { - ensure(tag.has(1)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure( - Address.Companion.isOfKind( - tag[1], - CommunityDefinitionEvent.Companion.KIND_STR, - ), - ) { return null } - return Address.Companion.parse(tag[1])?.toValue() - } - - fun parseAddress(tag: Array): Address? { - ensure(tag.has(1)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } - val address = Address.parse(tag[1]) ?: return null - ensure(address.kind == NipTextEvent.KIND) { return null } - return address - } - - fun parseAddressId(tag: Array): String? { - ensure(tag.has(1)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure( - Address.isOfKind( - tag[1], - NipTextEvent.KIND_STR, - ), - ) { return null } - return tag[1] - } - - fun parseAsHint(tag: Array): AddressHint? { - ensure(tag.has(2)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure( - Address.isOfKind( - tag[1], - NipTextEvent.KIND_STR, - ), - ) { return null } - ensure(tag[2].isNotEmpty()) { return null } - - val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) - ensure(relayHint != null) { return null } - - return AddressHint(tag[1], relayHint) - } - - fun assemble( - aTagId: String, - relay: NormalizedRelayUrl?, - ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url, "fork") - - fun assemble( - address: Address, - relay: NormalizedRelayUrl?, - ) = assemble(address.toValue(), relay) - - fun assemble( - kind: Int, - pubKey: String, - dTag: String, - relay: NormalizedRelayUrl?, - ) = assemble(Address.assemble(kind, pubKey, dTag), relay) - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt index 8257f672cb..f138a54b66 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.experimental.trustedLists.addressables.tags import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.decentralizedLists.CoordinateShape import com.vitorpamplona.quartz.experimental.trustedLists.tags.MemberTagFields import com.vitorpamplona.quartz.experimental.trustedLists.tags.TrustedListMemberTag import com.vitorpamplona.quartz.nip01Core.core.Address @@ -58,7 +59,11 @@ data class AddressMemberTag( companion object { const val TAG_NAME = "a" - fun isTag(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + // A member is a `kind:pubkey:d` coordinate. CoordinateShape checks that without allocating + // (a 30394 can list thousands of members) and, unlike AddressSerializer.parse, neither + // decodes an naddr (whose raw bech32 would then be used as the member key) nor logs a + // warning per rejected value. + fun isTag(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && CoordinateShape.matches(tag[1]) fun isTagged( tag: Tag, @@ -68,7 +73,7 @@ data class AddressMemberTag( fun parse(tag: Tag): AddressMemberTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } + ensure(CoordinateShape.matches(tag[1])) { return null } return AddressMemberTag(tag[1], MemberTagFields.relayHint(tag), MemberTagFields.score(tag)) } @@ -76,23 +81,21 @@ data class AddressMemberTag( fun parseAddressId(tag: Tag): String? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } + ensure(CoordinateShape.matches(tag[1])) { return null } return tag[1] } fun parseAddress(tag: Tag): Address? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } + ensure(CoordinateShape.matches(tag[1])) { return null } return AddressSerializer.parse(tag[1]) } fun parseAsHint(tag: Tag): AddressHint? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } - // only index a value that is actually a coordinate, as ATag does - ensure(tag[1].contains(':')) { return null } + ensure(CoordinateShape.matches(tag[1])) { return null } val hint = MemberTagFields.relayHint(tag) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 588e9782b7..b92be92f12 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -74,6 +74,7 @@ import com.vitorpamplona.quartz.experimental.trustedLists.addressables.Addressab import com.vitorpamplona.quartz.experimental.trustedLists.events.EventTrustedListEvent import com.vitorpamplona.quartz.experimental.trustedLists.externalIds.ExternalIdTrustedListEvent import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent +import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.feedDefinition.FeedDefinitionEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent @@ -243,6 +244,8 @@ import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent +import com.vitorpamplona.quartz.nip71Video.views.VideoViewEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent @@ -335,6 +338,9 @@ import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEven import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.deregistration.PushDeregistrationEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.preferences.PushPreferencesEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.registration.PushRegistrationEvent /** * Human-readable label and defining NIP for a Nostr event kind. @@ -506,6 +512,12 @@ object KindNames { AddressableShortVideoEvent.KIND to KindName("Shorts (Repl)", "71"), VideoNormalEvent.KIND to KindName("Video", "71"), VideoShortEvent.KIND to KindName("Shorts", "71"), + VideoCollaborationEvent.KIND to KindName("Video Collaboration", null), + TextTrackEvent.KIND to KindName("Video Subtitles", null), + VideoViewEvent.KIND to KindName("Video Views", null), + PushRegistrationEvent.KIND to KindName("Push Registration", null), + PushDeregistrationEvent.KIND to KindName("Push Deregistration", null), + PushPreferencesEvent.KIND to KindName("Push Preferences", null), VoiceEvent.KIND to KindName("Voice Msg", "A0"), VoiceReplyEvent.KIND to KindName("Voice Reply", "A0"), WakeUpEvent.KIND to KindName("WakeUp", null), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt index 80ca91929b..58fa7265aa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt @@ -75,6 +75,6 @@ class AddressSerializer { fun isOfKind( addressId: String, kind: String, - ) = addressId.startsWith(kind) && addressId[kind.length] == ':' + ) = addressId.length > kind.length && addressId.startsWith(kind) && addressId[kind.length] == ':' } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/dTag/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/dTag/EventExt.kt index a2758ff2f0..836c9265a0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/dTag/EventExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/dTag/EventExt.kt @@ -20,6 +20,12 @@ */ package com.vitorpamplona.quartz.nip01Core.tags.dTag +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event -fun Event.dTag() = tags.dTag() +/** + * The d-tag that places this event in its address. An [AddressableEvent] decides it (a + * replaceable kind's is always "", whatever `d` tags it carries), so a caller holding a plain + * [Event] gets the same answer as one holding the concrete class; anything else reads the tag. + */ +fun Event.dTag(): String = (this as? AddressableEvent)?.dTag() ?: tags.dTag() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt index 93099829a4..3933122ee1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt @@ -36,7 +36,7 @@ class DeletionIndex { val compared = reference.compareTo(other.reference) return if (compared == 0) { - publicKey.compareTo(publicKey) + publicKey.compareTo(other.publicKey) } else { compared } @@ -128,6 +128,16 @@ class DeletionIndex { pubKey: HexKey, ): Boolean = hasBeenDeleted(DeletionRequest(address.toValue(), pubKey)) + /** + * Checks if a kind-5 event signed by [pubKey] deleted the event [eventId], for callers that hold + * only the id and its proven author — a Concord pin entry names a message the reader may never + * have loaded (CORD-04 §7: a held delete hides the entry by identity). + */ + fun hasBeenDeleted( + eventId: HexKey, + pubKey: HexKey, + ): Boolean = hasBeenDeleted(DeletionRequest(eventId, pubKey)) + private fun hasBeenDeleted(key: DeletionRequest) = deletedReferencesBefore.containsKey(key) private fun hasBeenDeleted( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt index e9049476af..ba525a7239 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip10Notes import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.forks.IForkableEvent +import com.vitorpamplona.quartz.experimental.forks.parseForkedAddress import com.vitorpamplona.quartz.experimental.forks.parseForkedEventId import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -124,7 +125,7 @@ class TextNoteEvent( override fun isAFork() = tags.any { it.size > 3 && (it[0] == "a" || it[0] == "e") && it[3] == "fork" } - override fun forkFromAddress() = tags.firstNotNullOfOrNull(ATag::parseAddress) + override fun forkFromAddress() = tags.firstNotNullOfOrNull(::parseForkedAddress) override fun forkFromVersion() = tags.firstNotNullOfOrNull(MarkedETag::parseForkedEventId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt index b0f69fe20e..590067ec1a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt @@ -90,7 +90,7 @@ interface QTag { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length != 64) { return null } - ensure(!tag[1].contains(':')) { return null } + ensure(tag[1].contains(':')) { return null } return tag[1] } @@ -99,7 +99,7 @@ interface QTag { ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length != 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - ensure(!tag[1].contains(':')) { return null } + ensure(tag[1].contains(':')) { return null } val relayHint = pickRelayHint(tag) ensure(relayHint != null) { return null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/ListEntityExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/ListEntityExt.kt index 77229402b1..af697d5130 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/ListEntityExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/ListEntityExt.kt @@ -32,7 +32,6 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip19Bech32.entities.NSec fun NEvent.toEventHint() = relay.map { EventIdHint(hex, it) } @@ -86,12 +85,16 @@ fun List.pubKeyHints(): List = } }.flatten() +/** + * The pubkeys cited as `npub` / `nprofile`. An `nsec` is deliberately NOT here: its hex is a + * PRIVATE key, so reporting a pasted one as a "linked pubkey" would publish the secret to every + * index, hint store and relay filter that consumes this list. + */ fun List.pubKeys(): List = mapNotNull { entity -> when (entity) { is NProfile -> entity.hex is NPub -> entity.hex - is NSec -> entity.hex else -> null } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt index 3c261b869f..6e91f565a1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt @@ -47,7 +47,20 @@ class ChannelHideMessageEvent( override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) - fun eventsToHide() = tags.taggedEventIds() + /** + * NIP-28 names the channel only through a MARKED root (Quartz writes one; the spec's own 43 + * has none): the unmarked-root fallback of [BasePublicChatEvent] would read the first hidden + * message as the channel. + */ + override fun channel() = markedRoot() + + override fun channelId() = channel()?.eventId + + /** The hidden messages: every `e` except the channel it is posted in. */ + fun eventsToHide(): List { + val channel = channelId() + return tags.taggedEventIds().filter { it != channel } + } companion object { const val KIND = 43 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupParticipantsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupParticipantsEvent.kt index dbb6d36337..8dc6adb964 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupParticipantsEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupParticipantsEvent.kt @@ -24,6 +24,7 @@ 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.core.isValid import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag @@ -48,7 +49,7 @@ class GroupParticipantsEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun groupId() = dTag() - fun participants(): List = tags.mapValueTagged(TAG_NAME) { it } + fun participants(): List = tags.mapValueTagged(TAG_NAME) { it.takeIf { value -> value.isValid() } } companion object { const val KIND = 39004 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt index 4a1e9f26d0..80fa2c9c24 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.fastForEach import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.AddressPin @@ -56,7 +57,7 @@ fun TagArray.childGroupIds(): List = mapNotNull(ChildTag::parse) fun TagArray.userPubKeys(): List = mapNotNull(PTag::parseKey) -fun TagArray.deletedEventIds(): List = mapValueTagged("e") { it } +fun TagArray.deletedEventIds(): List = mapValueTagged("e") { it.takeIf { value -> value.isValid() } } /** The ordered pin list: `e` (event id) and `a` (address) references, interleaved as sent. */ fun TagArray.groupPins(): List = mapNotNull(GroupPin::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt index a47a2d4a74..18178e1d2b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt @@ -24,6 +24,7 @@ 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.TagArray +import com.vitorpamplona.quartz.nip01Core.core.isReplaceable import com.vitorpamplona.quartz.nip01Core.diff.ContentChange import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @@ -42,6 +43,13 @@ abstract class PrivateTagArrayEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { override fun isContentEncoded() = true + /** + * A NIP-51 LIST (10000–19999) is replaceable: NIP-01 fixes its address to `kind:pubkey:` + * whatever tags it carries. Read from the tags, a stray `d` would split one user's list + * into several addresses. SETS (30000–39999) are addressed by their `d`. + */ + override fun dTag(): String = if (kind.isReplaceable()) "" else super.dTag() + /** * How the NIP-44 encrypted private items changed since [older]. They can't be compared * item by item without decrypting, so only as a whole. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt index 936a691d84..a9d0b6f037 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt @@ -198,8 +198,8 @@ class GeohashListEvent( signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), ): GeohashListEvent { - val privateTagArray = publicGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray() - val publicTagArray = privateGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray() + val publicTagArray = publicGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray() + val privateTagArray = privateGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray() return signer.signNip51List(createdAt, KIND, publicTagArray, privateTagArray) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt index bdad09bcf8..9d342e714f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt @@ -121,16 +121,10 @@ class LiveActivitiesChatMessageEvent( return pHints + nip19Hints } - private fun activityHex() = tags.firstNotNullOfOrNull(ATag::parseAddressId) - fun activity() = tags.firstNotNullOfOrNull(ATag::parse) fun activityAddress() = tags.firstNotNullOfOrNull(ATag::parseAddress) - override fun markedReplyTos() = super.markedReplyTos().minus(activityHex() ?: "") - - override fun unmarkedReplyTos() = super.markedReplyTos().minus(activityHex() ?: "") - override fun exposeInDraft() = tagArray { activity()?.let { aTag(it) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/MeetingSpaceTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/MeetingSpaceTag.kt index 138de76efe..a6f105fa20 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/MeetingSpaceTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/MeetingSpaceTag.kt @@ -37,10 +37,18 @@ class MeetingSpaceTag( fun toTagArray() = assemble(address, relayHint) + /** + * The form a kind 10312 presence uses: NIP-53 marks the room it points at as its `root` + * (`["a", , , "root"]`). A meeting room (30313) references its space WITHOUT a + * marker, so this is not [toTagArray]. + */ + fun toRootTagArray() = arrayOf(TAG_NAME, address.toValue(), relayHint?.url ?: "", ROOT_MARKER) + fun toTagIdOnly() = assemble(address, null) companion object Companion { const val TAG_NAME = "a" + const val ROOT_MARKER = "root" fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt index 37ae7110ea..2d861e2334 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.nip53LiveActivities.presence import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.HandRaisedTag @@ -30,9 +29,10 @@ import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.MutedTag import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.OnstageTag import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.PublishingTag -fun TagArrayBuilder.roomMeeting(rep: MeetingSpaceTag) = addUnique(rep.toTagArray()) +// NIP-53: a presence points at its room with the `root` marker. +fun TagArrayBuilder.roomMeeting(rep: MeetingSpaceTag) = addUnique(rep.toRootTagArray()) -fun TagArrayBuilder.roomMeeting(rep: EventHintBundle) = addUnique(rep.toATag().toATagArray()) +fun TagArrayBuilder.roomMeeting(rep: EventHintBundle) = addUnique(MeetingSpaceTag(rep.event.address(), rep.relay).toRootTagArray()) fun TagArrayBuilder.handRaised(raised: Boolean) = addUnique(HandRaisedTag.assemble(raised)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiArticleEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiArticleEvent.kt index ce75d16efb..61f65f61c5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiArticleEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiArticleEvent.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.quartz.nip54Wiki import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.forks.IForkableEvent +import com.vitorpamplona.quartz.experimental.forks.parseForkedAddress import com.vitorpamplona.quartz.experimental.forks.parseForkedEventId -import com.vitorpamplona.quartz.experimental.nipsOnNostr.tags.ForkTag import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -150,7 +150,7 @@ class WikiArticleEvent( override fun isAFork() = tags.any { it.size > 3 && (it[0] == "a" || it[0] == "e") && it[3] == "fork" } - override fun forkFromAddress() = tags.firstNotNullOfOrNull(ForkTag::parseAddress) + override fun forkFromAddress() = tags.firstNotNullOfOrNull(::parseForkedAddress) override fun forkFromVersion() = tags.firstNotNullOfOrNull(MarkedETag::parseForkedEventId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/profile/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/profile/TagArrayExt.kt index 478737e306..d5b4bb5cd4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/profile/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/profile/TagArrayExt.kt @@ -24,9 +24,14 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents import com.vitorpamplona.quartz.nip58Badges.accepted.tags.AcceptedBadge +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent fun TagArray.acceptedBadges() = AcceptedBadge.parseAll(this) fun TagArray.badgeAwardEvents() = taggedEvents() -fun TagArray.badgeAwardDefinitions() = taggedAddresses() +/** + * The badge DEFINITIONS (kind 30009) a profile displays. NIP-58 profiles also carry `a` tags + * pointing at badge SETS (kind 30008), which are not definitions. + */ +fun TagArray.badgeAwardDefinitions() = taggedAddresses().filter { it.kind == BadgeDefinitionEvent.KIND } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt index c260a465b0..cb3d672ab7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt @@ -22,17 +22,19 @@ package com.vitorpamplona.quartz.nip64Chess.end.tags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.utils.ensure class WinnerTag { companion object { const val TAG_NAME = "winner" - fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isValid() fun parse(tag: Array): HexKey? { ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } + // The winner is a pubkey. + ensure(tag[1].isValid()) { return null } return tag[1] } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..41e5fc7e77 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/TagArrayBuilderExt.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.nip71Video.views + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag +import com.vitorpamplona.quartz.nip01Core.tags.events.toETagArray +import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent +import com.vitorpamplona.quartz.nip71Video.views.tags.LoopsTag +import com.vitorpamplona.quartz.nip71Video.views.tags.PhaseTag +import com.vitorpamplona.quartz.nip71Video.views.tags.SourceTag +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewPhase +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewSource +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewedRange +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewedTag + +/** Both pointers: the address for the video, the id for the exact version that was watched. */ +fun TagArrayBuilder.video(video: EventHintBundle) = addUnique(video.toATag().toATagArray()).addUnique(video.toETagArray()) + +fun TagArrayBuilder.phase(phase: ViewPhase) = addUnique(PhaseTag.assemble(phase)) + +fun TagArrayBuilder.viewed(range: ViewedRange) = addUnique(ViewedTag.assemble(range)) + +fun TagArrayBuilder.loops(loops: Double) = addUnique(LoopsTag.assemble(loops)) + +fun TagArrayBuilder.source(source: ViewSource) = addUnique(SourceTag.assemble(source)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/TagArrayExt.kt new file mode 100644 index 0000000000..7186f9bcdd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/TagArrayExt.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.nip71Video.views + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip71Video.views.tags.LoopsTag +import com.vitorpamplona.quartz.nip71Video.views.tags.PhaseTag +import com.vitorpamplona.quartz.nip71Video.views.tags.SourceTag +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewedTag + +fun TagArray.video() = firstNotNullOfOrNull(ATag::parseAddress) + +fun TagArray.videoVersion() = firstNotNullOfOrNull(ETag::parseId) + +fun TagArray.phase() = firstNotNullOfOrNull(PhaseTag::parse) + +fun TagArray.viewed() = firstNotNullOfOrNull(ViewedTag::parse) + +fun TagArray.loops() = firstNotNullOfOrNull(LoopsTag::parse) + +fun TagArray.source() = firstNotNullOfOrNull(SourceTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEvent.kt new file mode 100644 index 0000000000..a275f8a34c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEvent.kt @@ -0,0 +1,133 @@ +/* + * 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.nip71Video.views + +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.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewPhase +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewSource +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewedRange +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * "Someone watched this video": an ephemeral analytics event divine.video publishes for its + * NIP-71 videos (kind 22236, not part of NIP-71). Relays pass it on without storing it, so only a + * service listening live, such as Divine's relay, turns it into view and loop counts. + * + * A viewing session is reported in two phases: one [ViewPhase.START] when playback begins, which + * counts the view, then one [ViewPhase.END] per interruption carrying the watch time since the + * previous `end`. An event with no `phase` is the older single-shot report. The content is empty. + * + * Schema: divine-mobile `mobile/docs/NOSTR_VIDEO_EVENTS.md`. + */ +@Immutable +class VideoViewEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider, + EventHintProvider { + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + /** The video that was watched. */ + fun video() = tags.video() + + /** The id of the exact version that was watched. */ + fun videoVersion() = tags.videoVersion() + + fun phase() = tags.phase() + + fun viewed() = tags.viewed() + + fun loops() = tags.loops() + + fun source() = tags.source() + + companion object { + const val KIND = 22236 + + /** Playback started. Carries no watch time: nothing has been watched yet. */ + fun buildStart( + video: EventHintBundle, + source: ViewSource? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(video, ViewPhase.START, null, null, source, createdAt, initializer) + + /** + * A segment ended after [watchedSeconds] of playback, [loops] of them complete or partial. + * A [loops] that is not a positive finite number is left out, as divine-mobile does; a negative + * [watchedSeconds] throws, since it could only be a bug in the caller's clock. + */ + fun buildEnd( + video: EventHintBundle, + watchedSeconds: Long, + loops: Double? = null, + source: ViewSource? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val playthroughs = loops?.takeIf { it.isFinite() && it > 0.0 } + return build(video, ViewPhase.END, ViewedRange(0, watchedSeconds), playthroughs, source, createdAt, initializer) + } + + /** + * Prefer [buildStart] / [buildEnd]: they keep watch time off `start` events, where it + * would count engagement the viewer never gave. + */ + fun build( + video: EventHintBundle, + phase: ViewPhase?, + viewed: ViewedRange?, + loops: Double?, + source: ViewSource?, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + video(video) + phase?.let { phase(it) } + viewed?.let { viewed(it) } + loops?.let { loops(it) } + source?.let { source(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/LoopsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/LoopsTag.kt new file mode 100644 index 0000000000..802e8f1eb1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/LoopsTag.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.nip71Video.views.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * How many times the video played through, partial passes included (`0.75`). Informational: + * Divine's relay derives loops from the `viewed` seconds and does not read this tag. + */ +class LoopsTag { + companion object { + const val TAG_NAME = "loops" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Double? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + val loops = tag[1].toDoubleOrNull() ?: return null + ensure(loops.isFinite() && loops >= 0.0) { return null } + return loops + } + + fun assemble(loops: Double): Array { + require(loops.isFinite() && loops >= 0.0) { "Invalid loop count: $loops" } + return arrayOf(TAG_NAME, loops.toString()) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/PhaseTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/PhaseTag.kt new file mode 100644 index 0000000000..2d78efbaaf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/PhaseTag.kt @@ -0,0 +1,56 @@ +/* + * 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.nip71Video.views.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +enum class ViewPhase( + val code: String, +) { + /** Playback started. This is what counts the view, so an app killed mid-session still counts it. */ + START("start"), + + /** A segment of the session ended. Carries the watch time and loops since the previous `end`. */ + END("end"), +} + +class PhaseTag { + companion object { + const val TAG_NAME = "phase" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): ViewPhase? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return when (tag[1]) { + ViewPhase.START.code -> ViewPhase.START + ViewPhase.END.code -> ViewPhase.END + else -> null + } + } + + fun assemble(phase: ViewPhase) = arrayOf(TAG_NAME, phase.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/SourceTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/SourceTag.kt new file mode 100644 index 0000000000..03f5dc9a66 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/SourceTag.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.nip71Video.views.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +/** + * Where the viewer found the video. + * + * [type] is written as-is: divine-mobile names the discovery tab inside it (`discovery:foryou`, + * `discovery:new`, `discovery:featured`, …), so [category] strips the tab when only the surface + * matters. [detail] is what the surface was showing: the hashtag, the search query, the featured + * tab's id. + */ +@Immutable +data class ViewSource( + val type: String, + val detail: String? = null, +) { + /** [type] without the tab: `discovery` for every `discovery:`. */ + val category get() = type.substringBefore(':') + + companion object { + const val HOME = "home" + const val DISCOVERY = "discovery" + const val PROFILE = "profile" + const val SHARE = "share" + const val SEARCH = "search" + const val UNKNOWN = "unknown" + } +} + +class SourceTag { + companion object { + const val TAG_NAME = "source" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): ViewSource? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return ViewSource(tag[1], tag.getOrNull(2)?.ifEmpty { null }) + } + + fun assemble(source: ViewSource) = arrayOfNotNull(TAG_NAME, source.type, source.detail) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/ViewedTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/ViewedTag.kt new file mode 100644 index 0000000000..3a4a0e18b3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/ViewedTag.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.nip71Video.views.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Elapsed playback, in whole seconds, not positions in the video: a 6-second video looped twice + * is `0..12`. divine-mobile always writes a start of 0. + */ +@Immutable +data class ViewedRange( + val start: Long, + val end: Long, +) { + init { + // The same rule ViewedTag.parse applies: an inverted range would read as negative watch + // time, so it is refused on the way out rather than signed and then ignored on the way in. + require(start in 0..end) { "Invalid viewed range: $start..$end" } + } + + val seconds get() = end - start +} + +class ViewedTag { + companion object { + const val TAG_NAME = "viewed" + + fun isTag(tag: Array) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty() && tag[2].isNotEmpty() + + fun parse(tag: Array): ViewedRange? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + val start = tag[1].toLongOrNull() ?: return null + val end = tag[2].toLongOrNull() ?: return null + // An inverted range would read as negative watch time; the publisher drops those too. + ensure(start in 0..end) { return null } + return ViewedRange(start, end) + } + + fun assemble(range: ViewedRange) = arrayOf(TAG_NAME, range.start.toString(), range.end.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/CashuMintEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/CashuMintEvent.kt index d3db502775..eb67b6f962 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/CashuMintEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/CashuMintEvent.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip87Ecash.cashu import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event +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 @@ -36,15 +36,13 @@ class CashuMintEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun mintUrl() = tags.mintUrl() fun nuts() = tags.nuts() fun network() = tags.network() - fun dTag() = tags.dTag() - companion object { const val KIND = 38172 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/FedimintEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/FedimintEvent.kt index ceea8374c1..b46a30f248 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/FedimintEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/FedimintEvent.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip87Ecash.fedimint import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event +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 @@ -36,15 +36,13 @@ class FedimintEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun inviteCodes() = tags.inviteCodes() fun modules() = tags.modules() fun network() = tags.network() - fun dTag() = tags.dTag() - companion object { const val KIND = 38173 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt index 88bb9697d4..467e9a2fd6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip87Ecash.recommendation import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event +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 @@ -39,7 +39,7 @@ class MintRecommendationEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig), +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), SearchableEvent { override fun indexableContent() = content @@ -53,8 +53,6 @@ class MintRecommendationEvent( fun mintEventKind() = tags.mintEventKind() - fun dTag() = tags.dTag() - fun mintEventAddresses() = tags.mintEventAddresses() fun isCashuRecommendation() = mintEventKind() == CashuMintEvent.KIND diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt index f7860df5c9..095b12d9ef 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt @@ -23,6 +23,9 @@ package com.vitorpamplona.quartz.nipA0VoiceMessages import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip22Comments.tags.RootAuthorTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootEventTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootKindTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyAuthorTag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyEventTag @@ -47,6 +50,22 @@ fun TagArrayBuilder.audioIMeta(audioUrls: List.rootEvent( + eventId: String, + relayHint: NormalizedRelayUrl?, + pubkey: String?, +) = addUnique(RootEventTag.assemble(eventId, relayHint, pubkey)) + +fun TagArrayBuilder.rootKind(kind: String) = addUnique(RootKindTag.assemble(kind)) + +fun TagArrayBuilder.rootKind(kind: Int) = addUnique(RootKindTag.assemble(kind)) + +fun TagArrayBuilder.rootAuthor( + pubKey: HexKey, + relay: NormalizedRelayUrl?, +) = addUnique(RootAuthorTag.assemble(pubKey, relay)) + fun TagArrayBuilder.replyEvent( eventId: String, relayHint: NormalizedRelayUrl?, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt index 102522ae66..e1026e45ce 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt @@ -24,6 +24,11 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip22Comments.tags.RootAddressTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootAuthorTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootEventTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootIdentifierTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootKindTag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyAuthorTag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyEventTag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyKindTag @@ -55,6 +60,35 @@ class VoiceReplyEvent( fun replyingTo(): HexKey? = tags.lastNotNullOfOrNull(ReplyEventTag::parseKey) + /** The thread's root scope (NIP-22 `E`): the voice message the conversation started from. */ + fun rootEventId(): HexKey? = tags.firstNotNullOfOrNull(RootEventTag::parseKey) + + /** The root scope's author (NIP-22 `P`). */ + fun rootAuthorKey(): HexKey? = tags.firstNotNullOfOrNull(RootAuthorTag::parseKey) + + /** + * The root-scope tags a reply to this event inherits, or null when this event names no root. + * + * A NIP-22 reply carries them verbatim: `E`, `A` or `I` (a thread may be rooted at an + * address or an external id, not only at an event) plus `K` and `P`. A reply published + * before voice replies followed NIP-22 has only the lowercase parent tags; when that parent + * is a voice message (`k` 1222) the parent IS the root, so its `e` / `p` are rewritten as + * `E` / `P` (identical layouts). An older reply to a reply has lost its root: null. + */ + fun rootScopeTags(): List>? { + val scope = tags.filter { RootEventTag.match(it) || RootAddressTag.match(it) || RootIdentifierTag.match(it) || RootKindTag.match(it) || RootAuthorTag.match(it) } + if (scope.any { RootEventTag.match(it) || RootAddressTag.match(it) || RootIdentifierTag.match(it) }) return scope + + if (tags.none { ReplyKindTag.match(it) && it[1] == VoiceEvent.KIND.toString() }) return null + val parentTag = tags.lastOrNull { ReplyEventTag.parseKey(it) != null } ?: return null + val authorTag = tags.lastOrNull { ReplyAuthorTag.parseKey(it) != null } + return listOfNotNull( + parentTag.copyOf().also { it[0] = RootEventTag.TAG_NAME }, + RootKindTag.assemble(VoiceEvent.KIND), + authorTag?.copyOf()?.also { it[0] = RootAuthorTag.TAG_NAME }, + ) + } + companion object { const val KIND = 1244 @@ -73,6 +107,17 @@ class VoiceReplyEvent( createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = build(voiceMessage, KIND, createdAt) { + // NIP-A0: a voice reply MUST follow NIP-22, so it names the thread's root scope as + // well as its parent. Replying to the voice message itself makes it the root. + val parent = replyingTo.event + val inherited = if (parent is VoiceReplyEvent) parent.rootScopeTags() else null + if (inherited != null) { + inherited.forEach { addUnique(it) } + } else { + rootEvent(parent.id, replyingTo.relay, parent.pubKey) + rootKind(parent.kind) + rootAuthor(parent.pubKey, replyingTo.authorHomeRelay) + } replyEvent(replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey) replyKind(replyingTo.event.kind) replyAuthor(replyingTo.event.pubKey, replyingTo.authorHomeRelay) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt index 2deea22cc9..4369d95390 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.utils.ensure /** @@ -36,7 +37,7 @@ class EditTag { fun parse(tag: Array): HexKey? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].isValid()) { return null } return tag[1] } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushServiceEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushServiceEvent.kt new file mode 100644 index 0000000000..a3f04b0201 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushServiceEvent.kt @@ -0,0 +1,89 @@ +/* + * 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.nipXXPushNotifications + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import kotlinx.serialization.json.Json + +/** + * A control message from a user to a push-notification service, from the draft "NIP-XX Push + * Notifications" that divine.video runs (divine-push-service, `docs/nip-xx-push-notifications.md`). + * + * Every kind in the draft has the same envelope: a `p` tag naming the service, an `app` tag naming + * the application, and content that is NIP-44 ciphertext to the service's key. The service rejects + * plaintext, and ignores events addressed to another service or older than its seven-day replay + * window. Only the author and the service can read the payload; anyone else sees who talks to + * which service, not what they said. + */ +@Immutable +abstract class PushServiceEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + override fun isContentEncoded() = true + + fun pushService() = tags.pushService() + + fun app() = tags.app() + + fun canDecrypt(signer: NostrSigner) = counterpartyOf(signer.pubKey) != null + + /** + * The other end of the NIP-44 conversation for [reader]: the author reads its own event back + * through the service's key, the service reads it through the author's. Null for anyone else. + */ + private fun counterpartyOf(reader: HexKey): HexKey? { + val service = pushService() ?: return null + return when (reader) { + pubKey -> service + service -> pubKey + else -> null + } + } + + /** Throws [SignerExceptions.UnauthorizedDecryptionException] when [signer] is neither end. */ + protected suspend fun decryptContent(signer: NostrSigner): String { + val counterparty = counterpartyOf(signer.pubKey) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return signer.nip44Decrypt(content, counterparty) + } + + companion object { + /** + * The payloads are small JSON objects whose fields the service defines, so unknown keys + * are expected. Absent optionals are left out rather than written as `null`. + */ + internal val json = + Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..c4e8a1e775 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/TagArrayBuilderExt.kt @@ -0,0 +1,30 @@ +/* + * 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.nipXXPushNotifications + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nipXXPushNotifications.tags.AppTag + +fun TagArrayBuilder.pushService(pubKey: HexKey) = addUnique(PTag.assemble(pubKey, null)) + +fun TagArrayBuilder.app(app: String) = addUnique(AppTag.assemble(app)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/TagArrayExt.kt new file mode 100644 index 0000000000..a3a997fcb0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/TagArrayExt.kt @@ -0,0 +1,29 @@ +/* + * 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.nipXXPushNotifications + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nipXXPushNotifications.tags.AppTag + +fun TagArray.pushService() = firstNotNullOfOrNull(PTag::parseKey) + +fun TagArray.app() = firstNotNullOfOrNull(AppTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/deregistration/PushDeregistrationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/deregistration/PushDeregistrationEvent.kt new file mode 100644 index 0000000000..9f80020e2b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/deregistration/PushDeregistrationEvent.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.nipXXPushNotifications.deregistration + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipXXPushNotifications.PushServiceEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.app +import com.vitorpamplona.quartz.nipXXPushNotifications.pushService +import com.vitorpamplona.quartz.nipXXPushNotifications.registration.PushToken +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Removes a token registered with a [com.vitorpamplona.quartz.nipXXPushNotifications.registration.PushRegistrationEvent] + * (kind 3080). Clients publish it on logout. Preferences are keyed by the user, not the device, so + * they survive it. + */ +@Immutable +class PushDeregistrationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PushServiceEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + suspend fun decrypt(signer: NostrSigner): PushToken = json.decodeFromString(PushToken.serializer(), decryptContent(signer)) + + companion object { + const val KIND = 3080 + + suspend fun build( + token: String, + pushService: HexKey, + app: String, + signer: NostrSigner, + expiresAt: Long? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, signer.nip44Encrypt(json.encodeToString(PushToken.serializer(), PushToken(token)), pushService), createdAt) { + pushService(pushService) + app(app) + expiresAt?.let { expiration(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/preferences/PushPreferences.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/preferences/PushPreferences.kt new file mode 100644 index 0000000000..a366a88e18 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/preferences/PushPreferences.kt @@ -0,0 +1,39 @@ +/* + * 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.nipXXPushNotifications.preferences + +import kotlinx.serialization.Serializable + +/** + * The decrypted payload of a [PushPreferencesEvent]. + * + * [kinds] lists notification *categories*, which the service defines. They are numbered after event + * kinds but need not match what triggers them: divine-push-service reads `1` as "comments and + * mentions" (sent for kinds 1111, 30023 and 34236), `3` follows, `7` likes, `16` reposts, and + * `34236` new posts from authors the user subscribed to. An empty list turns everything off. + * + * [campaignsEnabled] is a separate opt-in for engagement campaigns; no category implies it. + */ +@Serializable +data class PushPreferences( + val kinds: List, + val campaignsEnabled: Boolean = false, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/preferences/PushPreferencesEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/preferences/PushPreferencesEvent.kt new file mode 100644 index 0000000000..894abcf90b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/preferences/PushPreferencesEvent.kt @@ -0,0 +1,64 @@ +/* + * 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.nipXXPushNotifications.preferences + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nipXXPushNotifications.PushServiceEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.app +import com.vitorpamplona.quartz.nipXXPushNotifications.pushService +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Which notifications a user wants from a push service (kind 3083). Optional: a service that never + * receives one sends everything it supports. + */ +@Immutable +class PushPreferencesEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PushServiceEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + suspend fun decrypt(signer: NostrSigner): PushPreferences = json.decodeFromString(PushPreferences.serializer(), decryptContent(signer)) + + companion object { + const val KIND = 3083 + + suspend fun build( + preferences: PushPreferences, + pushService: HexKey, + app: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, signer.nip44Encrypt(json.encodeToString(PushPreferences.serializer(), preferences), pushService), createdAt) { + pushService(pushService) + app(app) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/registration/PushRegistrationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/registration/PushRegistrationEvent.kt new file mode 100644 index 0000000000..71ca02d366 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/registration/PushRegistrationEvent.kt @@ -0,0 +1,70 @@ +/* + * 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.nipXXPushNotifications.registration + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipXXPushNotifications.PushServiceEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.app +import com.vitorpamplona.quartz.nipXXPushNotifications.pushService +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Registers a device's push token with a push service (kind 3079). + * + * Clients publish it when the push session becomes ready and again whenever the platform rotates + * the token. An `expiration` tag is allowed for relay housekeeping, but the reference service does + * not use it to decide how long the token stays valid. + */ +@Immutable +class PushRegistrationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PushServiceEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + suspend fun decrypt(signer: NostrSigner): PushToken = json.decodeFromString(PushToken.serializer(), decryptContent(signer)) + + companion object { + const val KIND = 3079 + + suspend fun build( + token: PushToken, + pushService: HexKey, + app: String, + signer: NostrSigner, + expiresAt: Long? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, signer.nip44Encrypt(json.encodeToString(PushToken.serializer(), token), pushService), createdAt) { + pushService(pushService) + app(app) + expiresAt?.let { expiration(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/registration/PushToken.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/registration/PushToken.kt new file mode 100644 index 0000000000..216e3f15f3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/registration/PushToken.kt @@ -0,0 +1,36 @@ +/* + * 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.nipXXPushNotifications.registration + +import kotlinx.serialization.Serializable + +/** + * The decrypted payload of a push registration or deregistration. + * + * [token] is the platform's device token (FCM, APNs, …). [timezoneOffsetMinutes] is the device's + * offset from UTC; the reference service needs it only to schedule campaign pushes, and a + * deregistration leaves it out. + */ +@Serializable +data class PushToken( + val token: String, + val timezoneOffsetMinutes: Int? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/tags/AppTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/tags/AppTag.kt new file mode 100644 index 0000000000..fcf0fda2af --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/tags/AppTag.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.nipXXPushNotifications.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * `["app", ""]`: which application a push control event is for. The service partitions + * tokens and preferences by it, so two apps signed in with the same key never see each other's + * devices. + */ +class AppTag { + companion object { + const val TAG_NAME = "app" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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(app: String) = arrayOf(TAG_NAME, app) + } +} 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 0c6598867e..cb8d8ba883 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -101,6 +101,7 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent @@ -345,6 +346,7 @@ import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent +import com.vitorpamplona.quartz.nip71Video.views.VideoViewEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent @@ -439,6 +441,9 @@ import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEven import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.deregistration.PushDeregistrationEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.preferences.PushPreferencesEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.registration.PushRegistrationEvent interface EventBuilder { fun build( @@ -467,6 +472,7 @@ class EventFactory { when (kind) { AcceptedBadgeSetEvent.KIND -> AcceptedBadgeSetEvent(id, pubKey, createdAt, tags, content, sig) ConcordChatEditEvent.KIND -> ConcordChatEditEvent(id, pubKey, createdAt, tags, content, sig) + ConcordTimerNoticeEvent.KIND -> ConcordTimerNoticeEvent(id, pubKey, createdAt, tags, content, sig) AdvertisedRelayListEvent.KIND -> AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) CvmServerAnnouncementEvent.KIND -> CvmServerAnnouncementEvent(id, pubKey, createdAt, tags, content, sig) CvmToolsListEvent.KIND -> CvmToolsListEvent(id, pubKey, createdAt, tags, content, sig) @@ -896,6 +902,10 @@ class EventFactory { AddressableNormalVideoEvent.KIND -> AddressableNormalVideoEvent(id, pubKey, createdAt, tags, content, sig) AddressableShortVideoEvent.KIND -> AddressableShortVideoEvent(id, pubKey, createdAt, tags, content, sig) TextTrackEvent.KIND -> TextTrackEvent(id, pubKey, createdAt, tags, content, sig) + VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig) + PushRegistrationEvent.KIND -> PushRegistrationEvent(id, pubKey, createdAt, tags, content, sig) + PushDeregistrationEvent.KIND -> PushDeregistrationEvent(id, pubKey, createdAt, tags, content, sig) + PushPreferencesEvent.KIND -> PushPreferencesEvent(id, pubKey, createdAt, tags, content, sig) VideoCollaborationEvent.KIND -> VideoCollaborationEvent(id, pubKey, createdAt, tags, content, sig) VideoNormalEvent.KIND -> VideoNormalEvent(id, pubKey, createdAt, tags, content, sig) VideoShortEvent.KIND -> VideoShortEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearingTest.kt index 25352275fa..5151430887 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearingTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearingTest.kt @@ -20,11 +20,16 @@ */ package com.vitorpamplona.quartz.concord.cord03Channels +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -69,4 +74,41 @@ class ConcordDisappearingTest { val bad = ChannelChat.message(author, channel, 4, "", 10, extraTags = arrayOf(arrayOf("timer", "04"))) assertNull(ConcordDisappearing.noticeTimerSecs(bad), "not a 1740") } + + @Test + fun timerNoticeParsesIntoItsOwnType() { + // A 1740 must land in the store as a typed event: an untyped Event is refused as unsupported. + val notice = ConcordDisappearing.timerNotice(author, channel, 4, 604_800, 10) + val parsed = Event.fromJson(notice.toJson()) + assertIs(parsed) + assertEquals(604_800L, parsed.timerSecs()) + } + + @Test + fun wrapCarriesTheRumorsExpirationBesideItsRandomP() = + runTest { + val alice = NostrSignerInternal(KeyPair()) + val plane = ConcordChannelKeys.publicChannel(ByteArray(32) { 0x5A }, ByteArray(32) { 0x42 }, 0) + val channelIdHex = ByteArray(32) { 0x42 }.toHexKey() + val exp = ConcordDisappearing.expirationFor(9, 1_000, 86_400) + val rumor = ChannelChat.message(alice.pubKey, channelIdHex, 0, "gm", 1_000, extraTags = ConcordDisappearing.withExpiration(emptyArray(), exp)) + + val wrap = ConcordStreamEnvelope.wrap(rumor, plane, alice, encrypted = true, outerTags = ConcordDisappearing.wrapTagsFor(rumor)) + // CORD-08 §2: the same value outside as inside, and the ephemeral `p` is still there. + assertEquals(listOf("p", "expiration"), wrap.tags.map { it[0] }) + assertEquals(64, wrap.tags[0][1].length) + assertEquals("87400", wrap.tags[1][1]) + assertEquals(87_400L, ConcordDisappearing.expirationOf(ConcordStreamEnvelope.open(wrap, plane).rumor)) + + // A rumor with no expiration gets a plain wrap: only the `p`. + val plain = ChannelChat.message(alice.pubKey, channelIdHex, 0, "forever", 1_000) + assertEquals(0, ConcordDisappearing.wrapTagsFor(plain).size) + assertEquals(listOf("p"), ConcordStreamEnvelope.wrap(plain, plane, alice, encrypted = true).tags.map { it[0] }) + } + + @Test + fun presetsNeverOfferLessThanADay() { + assertEquals(0L, ConcordDisappearing.PRESET_SECS.first()) + assertTrue(ConcordDisappearing.PRESET_SECS.drop(1).all { it >= ConcordDisappearing.MIN_OFFERED_SECS }) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt index 5a86d25d55..7aaf8510e3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt @@ -141,6 +141,8 @@ class ConcordPinsTest { val pin = ConcordPins.verify(edited, channelA)!! assertTrue(pin.edited) assertEquals("the plan", pin.content) + assertEquals(2_000L, pin.editOrderMs, "the proven Edit's send time, to judge a newer local Edit against") + assertNull(ConcordPins.verify(entry, channelA)!!.editOrderMs) assertEquals(original.id, pin.rumorId, "the identity stays the original's") val (_, foreign) = sendAndOpen(ChannelChat.edit(mallory.pubKey, channelA, 3, original.id, "pwned", 3), signer = mallory) @@ -159,6 +161,9 @@ class ConcordPinsTest { val sealed = ConcordPins.serializeSealed(listOf(entry), plane.conversationKey, 3) val opened3 = ConcordPins.read(sealed) { epoch -> plane.conversationKey.takeIf { epoch == 3L } } assertEquals(listOf(entry), opened3.entries) + assertTrue(ConcordPins.isSealedForm(sealed)) + assertFalse(ConcordPins.isSealedForm(ConcordPins.serializePublic(listOf(entry)))) + assertFalse(ConcordPins.isSealedForm("not json")) val noKey = ConcordPins.read(sealed) { null } assertTrue(noKey.sealedUnavailable, "unreadable is not empty: a writer must not build on it") assertTrue(noKey.entries.isEmpty()) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt index 396ffbad1b..985da96538 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt @@ -20,47 +20,203 @@ */ package com.vitorpamplona.quartz.concord.cord05Invites +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue class ConcordDirectInviteTest { + private val owner = NostrSignerInternal(KeyPair()) private val sender = NostrSignerInternal(KeyPair()) private val recipient = NostrSignerInternal(KeyPair()) private val stranger = NostrSignerInternal(KeyPair()) - private val invite = - CommunityInvite( - communityId = "11".repeat(32), - owner = "0f".repeat(32), - ownerSalt = "aa".repeat(32), - communityRoot = "bb".repeat(32), - name = "Nostrichs", + private suspend fun community(): NewConcordCommunity = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + + private fun inviteFor( + community: NewConcordCommunity, + expiresAt: Long? = null, + relays: List = listOf("wss://relay.example"), + channels: List = emptyList(), + ) = CommunityInvite( + communityId = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + communityRoot = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + channels = channels, + relays = relays, + name = "Nostrichs", + expiresAt = expiresAt, + ) + + /** Wraps an arbitrary [seal] to [to] exactly like [ConcordDirectInvite.build] does (ephemeral author, p + k tags). */ + private suspend fun wrapSeal( + seal: Event, + to: String, + ): GiftWrapEvent { + val eph = NostrSignerInternal(KeyPair()) + return eph.sign( + createdAt = seal.createdAt, + kind = GiftWrapEvent.KIND, + tags = arrayOf(arrayOf("p", to), arrayOf("k", "3313")), + content = eph.nip44Encrypt(seal.toJson(), to), ) + } + + /** A seal from [sealer] carrying a kind-[kind] rumor that CLAIMS [claimedAuthor]. */ + private suspend fun forgedSeal( + sealer: NostrSigner, + claimedAuthor: String, + content: String, + kind: Int = ConcordDirectInvite.KIND, + ): SealEvent { + val rumor = RumorAssembler.assembleRumor(claimedAuthor, 1_700_000_000L, kind, emptyArray(), content) + return SealEvent.create(rumor, recipient.pubKey, sealer, createdAt = 1_700_000_000L) + } + + private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) @Test - fun directInviteRoundTripsToTheRecipient() = + fun directInviteRoundTripsWithTheVerifiedSender() = runTest { - val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, invite, createdAt = 1_700_000_000L) + val c = community() + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c), createdAt = 1_700_000_000L) - // Wrap is a giftwrap tagged for the recipient and indexable by k=3313. + // Wrap is a giftwrap tagged for the recipient and indexable by k=3313, from an ephemeral author. + assertEquals(GiftWrapEvent.KIND, wrap.kind) assertEquals(recipient.pubKey, wrap.tags.first { it[0] == "p" }[1]) assertEquals("3313", wrap.tags.first { it[0] == "k" }[1]) + assertFalse(wrap.pubKey == sender.pubKey) - val parsed = ConcordDirectInvite.parse(wrap, recipient) - assertNotNull(parsed) - assertEquals("Nostrichs", parsed.name) - assertEquals("11".repeat(32), parsed.communityId) + val opened = ConcordDirectInvite.open(wrap, recipient) + assertNotNull(opened) + assertEquals(sender.pubKey, opened.sender) + assertEquals(wrap.id, opened.wrapId) + assertEquals(1_700_000_000L, opened.sentAt) + assertEquals("Nostrichs", opened.invite.name) + assertEquals(c.communityIdHex, opened.invite.communityId) + assertEquals(c.controlPkHex, opened.invite.controlPk) + + // The legacy parse keeps working. + assertEquals(c.communityIdHex, ConcordDirectInvite.parse(wrap, recipient)?.communityId) } @Test fun strangersCannotOpenIt() = runTest { - val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, invite, createdAt = 1L) - assertNull(ConcordDirectInvite.parse(wrap, stranger)) + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(community()), createdAt = 1L) + assertNull(ConcordDirectInvite.open(wrap, stranger)) } + + @Test + fun aRumorClaimingSomeoneElseIsRefused() = + runTest { + // The attacker seals (and so is the verified sender) a rumor claiming the owner wrote it. + val c = community() + val spoofed = wrapSeal(forgedSeal(stranger, claimedAuthor = owner.pubKey, content = json(inviteFor(c))), recipient.pubKey) + assertNull(ConcordDirectInvite.open(spoofed, recipient)) + + // The very same rumor claiming its real sealer opens. + val honest = wrapSeal(forgedSeal(stranger, claimedAuthor = stranger.pubKey, content = json(inviteFor(c))), recipient.pubKey) + assertEquals(stranger.pubKey, ConcordDirectInvite.open(honest, recipient)?.sender) + } + + @Test + fun theRumorKindIsTheAuthorityNotTheKTag() = + runTest { + // A k=3313-tagged wrap whose rumor is a kind-14 DM is not an invite. + val c = community() + val dm = wrapSeal(forgedSeal(sender, claimedAuthor = sender.pubKey, content = json(inviteFor(c)), kind = 14), recipient.pubKey) + assertNull(ConcordDirectInvite.open(dm, recipient)) + } + + @Test + fun wrapCarriesNip40ExpirationMatchingExpiresAt() = + runTest { + val c = community() + val expiresAtMs = 1_800_000_123_456L + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c, expiresAt = expiresAtMs), createdAt = 1_700_000_000L) + assertEquals(1_800_000_123L, wrap.tags.expiration()) + + assertFalse(ConcordDirectInvite.isWrapExpired(wrap, nowSecs = 1_800_000_122L)) + assertTrue(ConcordDirectInvite.isWrapExpired(wrap, nowSecs = 1_800_000_123L)) + + // No expires_at, no expiration tag. + val open = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c), createdAt = 1_700_000_000L) + assertNull(open.tags.expiration()) + assertFalse(ConcordDirectInvite.isWrapExpired(open, nowSecs = Long.MAX_VALUE)) + + // An expired bundle still opens (a parked invite renders), but reports itself expired. + val opened = ConcordDirectInvite.open(wrap, recipient) + assertNotNull(opened) + assertTrue(opened.isExpired(nowMs = expiresAtMs + 1)) + assertFalse(opened.isExpired(nowMs = expiresAtMs - 1)) + } + + @Test + fun sealAndWrapAreBackdatedWithinTwoDaysButTheRumorKeepsTheRealTime() = + runTest { + val c = community() + val now = 1_700_000_000L + val outer = mutableListOf() + repeat(6) { + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c), createdAt = now) + val seal = wrap.unwrapOrNull(recipient) + assertIs(seal) + for (t in listOf(wrap.createdAt, seal.createdAt)) { + assertTrue(t <= now, "outer timestamp $t is in the future") + assertTrue(t > now - ConcordDirectInvite.MAX_BACKDATE_SECS, "outer timestamp $t is backdated past two days") + outer += t + } + assertEquals(now, ConcordDirectInvite.open(wrap, recipient)?.sentAt) + } + // Twelve independent draws over a two-day range are not all "now". + assertTrue(outer.any { it < now }) + } + + @Test + fun theSection1BoundsApply() = + runTest { + val c = community() + val sixRelays = (1..6).map { "wss://r$it.example" } + val bounded = ConcordDirectInvite.open(ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c, relays = sixRelays), createdAt = 1L), recipient) + assertEquals(sixRelays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), bounded?.invite?.relays) + + val tooMany = (0..ConcordInviteBundle.MAX_BUNDLE_CHANNELS).map { InviteChannel(id = it.toString(16).padStart(64, '0'), key = "cd".repeat(32), epoch = 0) } + assertNull(ConcordDirectInvite.open(ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c, channels = tooMany), createdAt = 1L), recipient)) + } + + @Test + fun aBundleWhoseOwnerProofFailsIsRefused() = + runTest { + // A real community's id with someone else's owner: the id does not self-certify it. + val c = community() + val forged = inviteFor(c).copy(owner = stranger.pubKey) + assertNull(ConcordDirectInvite.open(ConcordDirectInvite.build(sender, recipient.pubKey, forged, createdAt = 1L), recipient)) + } + + @Test + fun inboxSinceRewindsByTheBackdateWindow() { + assertNull(ConcordDirectInvite.inboxSince(null)) + assertNull(ConcordDirectInvite.inboxSince(100L)) + assertEquals(1_700_000_000L - ConcordDirectInvite.MAX_BACKDATE_SECS, ConcordDirectInvite.inboxSince(1_700_000_000L)) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistryTest.kt new file mode 100644 index 0000000000..522c1ca0fc --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistryTest.kt @@ -0,0 +1,273 @@ +/* + * 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.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.ControlFixtures +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** CORD-05 §5: the Invite Registry (`vsk 8`) and the Public/Private mode its aggregate defines. */ +class ConcordInviteRegistryTest { + private val owner = "0f".repeat(32) + private val alice = "a1".repeat(32) + private val bob = "b2".repeat(32) + private val troll = "77".repeat(32) + private val inviterRole = "11".repeat(32) + private val link1 = "c1".repeat(32) + private val link2 = "c2".repeat(32) + private val link3 = "c3".repeat(32) + + private val cid = ControlFixtures.communityId + + private fun registryEid(creator: String) = ConcordInviteRegistry.coordinateHex(cid, creator) + + private fun edition( + kind: ControlEntityKind, + eid: String, + content: String, + author: String = owner, + version: Long = 0, + prev: ControlEdition? = null, + ) = ControlEdition(kind, eid.hexToByteArray(), version, prev?.hash, null, content, author, "r-$eid-$version-$author", version) + + private fun registry( + creator: String, + content: String, + version: Long = 0, + prev: ControlEdition? = null, + at: String = registryEid(creator), + ) = edition(ControlEntityKind.INVITE_REGISTRY, at, content, creator, version, prev) + + private fun list(vararg signers: String) = ConcordInviteRegistry.encode(signers.toList()) + + private val inviterRoleEdition = + edition(ControlEntityKind.ROLE, inviterRole, """{"role_id":"$inviterRole","name":"Inviter","position":2,"permissions":"${ConcordPermissions.of(ConcordPermissions.CREATE_INVITE).toWire()}"}""") + + private fun grant( + member: String, + vararg roles: String, + version: Long = 0, + prev: ControlEdition? = null, + ) = edition( + ControlEntityKind.GRANT, + ControlFixtures.grantEid(member), + """{"member":"$member","role_ids":[${roles.joinToString(",") { "\"$it\"" }}]}""", + version = version, + prev = prev, + ) + + @Test + fun theCoordinateIsTheSpecDerivation() { + assertEquals( + ConcordKeyDerivation.inviteLinksCoordinate(cid, alice.hexToByteArray()).toHexKey(), + registryEid(alice), + ) + } + + @Test + fun theBuilderWritesAnEditionAtTheCreatorsCoordinateWithLocatorsOnly() { + val rumor = ConcordInviteRegistry.rumor(alice, cid, listOf(link2, link1.uppercase(), link1, "not-a-key"), version = 3, prevHash = ByteArray(32) { 9 }, createdAt = 1_700_000_000) + val parsed = assertNotNull(ControlEdition.fromRumor(rumor)) + assertEquals(ControlEntityKind.INVITE_REGISTRY, parsed.entityKind) + assertEquals("8", parsed.vsk) + assertEquals(registryEid(alice), parsed.entityIdHex) + assertEquals(3, parsed.version) + assertEquals(alice, parsed.author) + // Lowercase, de-duplicated, sorted, junk dropped: a bare array of link-signer pubkeys. + assertEquals("""["$link1","$link2"]""", parsed.content) + } + + @Test + fun theOwnersRegistryMakesTheCommunityPublic() { + val state = ControlFixtures.fold(listOf(registry(owner, list(link1, link2))), owner) + assertEquals(mapOf(owner to listOf(link1, link2)), state.inviteRegistries) + assertEquals(setOf(link1, link2), state.liveInviteLinks) + assertTrue(state.isPublic) + } + + @Test + fun noRegistryOrAnEmptyOneIsPrivate() { + assertFalse(ControlFixtures.fold(emptyList(), owner).isPublic) + val emptied = ControlFixtures.fold(listOf(registry(owner, "[]")), owner) + assertFalse(emptied.isPublic) + assertEquals(emptyList(), emptied.registryOf(owner)) + } + + @Test + fun aCreateInviteHolderIsHonoredAndAnUnauthorizedAuthorIsNot() { + val editions = + listOf( + inviterRoleEdition, + grant(alice, inviterRole), + registry(alice, list(link1)), + registry(troll, list(link2)), + ) + val state = ControlFixtures.fold(editions, owner) + assertEquals(listOf(link1), state.registryOf(alice)) + assertEquals(emptyList(), state.registryOf(troll), "no CREATE_INVITE, no registry") + assertEquals(setOf(link1), state.liveInviteLinks) + } + + @Test + fun aRegistryAtAnotherCreatorsCoordinateIsIgnored() { + // Alice holds CREATE_INVITE but writes into Bob's coordinate: the coordinate binds to the author. + val editions = + listOf( + inviterRoleEdition, + grant(alice, inviterRole), + registry(alice, list(link1), at = registryEid(bob)), + ) + val state = ControlFixtures.fold(editions, owner) + assertTrue(state.inviteRegistries.isEmpty()) + assertFalse(state.isPublic) + } + + @Test + fun malformedContentFallsBackToThePreviousEditionAndJunkEntriesAreDropped() { + val v0 = registry(owner, list(link1)) + val broken = registry(owner, """{"links":["$link2"]}""", version = 1, prev = v0) + assertEquals(listOf(link1), ControlFixtures.fold(listOf(v0, broken), owner).registryOf(owner), "not an array: the head stays at v0") + + val notJson = registry(owner, "[$link2", version = 1, prev = v0) + assertEquals(listOf(link1), ControlFixtures.fold(listOf(v0, notJson), owner).registryOf(owner)) + + // An array is well-formed whatever it holds; only 64-hex string entries survive, lowercased. + val mixed = registry(owner, """["$link2", 42, null, "abc", "${link3.uppercase()}", "$link2", ["$link1"]]""", version = 1, prev = v0) + assertEquals(listOf(link2, link3), ControlFixtures.fold(listOf(v0, mixed), owner).registryOf(owner)) + + assertNull(ConcordInviteRegistry.decodeOrNull("nope")) + assertNull(ConcordInviteRegistry.decodeOrNull("""{"a":1}""")) + } + + @Test + fun theAggregateSpansCreatorsAndDrivesThePublicHelpers() { + val editions = + listOf( + inviterRoleEdition, + grant(alice, inviterRole), + grant(bob, inviterRole), + registry(owner, list(link1)), + registry(alice, list(link2)), + registry(bob, "[]"), + ) + val state = ControlFixtures.fold(editions, owner) + assertEquals(setOf(link1, link2), state.liveInviteLinks) + assertTrue(state.isPublic) + + // Banning alice leaves the owner's link: still Public. Banning her with the owner gone would not. + assertTrue(state.isPublic(listOf(alice))) + assertFalse(state.isPublic(listOf(alice, owner))) + + // Foreign links are anyone's but the viewer's (and the excluded). + assertTrue(state.hasForeignLiveLinks(owner)) + assertFalse(state.hasForeignLiveLinks(owner, listOf(alice))) + assertFalse(state.hasForeignLiveLinks(bob, listOf(alice, owner))) + + // Retiring one of two live links keeps it Public; retiring both flips it Private. + assertFalse(state.retiringWouldPrivatize(listOf(link1))) + assertTrue(state.retiringWouldPrivatize(listOf(link1, link2))) + assertFalse(ControlFixtures.fold(emptyList(), owner).retiringWouldPrivatize(listOf(link1)), "already Private: nothing flips") + } + + @Test + fun aCreatorWhoLosesCreateInviteDropsOut() { + val g0 = grant(alice, inviterRole) + val before = listOf(inviterRoleEdition, g0, registry(alice, list(link1))) + assertTrue(ControlFixtures.fold(before, owner).isPublic) + + // The owner strips alice's roles: her registry is no longer honored, the link no longer counts. + val g1 = grant(alice, version = 1, prev = g0) + val after = ControlFixtures.fold(before + g1, owner) + assertEquals(emptyList(), after.registryOf(alice)) + assertFalse(after.isPublic) + } + + @Test + fun aBannedCreatorDropsOut() { + val editions = + listOf( + inviterRoleEdition, + grant(alice, inviterRole), + registry(alice, list(link1)), + edition(ControlEntityKind.BANLIST, ControlFixtures.banlistEid(), """["$alice"]"""), + ) + assertFalse(ControlFixtures.fold(editions, owner).isPublic) + } + + @Test + fun aBanRefoundsOnlyWhenTheCommunityIsPrivateWithoutTheTargetsLinks() { + val editions = listOf(inviterRoleEdition, grant(alice, inviterRole), registry(alice, list(link1))) + val state = ControlFixtures.fold(editions, owner) + assertFalse(state.banRequiresRefounding(listOf(bob)), "Public: the Banlist alone") + assertTrue(state.banRequiresRefounding(listOf(alice)), "banning the only link creator leaves it Private") + assertTrue(ControlFixtures.fold(emptyList(), owner).banRequiresRefounding(listOf(bob)), "Private: a ban Refounds") + } + + private fun entry( + token: String, + signer: KeyPair, + community: String = ControlFixtures.COMMUNITY_ID_HEX, + expiresAt: Long? = null, + ) = ConcordInviteListEntry(token = token, signerSk = signer.privKey!!.toHexKey(), communityId = community, url = "u", createdAt = 1, expiresAt = expiresAt) + + @Test + fun theNextRegistryAddsMintsDropsRetiredAndPrunesExpiredOrTombstonedLinks() { + val live = KeyPair() + val expired = KeyPair() + val tombstoned = KeyPair() + val otherCommunity = KeyPair() + val doc = + ConcordInviteListDocument( + entries = + listOf( + entry("01", live), + entry("02", expired, expiresAt = 100), + entry("03", tombstoned), + entry("04", otherCommunity, community = "ee".repeat(32)), + ), + tombstones = listOf(ConcordInviteListTombstone("03", ControlFixtures.COMMUNITY_ID_HEX)), + ) + val livePk = live.pubKey.toHexKey() + val expiredPk = expired.pubKey.toHexKey() + + // The published registry still lists the expired link and an unrecorded one (link1); a new mint adds link2. + val next = ConcordInviteRegistry.nextLinks(listOf(expiredPk, link1), doc, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200, minted = listOf(link2)) + assertEquals(listOf(link1, link2, livePk).sorted(), next) + + // Retiring the recorded live link and link1 leaves only the mint. + assertEquals(listOf(link2), ConcordInviteRegistry.nextLinks(next, doc, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200, retired = listOf(livePk, link1))) + + // Before its expiry the link is still live; an unreadable list prunes nothing. + assertTrue(expiredPk in ConcordInviteRegistry.nextLinks(emptyList(), doc, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 50)) + assertEquals(listOf(expiredPk), ConcordInviteRegistry.nextLinks(listOf(expiredPk), null, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt new file mode 100644 index 0000000000..fc7577a4c6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.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.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A Direct Invite for an already-joined community is a catch-up: it may only add Private Channel + * keys on the SAME base (root, epoch, control_pk) — never move the base (Armada `catchUpChannelIds`). + */ +class ConcordInviteVendTest { + private val communityId = "11".repeat(32) + private val root = "22".repeat(32) + private val controlPk = "33".repeat(32) + private val chanA = "a1".repeat(32) + private val chanB = "b2".repeat(32) + private val keyA = "ca".repeat(32) + private val keyB = "db".repeat(32) + + private val held = + ConcordCommunityListEntry( + id = communityId, + owner = "44".repeat(32), + ownerSalt = "55".repeat(32), + root = root, + rootEpoch = 3, + controlPk = controlPk, + privateChannels = listOf(PrivateChannelKey(chanA, keyA, 1, "mods")), + relays = listOf("wss://relay.example"), + name = "Nostrichs", + inviteRef = "naddr1ref", + ) + + private fun bundle( + root: String = this.root, + epoch: Long = 3, + controlPk: String? = this.controlPk, + channels: List, + ) = CommunityInvite( + communityId = communityId, + owner = held.owner, + ownerSalt = held.ownerSalt, + communityRoot = root, + rootEpoch = epoch, + controlPk = controlPk, + channels = channels, + name = "Nostrichs", + ) + + @Test + fun aNewPrivateChannelKeyOnTheSameBaseIsACatchUp() { + val b = bundle(channels = listOf(InviteChannel(chanA, keyA, 1, "mods"), InviteChannel(chanB.uppercase(), keyB, 0, "vip"))) + assertEquals(listOf(chanB), ConcordInviteVend.catchUpChannelIds(held, b)) + + val adopted = ConcordInviteVend.adoptCatchUp(held, b) + assertNotNull(adopted) + // The base never moves. + assertEquals(root, adopted.root) + assertEquals(3, adopted.rootEpoch) + assertEquals(controlPk, adopted.controlPk) + assertEquals(held.inviteRef, adopted.inviteRef) + assertEquals(setOf(chanA to keyA, chanB to keyB), adopted.privateChannels.map { it.channelId to it.key }.toSet()) + } + + @Test + fun aNewerEpochOfAHeldChannelReplacesIt() { + val newer = "ee".repeat(32) + val b = bundle(channels = listOf(InviteChannel(chanA, newer, 2, "mods"))) + assertEquals(listOf(chanA), ConcordInviteVend.catchUpChannelIds(held, b)) + val adopted = assertNotNull(ConcordInviteVend.adoptCatchUp(held, b)) + assertEquals(listOf(Triple(chanA, newer, 2L)), adopted.privateChannels.map { Triple(it.channelId, it.key, it.epoch) }) + + // Same or older epoch contributes nothing. + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanA, newer, 1)))).isEmpty()) + } + + @Test + fun aBundleOnAnotherBaseIsNeverACatchUp() { + val grant = listOf(InviteChannel(chanB, keyB, 0, "vip")) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(root = "99".repeat(32), channels = grant)).isEmpty()) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(epoch = 4, channels = grant)).isEmpty()) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(controlPk = "98".repeat(32), channels = grant)).isEmpty()) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(controlPk = null, channels = grant)).isEmpty()) + assertNull(ConcordInviteVend.adoptCatchUp(held, bundle(root = "99".repeat(32), channels = grant))) + } + + @Test + fun nothingHeldMeansNoCatchUpAndKeylessGrantsDeliverNothing() { + assertTrue(ConcordInviteVend.catchUpChannelIds(null, bundle(channels = listOf(InviteChannel(chanB, keyB, 0)))).isEmpty()) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanB, "", 0)))).isEmpty()) + assertNull(ConcordInviteVend.adoptCatchUp(held, bundle(channels = emptyList()))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/attestations/RequestTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/attestations/RequestTagTest.kt new file mode 100644 index 0000000000..397aea98c5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/attestations/RequestTagTest.kt @@ -0,0 +1,86 @@ +/* + * 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.experimental.attestations + +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.RequestTag +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RequestTagTest { + private val pk = "1".repeat(64) + + @Test + fun aRequestTagPointsAtAnAttestationRequest() { + val request = "${AttestationRequestEvent.KIND}:$pk:claim" + val parsed: RequestTag? = RequestTag.parse(arrayOf("request", request)) + assertEquals(request, parsed?.toTag()) + assertEquals(request, RequestTag.parseAddressId(arrayOf("request", request))) + } + + @Test + fun anythingElseIsNotARequest() { + assertNull(RequestTag.parse(arrayOf("request", "30023:$pk:post"))) + } + + @Test + fun theRequestsPTagsAreItsAttestors() { + val a = "a".repeat(64) + val b = "b".repeat(64) + val event = AttestationRequestEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "x"), arrayOf("p", a), arrayOf("p", b)), "", "0".repeat(128)) + assertEquals(listOf(a, b), event.attestorPubKeys()) + } + + @Test + fun aRequestIdMustBeAWholeCoordinate() { + for (value in listOf("${AttestationRequestEvent.KIND}:junk", "${AttestationRequestEvent.KIND}:$pk", "${AttestationRequestEvent.KIND}")) { + assertNull(RequestTag.parseAddressId(arrayOf("request", value)), value) + assertNull(RequestTag.parseAsHint(arrayOf("request", value, "wss://relay.example/")), value) + } + } + + @Test + fun anAttestationLinksAndHintsTheRequestItAnswers() { + val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example/")!! + val request = AttestationRequestEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "claim")), "", "0".repeat(128)) + val note = TextNoteEvent("2".repeat(64), "3".repeat(64), 1, emptyArray(), "hi", "0".repeat(128)) + val attestation = + NostrSignerSync().sign( + AttestationEvent.buildEvent( + "att", + EventHintBundle(note), + requestAddress = EventHintBundle(request).also { it.relay = relay }, + ), + ) + val requestId = request.address().toValue() + assertTrue(requestId in attestation.linkedAddressIds()) + assertTrue(AddressHint(requestId, relay) in attestation.addressHints()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddressTest.kt new file mode 100644 index 0000000000..972241ca52 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddressTest.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.experimental.forks + +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent +import kotlin.test.Test +import kotlin.test.assertEquals + +class ForkedAddressTest { + private val pk = "1".repeat(64) + private val community = "34550:$pk:group" + + @Test + fun aNoteForksFromItsMarkedAddressNotItsCommunity() { + val origin = "30023:$pk:post" + val note = TextNoteEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("a", community), arrayOf("a", origin, "", "fork")), "", "0".repeat(128)) + assertEquals(origin, note.forkFromAddress()?.toValue()) + } + + @Test + fun aWikiArticleFindsTheArticleItWasForkedFrom() { + val origin = "30818:$pk:bitcoin" + val article = WikiArticleEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "bitcoin"), arrayOf("a", origin, "", "fork")), "", "0".repeat(128)) + assertEquals(origin, article.forkFromAddress()?.toValue()) + } + + @Test + fun aNipTextFindsTheTextItWasForkedFrom() { + val origin = "${NipTextEvent.KIND}:$pk:nip-01" + val text = NipTextEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "nip-01"), arrayOf("a", community), arrayOf("a", origin, "", "fork")), "", "0".repeat(128)) + assertEquals(origin, text.forkFromAddress()?.toValue()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/ReadingStateBuildTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/ReadingStateBuildTest.kt new file mode 100644 index 0000000000..7921cb194b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/ReadingStateBuildTest.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.experimental.interactiveStories + +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import kotlin.test.Test +import kotlin.test.assertEquals + +class ReadingStateBuildTest { + @Test + fun theReadingStateKeepsItsRootAndItsSummaryAndImage() { + val pk = "1".repeat(64) + val prologue = + InteractiveStoryPrologueEvent( + "0".repeat(64), + pk, + 1, + arrayOf(arrayOf("d", "story"), arrayOf("title", "A Story"), arrayOf("summary", "the summary"), arrayOf("image", "https://img.example/cover.png")), + "", + "0".repeat(128), + ) + val scene = InteractiveStorySceneEvent("2".repeat(64), pk, 2, arrayOf(arrayOf("d", "scene-2")), "", "0".repeat(128)) + val state = NostrSignerSync().sign(InteractiveStoryReadingStateEvent.build(EventHintBundle(prologue), EventHintBundle(scene))) + + assertEquals(prologue.address().toValue(), state.root()?.toTag()) + assertEquals(scene.address().toValue(), state.currentScene()?.toValue()) + assertEquals("the summary", state.summary()) + assertEquals("https://img.example/cover.png", state.image()) + } + + @Test + fun aStateWrittenWithoutItsRootTagFallsBackToItsDTagAndHealsOnUpdate() { + val pk = "1".repeat(64) + val story = "30296:$pk:story" + val scene = "30297:$pk:scene-2" + // What the old builder published: the root's lowercase `a` was replaced by the scene's. + val old = + InteractiveStoryReadingStateEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", story), arrayOf("a", scene), arrayOf("status", "reading")), "", "0".repeat(128)) + assertEquals(story, old.root()?.toTag()) + + val next = InteractiveStorySceneEvent("3".repeat(64), pk, 2, arrayOf(arrayOf("d", "scene-3")), "", "0".repeat(128)) + val updated = NostrSignerSync().sign(InteractiveStoryReadingStateEvent.update(old, EventHintBundle(next))) + assertEquals(listOf(story), updated.tags.filter { it[0] == "A" }.map { it[1] }) + assertEquals(next.address().toValue(), updated.currentScene()?.toValue()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/AddressMemberShapeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/AddressMemberShapeTest.kt new file mode 100644 index 0000000000..25540b00b6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/AddressMemberShapeTest.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.experimental.trustedLists + +import com.vitorpamplona.quartz.experimental.trustedLists.addressables.tags.AddressMemberTag +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class AddressMemberShapeTest { + private val pk = "1".repeat(64) + + @Test + fun onlyCoordinatesAreMembers() { + val coordinate = "30023:$pk:post" + assertEquals(coordinate, AddressMemberTag.parseAddressId(arrayOf("a", coordinate))) + + // An naddr would decode, but its raw bech32 must not become the member key. + val naddr = NAddress.create(30023, pk, "post", null) + for (value in listOf(naddr, "abc:$pk:d", "not-an-address", "30023:short:d")) { + assertNull(AddressMemberTag.parse(arrayOf("a", value)), value) + assertNull(AddressMemberTag.parseAddressId(arrayOf("a", value)), value) + assertNull(AddressMemberTag.parseAddress(arrayOf("a", value)), value) + assertFalse(AddressMemberTag.isTag(arrayOf("a", value)), value) + } + assertEquals(Address(30023, pk, "post"), AddressMemberTag.parseAddress(arrayOf("a", coordinate))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressIsOfKindBoundsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressIsOfKindBoundsTest.kt new file mode 100644 index 0000000000..560db53164 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressIsOfKindBoundsTest.kt @@ -0,0 +1,43 @@ +/* + * 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.nip01Core.core + +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.RequestTag +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AddressIsOfKindBoundsTest { + @Test + fun aValueThatIsOnlyTheKindIsNotOfThatKind() { + assertFalse(Address.isOfKind("31872", "31872")) + assertFalse(AddressSerializer.isOfKind("31872", "31872")) + assertFalse(Address.isOfKind("318720:x:y", "31872")) + assertTrue(Address.isOfKind("31872:x:y", "31872")) + } + + @Test + fun aHostileRequestTagIsRejectedNotThrown() { + assertNull(RequestTag.parse(arrayOf("request", "31872"))) + assertFalse(RequestTag.isTagged(arrayOf("request", "31872"))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndexByIdTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndexByIdTest.kt new file mode 100644 index 0000000000..e19e42f9ae --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndexByIdTest.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.nip09Deletions + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DeletionIndexByIdTest { + private val alice = NostrSignerInternal(KeyPair()) + private val mallory = NostrSignerInternal(KeyPair()) + + @Test + fun aDeleteIsFoundByIdAndAuthorAloneAndOnlyForItsAuthor() = + runTest { + val message = alice.sign(ChatEvent.build("hello", createdAt = 1)) + val index = DeletionIndex() + assertFalse(index.hasBeenDeleted(message.id, alice.pubKey)) + + // A stranger's delete names the id but is not the author's own. + index.add(mallory.sign(DeletionRequestEvent.build(listOf(message), createdAt = 2)), wasVerified = false) + assertFalse(index.hasBeenDeleted(message.id, alice.pubKey)) + assertTrue(index.hasBeenDeleted(message.id, mallory.pubKey)) + + index.add(alice.sign(DeletionRequestEvent.build(listOf(message), createdAt = 3)), wasVerified = false) + assertTrue(index.hasBeenDeleted(message.id, alice.pubKey), "no loaded event needed: the id and its author suffice") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/QTagAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/QTagAddressTest.kt new file mode 100644 index 0000000000..0e2dcc4ba5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/QTagAddressTest.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.nip18Reposts.quotes + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class QTagAddressTest { + private val address = "30023:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:my-article" + + @Test + fun anAddressQuoteParsesAsAnAddress() { + assertEquals(address, QTag.parseAddressId(arrayOf("q", address))) + assertEquals(address, QTag.parseAddressAsHint(arrayOf("q", address, "wss://relay.example.com"))?.addressId) + } + + @Test + fun anEventQuoteIsNotAnAddress() { + assertNull(QTag.parseAddressId(arrayOf("q", "a".repeat(64)))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/PubKeysNeverLeakNsecTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/PubKeysNeverLeakNsecTest.kt new file mode 100644 index 0000000000..247b106bbd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/PubKeysNeverLeakNsecTest.kt @@ -0,0 +1,38 @@ +/* + * 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.nip19Bech32 + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals + +class PubKeysNeverLeakNsecTest { + private val secret = "7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a" + private val npubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + + @Test + fun aPastedNsecIsNotALinkedPubkey() { + val nsec = secret.hexToByteArray().toNsec() + val npub = npubKey.hexToByteArray().toNpub() + val cited = Nip19Parser.parseAll("leaked nostr:$nsec and hi nostr:$npub") + assertEquals(listOf(npubKey), cited.pubKeys()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip28PublicChat/ChannelHideMessageEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip28PublicChat/ChannelHideMessageEventTest.kt new file mode 100644 index 0000000000..32d5a848e5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip28PublicChat/ChannelHideMessageEventTest.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.nip28PublicChat + +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ChannelHideMessageEventTest { + private val channel = "c".repeat(64) + private val message = "d".repeat(64) + + private fun hide(vararg tags: Array) = ChannelHideMessageEvent("0".repeat(64), "1".repeat(64), 1, arrayOf(*tags), "spam", "0".repeat(128)) + + @Test + fun theChannelIsNotAmongTheHiddenMessages() { + val event = hide(arrayOf("e", channel, "", "root"), arrayOf("e", message)) + assertEquals(channel, event.channelId()) + assertEquals(listOf(message), event.eventsToHide()) + } + + @Test + fun aSpecShapedHideDoesNotReadTheHiddenMessageAsTheChannel() { + // NIP-28's own kind 43 carries only the message to hide. + val event = hide(arrayOf("e", message)) + assertNull(event.channelId()) + assertEquals(listOf(message), event.eventsToHide()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/EventDTagAgreesWithAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/EventDTagAgreesWithAddressTest.kt new file mode 100644 index 0000000000..30d17ee43a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/EventDTagAgreesWithAddressTest.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.nip51Lists + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent +import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent +import kotlin.test.Test +import kotlin.test.assertEquals + +class EventDTagAgreesWithAddressTest { + private val pk = "1".repeat(64) + + @Test + fun aListSeenAsAPlainEventHasItsAddressesDTag() { + val list: Event = MediaFollowListEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "stray")), "", "0".repeat(128)) + assertEquals("", list.dTag()) + + val set: Event = FollowSetEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "friends")), "", "0".repeat(128)) + assertEquals("friends", set.dTag()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/ListAddressAndGeohashTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/ListAddressAndGeohashTest.kt new file mode 100644 index 0000000000..bc105bd37d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/ListAddressAndGeohashTest.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.nip51Lists + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ListAddressAndGeohashTest { + private val pk = "1".repeat(64) + + @Test + fun aStrayDTagDoesNotSplitAReplaceableListsAddress() { + val list = MediaFollowListEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "stray")), "", "0".repeat(128)) + assertEquals("", list.dTag()) + assertEquals("${MediaFollowListEvent.KIND}:$pk:", list.addressTag()) + } + + @Test + fun aSetIsStillAddressedByItsD() { + val set = FollowSetEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "friends")), "", "0".repeat(128)) + assertEquals("friends", set.dTag()) + } + + @Test + fun privateGeohashesAreNeverPublishedInClear() { + val event = GeohashListEvent.create(listOf("u4pr"), listOf("9q8y"), NostrSignerSync()) + val clear = event.tags.filter { it.size > 1 && it[0] == "g" }.map { it[1] } + assertTrue("u4pr" in clear, "$clear") + assertFalse("9q8y" in clear, "a private geohash leaked into the public tags: $clear") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/LiveActivitiesLinkFixesTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/LiveActivitiesLinkFixesTest.kt new file mode 100644 index 0000000000..9fa2ca2997 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/LiveActivitiesLinkFixesTest.kt @@ -0,0 +1,58 @@ +/* + * 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.nip53LiveActivities + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.tagArray +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag +import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.presence.roomMeeting +import kotlin.test.Test +import kotlin.test.assertEquals + +class LiveActivitiesLinkFixesTest { + private val pk = "1".repeat(64) + + @Test + fun unmarkedReplyTosAreTheUnmarkedOnes() { + val marked = "a".repeat(64) + val unmarked = "b".repeat(64) + val chat = + LiveActivitiesChatMessageEvent( + "0".repeat(64), + pk, + 1, + arrayOf(arrayOf("a", "30311:$pk:stream", "", "root"), arrayOf("e", marked, "", "reply"), arrayOf("e", unmarked)), + "hi", + "0".repeat(128), + ) + assertEquals(listOf(marked), chat.markedReplyTos()) + assertEquals(listOf(unmarked), chat.unmarkedReplyTos()) + } + + @Test + fun aPresencePointsAtItsRoomWithTheRootMarker() { + val room = Address(30313, pk, "room") + val tags = tagArray { roomMeeting(MeetingSpaceTag(room, null)) } + assertEquals(listOf("a", room.toValue(), "", "root"), tags.single().toList()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip58Badges/ProfileBadgeDefinitionsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip58Badges/ProfileBadgeDefinitionsTest.kt new file mode 100644 index 0000000000..5064b4d0a9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip58Badges/ProfileBadgeDefinitionsTest.kt @@ -0,0 +1,43 @@ +/* + * 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.nip58Badges + +import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent +import kotlin.test.Test +import kotlin.test.assertEquals + +class ProfileBadgeDefinitionsTest { + @Test + fun badgeSetPointersAreNotBadgeDefinitions() { + val pk = "1".repeat(64) + val definition = "30009:$pk:early-adopter" + val profile = + ProfileBadgesEvent( + "0".repeat(64), + pk, + 1, + arrayOf(arrayOf("d", "profile_badges"), arrayOf("a", definition), arrayOf("e", "e".repeat(64)), arrayOf("a", "30008:$pk:favorites")), + "", + "0".repeat(128), + ) + assertEquals(listOf(definition), profile.badgeAwardDefinitions().map { it.toValue() }) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEventTest.kt new file mode 100644 index 0000000000..84e76de554 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEventTest.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.nip71Video.views + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent +import com.vitorpamplona.quartz.nip71Video.views.tags.LoopsTag +import com.vitorpamplona.quartz.nip71Video.views.tags.PhaseTag +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewPhase +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewSource +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewedRange +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewedTag +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull + +/** + * Kind 22236 against the shape divine-mobile publishes (`mobile/lib/services/view_event_publisher.dart` + * and the example in `mobile/docs/NOSTR_VIDEO_EVENTS.md`). + */ +class VideoViewEventTest { + private val videoAuthor = "4d7dccc0a5116daa057348ef79c573873cddd9eff066fc6a5f3d37e8264afbeb" + private val videoD = "c855df3d07ba963e9097d5a151b0c14a0a3d494e4ff9b04a389bc0b5f5c16862" + private val videoId = "fa5a793b24edfe109f8d02ad6aa6cde77b0a923566f891df403049721b46e8d9" + + private val video = + """{"id":"$videoId","pubkey":"$videoAuthor","created_at":1789661586,"kind":34236,"tags":[["d","$videoD"],["imeta","url https://media.divine.video/$videoD","m video/mp4"],["title","Xmas Already??"]],"content":"","sig":""}""" + + // An `end` segment exactly as divine-mobile writes it, client tag included. + private val endView = + """{"id":"2b1b0b53d6c0c2f1f5a8a2d5f0e7e1f8c9a4b3d2e1f0a9b8c7d6e5f4a3b2c1d0","pubkey":"34257350449d357c37e93eb8aef387ff1fee8879d794da664462346a4b540aa8","created_at":1789666900,"kind":22236,"tags":[["a","34236:$videoAuthor:$videoD","wss://relay.divine.video"],["e","$videoId","wss://relay.divine.video"],["phase","end"],["viewed","0","12"],["source","discovery:foryou"],["loops","2.0"],["version","1.0.23"],["client","Divine","31990:d95aa8fc0eff8e488952495b8064991d27fb96ed8652f12cdedc5a4e8b5ae540:divine-mobile","wss://relay.divine.video"]],"content":"","sig":""}""" + + // The pre-phase single-shot shape: no `phase`, and `viewed` carries the whole session. + private val legacyView = + """{"id":"3c2c1c64e7d1d3f2f6b9b3e6f1f8f2f9dab5c4e3f2f1bac9d8e7f6f5b4c3d2e1","pubkey":"34257350449d357c37e93eb8aef387ff1fee8879d794da664462346a4b540aa8","created_at":1789666900,"kind":22236,"tags":[["a","34236:$videoAuthor:$videoD","wss://relay.divine.video"],["e","$videoId","wss://relay.divine.video"],["viewed","0","5"],["loops","0.75"],["source","search","cats"]],"content":"","sig":""}""" + + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.divine.video")!! + + @Test + fun parsesAnEndSegment() { + val event = assertIs(Event.fromJson(endView)) + + assertEquals("34236:$videoAuthor:$videoD", event.video()?.toValue()) + assertEquals(videoId, event.videoVersion()) + assertEquals(ViewPhase.END, event.phase()) + assertEquals(ViewedRange(0, 12), event.viewed()) + assertEquals(12, event.viewed()?.seconds) + assertEquals(2.0, event.loops()) + // The tab rides inside the type; category is how a reader groups every discovery tab. + assertEquals(ViewSource("discovery:foryou"), event.source()) + assertEquals(ViewSource.DISCOVERY, event.source()?.category) + assertEquals(listOf("34236:$videoAuthor:$videoD"), event.linkedAddressIds()) + assertEquals(listOf(videoId), event.linkedEventIds()) + } + + @Test + fun parsesALegacySingleShot() { + val event = assertIs(Event.fromJson(legacyView)) + + assertNull(event.phase()) + assertEquals(ViewedRange(0, 5), event.viewed()) + assertEquals(0.75, event.loops()) + assertEquals(ViewSource(ViewSource.SEARCH, "cats"), event.source()) + assertEquals(ViewSource.SEARCH, event.source()?.category) + } + + @Test + fun rejectsMalformedValues() { + assertNull(PhaseTag.parse(arrayOf("phase", "middle"))) + assertNull(ViewedTag.parse(arrayOf("viewed", "10", "5"))) + assertNull(ViewedTag.parse(arrayOf("viewed", "0"))) + assertNull(LoopsTag.parse(arrayOf("loops", "-1"))) + assertNull(LoopsTag.parse(arrayOf("loops", "NaN"))) + } + + @Test + fun endRefusesWhatTheParserWouldDrop() { + val bundle = EventHintBundle(Event.fromJson(video) as AddressableShortVideoEvent, relay) + + assertFailsWith { VideoViewEvent.buildEnd(bundle, watchedSeconds = -1) } + + // Not a playthrough count: left out, as divine-mobile does, instead of signed and ignored. + for (loops in listOf(0.0, -1.0, Double.NaN, Double.POSITIVE_INFINITY)) { + val template = VideoViewEvent.buildEnd(bundle, watchedSeconds = 3, loops = loops) + assertNull(template.tags.firstOrNull { it[0] == LoopsTag.TAG_NAME }, "loops=$loops") + } + } + + @Test + fun startCarriesNoWatchTime() { + val bundle = EventHintBundle(Event.fromJson(video) as AddressableShortVideoEvent, relay) + val template = VideoViewEvent.buildStart(bundle, ViewSource(ViewSource.HOME)) + + assertEquals(VideoViewEvent.KIND, template.kind) + assertEquals("", template.content) + assertEquals( + listOf( + listOf("a", "34236:$videoAuthor:$videoD", "wss://relay.divine.video/"), + listOf("e", videoId, "wss://relay.divine.video/", videoAuthor), + listOf("phase", "start"), + listOf("source", "home"), + ), + template.tags.map { it.toList() }, + ) + } + + @Test + fun endCarriesTheSegment() { + val bundle = EventHintBundle(Event.fromJson(video) as AddressableShortVideoEvent, relay) + val template = VideoViewEvent.buildEnd(bundle, watchedSeconds = 12, loops = 2.0, source = ViewSource(ViewSource.PROFILE)) + + assertContentEquals(arrayOf("phase", "end"), template.tags[2]) + assertContentEquals(arrayOf("viewed", "0", "12"), template.tags[3]) + assertContentEquals(arrayOf("loops", "2.0"), template.tags[4]) + assertContentEquals(arrayOf("source", "profile"), template.tags[5]) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip87Ecash/MintEventsAreAddressableTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip87Ecash/MintEventsAreAddressableTest.kt new file mode 100644 index 0000000000..25f8c9ad13 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip87Ecash/MintEventsAreAddressableTest.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.nip87Ecash + +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent +import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent +import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class MintEventsAreAddressableTest { + private val pk = "1".repeat(64) + private val tags = arrayOf(arrayOf("d", "mint-id")) + + @Test + fun theNip87KindsAreAddressable() { + for (event in listOf( + CashuMintEvent("0".repeat(64), pk, 1, tags, "", "0".repeat(128)), + FedimintEvent("0".repeat(64), pk, 1, tags, "", "0".repeat(128)), + MintRecommendationEvent("0".repeat(64), pk, 1, tags, "", "0".repeat(128)), + )) { + assertIs(event) + assertEquals("${event.kind}:$pk:mint-id", event.addressTag()) + } + } + + @Test + fun deletingARecommendationNamesItsAddressOnce() { + val rec = MintRecommendationEvent("0".repeat(64), pk, 1, tags, "", "0".repeat(128)) + val deletion = NostrSignerSync().sign(DeletionRequestEvent.build(listOf(rec))) + assertEquals(listOf("38000:$pk:mint-id"), deletion.tags.filter { it[0] == "a" }.map { it[1] }) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyRootScopeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyRootScopeTest.kt new file mode 100644 index 0000000000..2583e344cd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyRootScopeTest.kt @@ -0,0 +1,87 @@ +/* + * 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.nipA0VoiceMessages + +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class VoiceReplyRootScopeTest { + private val audio = AudioMeta("https://blossom.example/a.m4a", "audio/mp4", "f".repeat(64), 3, listOf(0.1f)) + + @Test + fun aVoiceReplyNamesItsThreadsRootScope() { + val signer = NostrSignerSync() + val voice = VoiceEvent("9".repeat(64), "1".repeat(64), 1, arrayOf(arrayOf("imeta", "url https://blossom.example/v.m4a")), "", "0".repeat(128)) + + val reply = signer.sign(VoiceReplyEvent.build(audio, EventHintBundle(voice))) + assertEquals(voice.id, reply.rootEventId()) + assertEquals(voice.pubKey, reply.rootAuthorKey()) + assertEquals(voice.id, reply.replyingTo()) + + // A reply to that reply keeps the thread's root and names its new parent. + val nested = signer.sign(VoiceReplyEvent.build(audio, EventHintBundle(reply))) + assertEquals(voice.id, nested.rootEventId()) + assertEquals(reply.id, nested.replyingTo()) + } + + @Test + fun aReplyToAnAddressRootedReplyKeepsTheAddressRoot() { + val article = "30023:${"2".repeat(64)}:post" + val parent = + VoiceReplyEvent( + "8".repeat(64), + "3".repeat(64), + 1, + arrayOf(arrayOf("A", article), arrayOf("K", "30023"), arrayOf("P", "2".repeat(64)), arrayOf("e", "7".repeat(64)), arrayOf("k", "1111")), + "", + "0".repeat(128), + ) + val reply = NostrSignerSync().sign(VoiceReplyEvent.build(audio, EventHintBundle(parent))) + assertEquals(listOf(article), reply.tags.filter { it[0] == "A" }.map { it[1] }) + assertEquals(listOf("30023"), reply.tags.filter { it[0] == "K" }.map { it[1] }) + assertTrue(reply.tags.none { it[0] == "E" }) + assertEquals(parent.id, reply.replyingTo()) + } + + @Test + fun aReplyToALegacyReplyFindsTheVoiceMessageRoot() { + // Published before voice replies wrote NIP-22 root tags: only the lowercase parent. + val voiceId = "9".repeat(64) + val voiceAuthor = "1".repeat(64) + val legacy = + VoiceReplyEvent( + "8".repeat(64), + "3".repeat(64), + 1, + arrayOf(arrayOf("e", voiceId, "", voiceAuthor), arrayOf("k", "1222"), arrayOf("p", voiceAuthor)), + "", + "0".repeat(128), + ) + val reply = NostrSignerSync().sign(VoiceReplyEvent.build(audio, EventHintBundle(legacy))) + assertEquals(voiceId, reply.rootEventId()) + assertEquals(voiceAuthor, reply.rootAuthorKey()) + assertEquals(listOf("1222"), reply.tags.filter { it[0] == "K" }.map { it[1] }) + assertEquals(legacy.id, reply.replyingTo()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushNotificationEventsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushNotificationEventsTest.kt new file mode 100644 index 0000000000..58c78bab7c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushNotificationEventsTest.kt @@ -0,0 +1,112 @@ +/* + * 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.nipXXPushNotifications + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipXXPushNotifications.deregistration.PushDeregistrationEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.preferences.PushPreferences +import com.vitorpamplona.quartz.nipXXPushNotifications.preferences.PushPreferencesEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.registration.PushRegistrationEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.registration.PushToken +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The three control kinds of divine-push-service's draft (`docs/nip-xx-push-notifications.md`). + * + * The payloads are checked as the service would decrypt them, not only through our own decoder, + * so a field renamed on this side cannot pass by agreeing with itself. + */ +class PushNotificationEventsTest { + private val user = NostrSignerInternal(KeyPair()) + private val service = NostrSignerInternal(KeyPair()) + private val stranger = NostrSignerInternal(KeyPair()) + private val app = "co.openvine.app" + + private suspend fun plaintextAtService(event: Event) = service.nip44Decrypt(event.content, event.pubKey) + + @Test + fun registration() = + runTest { + val template = PushRegistrationEvent.build(PushToken("fcm-token", -300), service.pubKey, app, user, expiresAt = 1_800_000_000) + val event = assertIs(Event.fromJson(user.sign(template).toJson())) + + assertEquals(service.pubKey, event.pushService()) + assertEquals(app, event.app()) + assertEquals(1_800_000_000, event.expiration()) + assertTrue(event.isContentEncoded()) + assertEquals("""{"token":"fcm-token","timezoneOffsetMinutes":-300}""", plaintextAtService(event)) + + assertEquals(PushToken("fcm-token", -300), event.decrypt(service)) + assertEquals(PushToken("fcm-token", -300), event.decrypt(user)) + } + + @Test + fun deregistrationLeavesTheOffsetOut() = + runTest { + val event = user.sign(PushDeregistrationEvent.build("fcm-token", service.pubKey, app, user, expiresAt = 1_800_000_000)) + + assertEquals(PushDeregistrationEvent.KIND, event.kind) + assertEquals(1_800_000_000, event.expiration()) + assertEquals("""{"token":"fcm-token"}""", plaintextAtService(event)) + assertEquals(PushToken("fcm-token"), event.decrypt(service)) + } + + @Test + fun preferences() = + runTest { + val prefs = PushPreferences(listOf(1, 3, 7, 16, 34236), campaignsEnabled = false) + val event = user.sign(PushPreferencesEvent.build(prefs, service.pubKey, app, user)) + + assertEquals("""{"kinds":[1,3,7,16,34236],"campaignsEnabled":false}""", plaintextAtService(event)) + assertEquals(prefs, event.decrypt(service)) + } + + @Test + fun readsWhatTheServiceWrites() = + runTest { + // A payload from a newer client: an unknown field, and no campaign flag. + val content = user.nip44Encrypt("""{"kinds":[],"quietHours":"22-07"}""", service.pubKey) + val event = user.sign(1_789_000_000, PushPreferencesEvent.KIND, arrayOf(arrayOf("p", service.pubKey), arrayOf("app", app)), content) + + assertEquals(PushPreferences(emptyList(), campaignsEnabled = false), event.decrypt(service)) + } + + @Test + fun onlyTheTwoEndsCanDecrypt() = + runTest { + val event = user.sign(PushRegistrationEvent.build(PushToken("fcm-token"), service.pubKey, app, user)) + + assertTrue(event.canDecrypt(user)) + assertTrue(event.canDecrypt(service)) + assertFalse(event.canDecrypt(stranger)) + assertFailsWith { event.decrypt(stranger) } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/IdShapedTagParsersTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/IdShapedTagParsersTest.kt new file mode 100644 index 0000000000..7065b63899 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/IdShapedTagParsersTest.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.utils + +import com.vitorpamplona.quartz.buzz.amTurnMetrics.tags.AgentTag +import com.vitorpamplona.quartz.buzz.iaIdentityArchival.tags.ConsentTag +import com.vitorpamplona.quartz.buzz.iaIdentityArchival.tags.ReplacedByTag +import com.vitorpamplona.quartz.buzz.moderation.tags.ReportTag +import com.vitorpamplona.quartz.nip64Chess.end.tags.WinnerTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** Tags whose value IS a pubkey or event id must not hand back anything else as one. */ +class IdShapedTagParsersTest { + private val key = "a".repeat(64) + + @Test + fun onlyHexIdsParse() { + for ((name, parse) in listOf) -> String?>>( + ReplacedByTag.TAG_NAME to ReplacedByTag::parse, + AgentTag.TAG_NAME to AgentTag::parse, + ReportTag.TAG_NAME to ReportTag::parse, + WinnerTag.TAG_NAME to WinnerTag::parse, + EditTag.TAG_NAME to EditTag::parse, + )) { + assertEquals(key, parse(arrayOf(name, key)), name) + assertNull(parse(arrayOf(name, "not-a-key")), name) + assertNull(parse(arrayOf(name, "g".repeat(64))), name) + } + assertNull(ConsentTag.parse(arrayOf(ConsentTag.TAG_NAME, "path", "not-a-key"))) + assertEquals(key, ConsentTag.parse(arrayOf(ConsentTag.TAG_NAME, "path", key))?.actorPubKey) + } +} diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.jvm.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.jvm.kt index e620fb99fc..75025e7d9e 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.jvm.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.jvm.kt @@ -56,6 +56,6 @@ actual data class Address actual constructor( actual fun isOfKind( addressId: String, kind: String, - ) = addressId.startsWith(kind) && addressId[kind.length] == ':' + ) = addressId.length > kind.length && addressId.startsWith(kind) && addressId[kind.length] == ':' } } diff --git a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.native.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.native.kt index d8e20aec43..a9a8f77983 100644 --- a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.native.kt +++ b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.native.kt @@ -61,6 +61,6 @@ actual data class Address actual constructor( actual fun isOfKind( addressId: String, kind: String, - ) = addressId.startsWith(kind) && addressId[kind.length] == ':' + ) = addressId.length > kind.length && addressId.startsWith(kind) && addressId[kind.length] == ':' } }