Merge upstream/main into fix/concord-interop

Conflicts: the invite join path was refactored into joinValidatedConcordInvite
(shared with CORD-05 §6 Direct Invites); kept main's creator/label naming and
reapplied the fetched-wrap hand-off and NotSaved check. The Direct Invite
accept screen now reports NotSaved specifically. cli/README keeps main's new
invite rows plus the hidden_banned note on read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-29 15:51:58 -04:00
co-authored by Claude Opus 5.5
147 changed files with 10239 additions and 508 deletions
@@ -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)
},
)
@@ -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()
}
}
}
}
}
@@ -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.
@@ -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
@@ -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<String?>(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
@@ -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<ImagePointer?>(null) }
val banner = remember { mutableStateOf<ImagePointer?>(null) }
val relays = remember { mutableStateListOf<NormalizedRelayUrl>() }
// 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
@@ -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
@@ -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<ConcordInviteListEntry?>(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.
@@ -226,7 +226,7 @@
<string name="git_repo_tab_readme">पठनीय</string>
<string name="git_issue_close">विषय आवृत करें</string>
<string name="git_issue_reopen">पुनःखोलें</string>
<string name="media_servers_blossom_section">ब्लोस्सम॰ सेवासंगणक</string>
<string name="media_servers_blossom_section">ब्लोस्सम सेवासंगणक</string>
<string name="media_servers_blossom_explainer">सेवासंगणक जितना चाहें जोडें। किस संगणक का उपयोग करना है उसका चयन कर सकते हैं चित्र का आरोहण करते समय</string>
<string name="torrent_download">अवरोहण</string>
<!-- %1$s is replaced at runtime by an inline star icon, not text. Keep the placeholder. -->
@@ -276,7 +276,7 @@
<string name="always_on_notif_title">अमेथिस्ट सूचनाएँ सक्रिय</string>
<string name="blossom_sync_cancel">निरस्त</string>
<string name="blossom_sync_channel_description">प्रगति दर्शाता है आपके अभिलेखों की अनुकृति करते समय आपके ब्लोस्सम सेवासंगणकों में।</string>
<string name="blossom_sync_channel_name">ब्लोस्सम॰ समचरणीकरण</string>
<string name="blossom_sync_channel_name">ब्लोस्सम समचरणीकरण</string>
<string name="call_ongoing">आह्वान</string>
<string name="call_ongoing_description">चालू आह्वान सूचना</string>
<string name="call_with">%1$s के साथ आह्वान</string>
+13 -5
View File
@@ -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/
└── …
@@ -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")
@@ -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
|
@@ -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,
@@ -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 <create|list|import|channels|send|read|invite|revoke|join|recover|rekey|roles|role|grant|ban|unban|refound|dissolve>",
"concord <create|list|import|channels|send|read|invite|invites|accept|decline|revoke|join|recover|rekey|roles|role|grant|ban|unban|pins|pin|unpin|refound|dissolve|timer>",
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<NormalizedRelayUrl>,
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<String, Any?> =
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<String>,
): 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<String>,
): 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<String, Any?>) = 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<String>,
): 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 {
@@ -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<String> = emptyList(),
retired: List<String> = emptyList(),
): Map<String, Any?> {
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 <community> <name> <position> 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<String>,
): 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 <community> <user>`. */
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<ControlEdition>,
@@ -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 <community> --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 <community> --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
@@ -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<ChannelPlane>,
val wraps: List<Event>,
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<ControlEdition>,
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<String, Any?> =
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<String>,
): 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<String>,
): Int = write(dataDir, rest, pin = true)
/** `concord unpin COMMUNITY CHANNEL RUMOR_ID` */
suspend fun unpin(
dataDir: DataDir,
rest: Array<String>,
): Int = write(dataDir, rest, pin = false)
private suspend fun write(
dataDir: DataDir,
rest: Array<String>,
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()
}
}
@@ -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<String> = emptyList(),
)
/**
* `~/.amy/<account>/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<StoredInviteInbox>(file.readText()) }.getOrDefault(StoredInviteInbox())
} else {
StoredInviteInbox()
}
fun declined(): Set<String> = 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)))
}
}
@@ -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<HexKey>): 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<Array<String>>,
kind: Int,
createdAt: Long,
timerSecs: Long?,
): Array<Array<String>> = 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<Array<String>> = 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<IMetaTag>,
createdAt: Long,
extraTags: Array<Array<String>> = 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<Array<String>> = 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<Array<String>> = 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<IMetaTag>,
createdAt: Long,
extraTags: Array<Array<String>> = 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<Array<String>> = 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<Array<String>> = 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<NormalizedRelayUrl> {
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,
@@ -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<ControlEdition>,
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<HexKey> = 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<HexKey>,
current: List<ControlEdition>,
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,
@@ -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<VerifiedPin>,
/** Verified entries their author erased (a held kind 5): hidden now, owed an omitting edition. */
val killed: List<VerifiedPin>,
/** [alive] for display, newest message first, with held Edits applied. */
val pins: List<ConcordPinnedMessage>,
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<HexKey, VerifiedPin?>()
/** 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<Event>,
) {
private val deletesByTarget = HashMap<HexKey, MutableList<Event>>()
private val editsByTarget = HashMap<HexKey, MutableList<Event>>()
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<JsonObject> = 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<ControlEdition>,
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<ControlEdition>,
communityIdHex: HexKey,
owner: HexKey,
channelIdHex: HexKey,
floors: Map<String, EntityFloor> = 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<VerifiedPin>()
val killed = ArrayList<VerifiedPin>()
var invalid = 0
val seen = HashSet<HexKey>()
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<ConcordPinnedMessage> { 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<Event>,
planes: Collection<ChannelPlane>,
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<VerifiedPin> = if (!ctx.channelIsPrivate && ctx.pins.sealedForm) emptyList() else ctx.pins.alive
private suspend fun publish(
ctx: ConcordPinContext,
entries: List<JsonObject>,
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<HexKey>,
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)
}
}
@@ -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)
}
@@ -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 —
@@ -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 ->
@@ -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<HexKey> = emptyList(),
retired: List<HexKey> = 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<NormalizedRelayUrl>,
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<List<ConcordDirectInviteView>> =
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<NormalizedRelayUrl> =
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<NormalizedRelayUrl> {
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<HexKey>,
) {
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<HexKey>,
): 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<HexKey>,
): 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
@@ -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,
}
@@ -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
}
@@ -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)
@@ -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 -> {
@@ -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<NormalizedRelayUrl>) -> 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<HexKey, LinkedHashMap<HexKey, Event>>()
private val channelWrapsById = HashMap<HexKey, LinkedHashMap<HexKey, Event>>() // 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<HexKey, HexKey>()
/** Pin-entry verdicts memoized by entry identity (CORD-04 §7 Weight). */
private val pinVerifier = ConcordPinVerifier()
private val guestbookWraps = LinkedHashMap<HexKey, Event>()
private val baseRekeyWraps = LinkedHashMap<HexKey, Event>()
private val siblingRekeyWraps = LinkedHashMap<HexKey, Event>()
@@ -272,6 +301,15 @@ class ConcordCommunitySession(
private val _state = MutableStateFlow<ConcordCommunityState?>(null)
val state: StateFlow<ConcordCommunityState?> = _state
private val _pinHeads = MutableStateFlow<Map<HexKey, ControlEdition>>(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<Map<HexKey, ControlEdition>> = _pinHeads
private val _members = MutableStateFlow<Set<HexKey>>(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<HexKey, ChannelPlane>()
@@ -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<NormalizedRelayUrl> = emptySet(),
) {
val authors = HashSet<HexKey>()
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<HexKey, ExpiredConcordRumor>()
private val _nextExpiry = MutableStateFlow<Long?>(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<Long?> = _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<ExpiredConcordRumor> =
lock.withLock {
if (expiringByWrapId.isEmpty()) return@withLock emptyList()
val out = ArrayList<ExpiredConcordRumor>()
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) }
@@ -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<String> 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<HexKey>()
private val _pending = MutableStateFlow<Map<HexKey, OpenedDirectInvite>>(emptyMap())
/** Parked invites by wrap id, as opened. See [visible] for what a UI should show. */
val pending: StateFlow<Map<HexKey, OpenedDirectInvite>> = _pending.asStateFlow()
private val _declined = MutableStateFlow<Set<HexKey>>(emptySet())
/** Wrap ids the user declined; persisted by the front end so they stay declined across restarts. */
val declined: StateFlow<Set<HexKey>> = _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<HexKey>) {
_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<OpenedDirectInvite>,
joined: List<ConcordCommunityListEntry>,
nowMs: Long = TimeUtils.nowMillis(),
): List<ConcordDirectInviteView> {
val heldById = joined.associateBy { it.id.lowercase() }
val byKey = LinkedHashMap<String, ConcordDirectInviteView>()
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<ConcordDirectInviteView> { it.opened.sentAt }.thenBy { it.wrapId })
}
}
}
@@ -69,6 +69,16 @@ class ConcordSessionManager(
/** Monotonic counter bumped whenever the joined set or any community's fold changes. */
val revision: StateFlow<Int> = _revision
// Declared before `init`: the communities collector may run synchronously on an immediate dispatcher.
private val _nextExpiry = MutableStateFlow<Long?>(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<Long?> = _nextExpiry
private val lock = KmpLock()
private val stateWatchers = HashMap<HexKey, Job>() // 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<HexKey, List<ExpiredConcordRumor>> {
val out = HashMap<HexKey, List<ExpiredConcordRumor>>()
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
@@ -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()
}
}
@@ -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<Event> {
val collected = mutableListOf<Event>()
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<HexKey, Event>()
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()
}
// ============================================================
@@ -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<Preferences>,
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<String>) {
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."
}
}
@@ -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<ControlEdition>()
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<ConcordDirectInviteDraft.Ready>(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)))
}
}
@@ -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)
}
}
@@ -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<PrivateChannelKey> = 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<Event>()
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")
}
}
@@ -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<InviteChannel> = 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<SealEvent>(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<DirectInviteAcceptPlan.CatchUp>(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))
}
}
@@ -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<Event> = mutableListOf(),
): Pair<NewConcordCommunity, ConcordCommunitySession> {
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<Event>()
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<Event>()
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<Event>()
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<ConcordTimerNoticeEvent>(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))
}
}
@@ -79,7 +79,7 @@
<string name="screen_messages_title">सन्देश</string>
<string name="screen_messages_description">आपके रहस्यीकृत सीधे सन्देश यहाँ दिखेंगे।</string>
<string name="screen_notifications_title">सूचनाएँ</string>
<string name="screen_notifications_description">उल्लेख प्रत्युत्तर तथा प्रतिक्रियाएँ यहाँ दिखेंगे।</string>
<string name="screen_notifications_description">उल्लेख प्रतिवचन तथा प्रतिक्रियाएँ यहाँ दिखेंगे।</string>
<!-- Accessibility -->
<string name="accessibility_user_avatar">प्रयोक्ता परिचायकचित्र</string>
<string name="accessibility_navigate">मार्गदर्शन</string>
@@ -327,6 +327,7 @@
<string name="relay_discovery_geohash">स्थान</string>
<string name="calendar_rsvp_going">जाना निश्चित</string>
<string name="calendar_rsvp_maybe">सम्भाव्य</string>
<string name="calendar_rsvp_loading_event">घटना आवहन…</string>
<string name="calendar_rsvp_not_going">नहीं जा सकते</string>
<plurals name="calendar_collection_count">
<item quantity="one">%1$d घटना</item>
@@ -1014,11 +1015,11 @@
<string name="relay_auth_login_as">क्या %1$s के रूप में प्रवेशांकन करें।</string>
<string name="relay_auth_relay_asks">पूछता है आप कौन हैं</string>
<string name="relay_auth_why_send_dm">प्रवेशांकन बिना स्वीकार नहीं करेगा आपका सन्देश %1$s के प्रति।</string>
<string name="relay_auth_why_notify_inbox">%1$s को आपका प्रत्युत्तर प्राप्त नहीं होगा जबतक आप प्रवेशांकन नहीं करते।</string>
<string name="relay_auth_why_notify_inbox">%1$s को आपका प्रतिवचन प्राप्त नहीं होगा जबतक आप प्रवेशांकन नहीं करते।</string>
<string name="relay_auth_why_read_outbox">%1$s से पत्र प्राप्त नहीं होंगे जबतक आप प्रवेशांकन नहीं करते।</string>
<string name="relay_auth_why_post_venue">प्रवेशांकन बिना आपका पत्र स्वीकार नहीं करेगा %1$s में।</string>
<string name="relay_auth_why_read_venue">%1$s प्राप्त नहीं होगा जबतक आप प्रवेशांकन नहीं करते।</string>
<string name="relay_auth_why_my_inbox">आपको भेजे गए प्रत्युत्तर तथा ज्साप तथा सन्देश इससे प्राप्त नहीं होंगे जबतक आप प्रवेशांकन नहीं करते।</string>
<string name="relay_auth_why_my_inbox">आपको भेजे गए प्रतिवचन तथा ज्साप तथा सन्देश इससे प्राप्त नहीं होंगे जबतक आप प्रवेशांकन नहीं करते।</string>
<string name="relay_auth_why_thread">शेष वार्तालाप इससे प्राप्त नहीं होगा जबतक आप प्रवेशांकन नहीं करते।</string>
<string name="relay_auth_why_thread_with">%1$s के साथ शेष वार्तालाप इससे प्राप्त नहीं होगा जबतक आप प्रवेशांकन नहीं करते।</string>
<string name="relay_auth_why_my_own_relay">यह आपके पुनःप्रसारकों में से एक है। तथा सबको प्रवेशांकन करने को कहता है।</string>
@@ -1373,13 +1374,13 @@
<string name="zap_type_nonzap">ज्साप अतिरिक्त</string>
<string name="zap_type_nonzap_explainer">नोस्ट्र में कोई पदचिह्न नहीं, केवल लैटनिंग पर</string>
<string name="post_anonymously">नामरहित</string>
<string name="use_local_blossom_cache">स्थानीय ब्लोस्सम॰ द्रुतस्मृति का प्रयोग करें</string>
<string name="use_local_blossom_cache_caption">जब एक ब्लोस्सम॰ द्रुतस्मृति चल रही है इस यन्त्र पर (संयोजनद्वार २४२४२) तब चित्र चलचित्र अवरोहण उसके द्वारा करें।</string>
<string name="use_local_blossom_cache">स्थानीय ब्लोस्सम द्रुतस्मृति का प्रयोग करें</string>
<string name="use_local_blossom_cache_caption">जब एक ब्लोस्सम द्रुतस्मृति चल रही है इस यन्त्र पर (संयोजनद्वार २४२४२) तब चित्र चलचित्र अवरोहण उसके द्वारा करें।</string>
<string name="local_blossom_cache_detected">स्थानीय द्रुतस्मृति का पता चला संयोजनद्वार २४२४२ पर।</string>
<string name="local_blossom_cache_not_detected">स्थानीय द्रुतस्मृति का पता नहीं चला संयोजनद्वार २४२४२ पर।</string>
<string name="local_blossom_cache_profile_pics_only">केवल परिचय चित्रों को द्रुतस्मृति में रखें</string>
<string name="local_blossom_cache_profile_pics_only_caption">स्थानीय द्रुतस्मृति को केवल परिचय चित्रों तक सीमित रखें। सूचनावली चित्र तथा चलचित्र सीधे मूल सेवासंगणकों से प्राप्त किए जाएँगे।</string>
<string name="no_blossom_server_message">आपका कोई ब्लोस्सम॰ प्रसारसंगणक स्थापित नहीं। आप अमेथिस्ट की सूची का प्रयोग कर सकते हैं अथवा नीचे एक जोड सकते हैं ↓</string>
<string name="no_blossom_server_message">आपका कोई ब्लोस्सम प्रसारसंगणक स्थापित नहीं। आप अमेथिस्ट की सूची का प्रयोग कर सकते हैं अथवा नीचे एक जोड सकते हैं</string>
<string name="media_servers_upload_section">आरोहण व्यवहार</string>
<string name="blossom_mirror_uploads">प्रतिबिम्ब आरोहण</string>
<string name="blossom_mirror_uploads_caption">आरोहण पश्चात अभिलेख की अनुकृति आपके अन्य ब्लोस्सम सेवासंगणकों में करें जिससे वह उपलब्ध रहेगा एक संगणक असंयोजित होने पर भी।</string>
@@ -1406,14 +1407,14 @@
<string name="blossom_more_actions">अधिक क्रियाएँ</string>
<string name="blossom_import_menu">अभिलेख आयात…</string>
<string name="blossom_import_title">अभिलेख आयात</string>
<string name="blossom_import_intro">अन्य ब्लोस्सम॰ सेवासंगणकों की जाँच करें अभिलेखों के लिए जिनका आरोहण आपने अन्यत्र किए तथा उन्हें अपने स्वयम के सेवासंगणकों में अनुकृति करके रखें।</string>
<string name="blossom_import_intro">अन्य ब्लोस्सम सेवासंगणकों की जाँच करें अभिलेखों के लिए जिनका आरोहण आपने अन्यत्र किए तथा उन्हें अपने स्वयम के सेवासंगणकों में अनुकृति करके रखें।</string>
<string name="blossom_import_sources_section">सेवासंगणक जाँच करने के लिए</string>
<string name="blossom_import_add_url_label">अथवा सेवासंगणक पता चिपकाएँ</string>
<string name="blossom_import_scan">सेवासंगणक जाँच</string>
<string name="blossom_import_scanning">जाँच चालू…</string>
<string name="blossom_import_source_failed">यह सेवासंगणक अभिगम्य नहीं</string>
<string name="blossom_import_none_found">कोई नए अभिलेख प्राप्त नहीं चयनित सेवासंगणकों पर।</string>
<string name="blossom_import_no_targets">अपने ब्लोस्सम॰ सेवासंगणकों को पहले जोडें। जिससे कि कोई स्थान हो आयातित अभिलेखों की अनुकृति करके रखने के लिए।</string>
<string name="blossom_import_no_targets">अपने ब्लोस्सम सेवासंगणकों को पहले जोडें। जिससे कि कोई स्थान हो आयातित अभिलेखों की अनुकृति करके रखने के लिए।</string>
<string name="blossom_import_manage_servers">मेरे सेवासंगणकों का प्रबन्धन</string>
<string name="use_default_servers">मूलविकल्प सूची का प्रयोग करें</string>
<string name="add_media_server">श्रव्यदृश्याभिलेख सेवासंगणक जोडें</string>
@@ -1794,7 +1795,7 @@
<string name="relay_group_browse_relay_label">पुनःप्रसारक पता</string>
<string name="relay_group_browse_go">वीक्षण</string>
<string name="relay_tor_clearnet_title">यह पुनःप्रसारक अभिगम्य नहीं टोर॰ पर</string>
<string name="relay_tor_clearnet_body">%1$s से कोई प्रत्युत्तर नहीं टोर॰ द्वारा। इसका जालनिवास सम्भाव्यतः टोर॰ निर्गमन जालबिन्दुओं को अवरुद्ध कर रहा है। क्या इसके स्थान पर स्पष्टजाल पर संयोजन करें।</string>
<string name="relay_tor_clearnet_body">%1$s से कोई प्रतिवचन नहीं टोर॰ द्वारा। इसका जालनिवास सम्भाव्यतः टोर॰ निर्गमन जालबिन्दुओं को अवरुद्ध कर रहा है। क्या इसके स्थान पर स्पष्टजाल पर संयोजन करें।</string>
<string name="relay_tor_clearnet_action">स्पष्टजाल का उपयोग करें</string>
<string name="relay_group_browse_your_relays">पुनःप्रसारक जिन पर आप हैं</string>
<string name="relay_group_browse_popular">लोकप्रिय पुनःप्रसारक</string>
@@ -2138,8 +2139,8 @@
<string name="calendar_collection_description">विवरण</string>
<string name="calendar_collection_invalid">शीर्षक अनिवार्य।</string>
<string name="calendar_collection_empty_members">इस दिनदर्शिका में कोई घटनाएँ नहीं अब तक।</string>
<string name="calendar_rsvp_section">शीघ्रप्रत्युत्तर (%1$d)</string>
<string name="calendar_rsvp_none">कोई शीघ्रप्रत्युत्तर नहीं अब तक।</string>
<string name="calendar_rsvp_section">याचित प्रतिवचन। %1$d</string>
<string name="calendar_rsvp_none">कोई याचित प्रतिवचन नहीं अब तक।</string>
<string name="calendar_participants_section">सहभागी (%1$d)</string>
<string name="calendar_event_in_calendars">दिनदर्शिकाओं में (%1$d)</string>
<string name="calendar_event_in_no_calendars">किसी भी दिनदर्शिका का भाग नहीं अब तक।</string>
@@ -2307,7 +2308,7 @@
<string name="buzz_forum_body_label">आप किस विषय पर चर्चा करना चाहते हैं।</string>
<string name="buzz_forum_post_action">विषय प्रकाशन</string>
<string name="buzz_forum_thread_title">मंच सूत्र</string>
<string name="buzz_forum_no_replies">कोई प्रत्युत्तर नहीं अब तक। सबसे पहले उत्तर देनेवाले बनें।</string>
<string name="buzz_forum_no_replies">कोई प्रतिवचन नहीं अब तक। सबसे पहले उत्तर देनेवाले बनें।</string>
<string name="buzz_dm_title">सीधे सन्देश</string>
<string name="buzz_dm_new">नया सन्देश</string>
<string name="buzz_dm_empty_title">कोई सीधेसन्देश नहीं अब तक</string>
@@ -2883,7 +2884,7 @@
<string name="relay_join_request">पुनःप्रसारक से जुडने का अनुरोध</string>
<string name="relay_leave_request">पुनःप्रसारक से चले जाने का अनुरोध</string>
<string name="compose_signature_setting_title">हस्ताक्षर</string>
<string name="compose_signature_setting_description">सन्देश के अन्त में जोडा जाएगा नए पत्र प्रत्युत्तर उद्धरण अथवा निबन्ध खोलते समय। रिक्त छोड दें अक्षम करने के लिए।</string>
<string name="compose_signature_setting_description">सन्देश के अन्त में जोडा जाएगा नए पत्र प्रतिवचन उद्धरण अथवा निबन्ध खोलते समय। रिक्त छोड दें अक्षम करने के लिए।</string>
<string name="compose_signature_setting_hint">आपका हस्ताक्षर</string>
<string name="pow_difficulty_title">कठिनाई</string>
<string name="pow_difficulty_explainer">एक निप॰१३ श्रमप्रमाण का खनन करता है आपके पत्रों के लिए प्रकाशन पूर्व जिससे पुनःप्रसारक तथा पढनेवाले उसका तोलन कर सके कचरालेख के प्रति। उच्चतर मूल्यों के लिए घातांकीयतः अधिक समय लगेगा। पत्र प्रकाशित होंगे अदृश्यतः खनन पश्चात।</string>
@@ -3153,7 +3154,7 @@
<item quantity="other">%1$d छलनियाँ आरोपित नहीं अब तक</item>
</plurals>
<string name="add">जोडें</string>
<string name="add_a_blossom_server">ब्लोस्सम॰ प्रसारसंगणक जोडें</string>
<string name="add_a_blossom_server">ब्लोस्सम प्रसारसंगणक जोडें</string>
<string name="add_a_nip96_server">निप॰-९६ सेवासंगणक जोडें</string>
<string name="add_client_tag_explainer">जब सक्षम अमेथिस्ट निप॰८९ ग्राहक सूचक जोडेगा आपके द्वारा प्रकाशित घटनाओं में।</string>
<string name="add_client_tag_title">मेरे घटनाओं में ग्राहक सूचक जोडें</string>
@@ -3424,6 +3425,13 @@
<string name="calendar_reminder_settings_title">दिनदर्शिका अनुस्मारक</string>
<string name="calendar_view_day">दिन</string>
<string name="calendar_view_feed">सूचनावली</string>
<string name="calendar_view_follows_going">मित्र जो जा रहे हैं</string>
<string name="calendar_follows_going_empty_title">कोई योजना नहीं अब तक</string>
<string name="calendar_follows_going_empty_subtitle">जब इस सूची में लोग जाना निश्चित प्रतिवचन देते हैं एक आगामी घटना के लिए तब वह यहाँ प्रकट होता है।</string>
<plurals name="calendar_follows_going_count">
<item quantity="one">%1$d जाना निश्चित</item>
<item quantity="other">%1$d जाना निश्चित</item>
</plurals>
<string name="calendar_view_month">मास</string>
<string name="calendar_view_week">सप्ताह</string>
<string name="call_accept">स्वीकार</string>
@@ -3570,7 +3578,7 @@
<string name="concord_create_title">नयी कोंकोर्ड प्रणाली</string>
<string name="concord_edit_title">समुदाय सम्पादन</string>
<string name="concord_editing_banner">सन्देश सम्पादन</string>
<string name="concord_home_title">कोंकोर्ड॰ प्रणाली</string>
<string name="concord_home_title">कोंकोर्ड प्रणाली</string>
<string name="concord_invite_action">लोगों को आमन्त्रण</string>
<string name="concord_invite_failed">आमन्त्रण अप्राप्त। सम्भाव्यतः योजक निष्क्रिय है अथवा इसके पुनःप्रसारक अनभिगम्य।</string>
<string name="concord_invite_failed_banned">इस समुदाय ने आपको हटा दिया। योजक अब भी कार्य करता है पर इसके सदस्यसूची में आप स्वीकृत नहीं।</string>
@@ -3852,7 +3860,7 @@
<string name="home_content_type_birds">पक्षी अवलोकन घटनाएँ</string>
<string name="home_content_type_chess">चतुरंग खेल</string>
<string name="home_content_type_classifieds">वर्गीकृत विज्ञापन</string>
<string name="home_content_type_comments">टिप्पणियाँ तथा प्रत्युत्तर</string>
<string name="home_content_type_comments">टिप्पणियाँ तथा प्रतिवचन</string>
<string name="home_content_type_ephemeral_chat">अस्थायी चर्चाएँ</string>
<string name="home_content_type_fundraisers">धनसंग्रहण</string>
<string name="home_content_type_highlights">प्रमुखताएँ</string>
@@ -3864,6 +3872,7 @@
<string name="home_content_type_podcasts">पुटप्रसार</string>
<string name="home_content_type_polls">मतदान</string>
<string name="home_content_type_ratings">मूल्यांकन तथा समीक्षाएँ</string>
<string name="home_content_type_calendar_rsvps">घटना याचित प्रतिवचन</string>
<string name="home_content_type_reposts">पुनःप्रकाशन</string>
<string name="home_content_type_shorts">लघु चलचित्र</string>
<string name="home_content_type_text_notes">लेखीय टीकाएँ</string>
@@ -3931,7 +3940,7 @@
<string name="kind_app_recommendations">क्रमक अनुशंसाएँ</string>
<string name="kind_appointment">समयनियुक्ति</string>
<string name="kind_apps">क्रमक</string>
<string name="kind_appt_rsvp">समयनियुक्ति प्रत्युत्तर अपेक्षित</string>
<string name="kind_appt_rsvp">समयनियुक्ति याचित प्रतिवचन</string>
<string name="kind_article_curation_set">लेख सूची</string>
<string name="kind_audio_header">ध्वनि शीर्षक</string>
<string name="kind_audio_track">ध्वनि अभिलेख</string>
@@ -3942,8 +3951,8 @@
<string name="kind_blob_headers">द्व्यंकीय अभिलेख शीर्षक</string>
<string name="kind_blocked_relays">बाधित पुनःप्रसारक</string>
<string name="kind_blogs">जाललेख</string>
<string name="kind_blossom_auth">ब्लोस्सम॰ प्रमाणीकरण</string>
<string name="kind_blossom_servers">ब्लोस्सम॰ प्रसारसंगणक</string>
<string name="kind_blossom_auth">ब्लोस्सम प्रमाणीकरण</string>
<string name="kind_blossom_servers">ब्लोस्सम प्रसारसंगणक</string>
<string name="kind_bookmark_list">स्मर्त्तव्य सूची</string>
<string name="kind_bookmark_set">स्मर्त्तव्य सूची</string>
<string name="kind_broadcast_relays">प्रसारण पुनःप्रसारक</string>
@@ -3974,10 +3983,10 @@
<string name="kind_dm_relays">सीधा संदेश पुनःप्रसारक</string>
<string name="kind_drafts">पाण्डुलिपियाँ</string>
<string name="kind_dvm_content_req">डीवीएम॰ विषयवस्तु अनुरोध</string>
<string name="kind_dvm_content_resp">डीवीएम॰ विषयवस्तु प्रत्युत्तर</string>
<string name="kind_dvm_content_resp">डीवीएम॰ विषयवस्तु प्रतिवचन</string>
<string name="kind_dvm_status">डीवीएम॰ स्थिति</string>
<string name="kind_dvm_user_req">डीवीएम॰ प्रयोक्ता अनुरोध</string>
<string name="kind_dvm_user_resp">डीवीएम॰ प्रयोक्ता प्रत्युत्तर</string>
<string name="kind_dvm_user_resp">डीवीएम॰ प्रयोक्ता प्रतिवचन</string>
<string name="kind_edits">सम्पादन</string>
<string name="kind_emoji_pack_list">भावचिह्न पोटली सूची</string>
<string name="kind_emoji_packs">भावचिह्न पोटलियाँ</string>
@@ -4030,7 +4039,7 @@
<string name="kind_nostr_connect">नोस्टर संयोजन</string>
<string name="kind_notes">टीकाएँ</string>
<string name="kind_nwc_request">नोस्टर धनकोष अनुरोध</string>
<string name="kind_nwc_response">नोस्टर धनकोष प्रत्युत्तर</string>
<string name="kind_nwc_response">नोस्टर धनकोष प्रतिवचन</string>
<string name="kind_old_bookmark_list">पुरानी स्मर्त्तव्यचिह्न सूची</string>
<string name="kind_ots">ओटीएस॰</string>
<string name="kind_outbox_relays">निर्गतपेटिका पुनःप्रसारक</string>
@@ -4208,7 +4217,7 @@
<string name="mute_button">ध्वनि सक्रिय। मौन करने के लिए टाँकें</string>
<string name="mute_notifications">सूचनाएँ मौन करें</string>
<string name="muted_button">मौन किया गया। अमौन करने के लिए टाँकें</string>
<string name="my_blossom_data">मेरे ब्लोस्सम॰ अभिलेख</string>
<string name="my_blossom_data">मेरे ब्लोस्सम अभिलेख</string>
<string name="my_fitness">मेरा स्वास्थ्य</string>
<string name="my_lists">मेरे सूचियाँ</string>
<string name="name_is_required">नाम अनिवार्य</string>
@@ -4225,7 +4234,7 @@
<string name="napplet_cap_relay">पुनःप्रसारक</string>
<string name="napplet_cap_relay_desc">पढें हस्ताक्षर करें तथा प्रकाशित करें आपके घटनाओं को</string>
<string name="napplet_cap_resource">जाल</string>
<string name="napplet_cap_resource_desc">जाल तथा ब्लोस्सम॰ संसाधन ले आएँ</string>
<string name="napplet_cap_resource_desc">जाल तथा ब्लोस्सम संसाधन ले आएँ</string>
<string name="napplet_cap_signer">रहस्यीकरण</string>
<string name="napplet_cap_signer_desc">निजी सन्देशों का रहस्यीकरण तथा अरहस्यीकरण करें अपनी कुंचिका से</string>
<string name="napplet_cap_storage">भण्डार</string>
@@ -4478,8 +4487,8 @@
<item quantity="one">%1$d सम्पत्ति समावेशित</item>
<item quantity="other">%1$d सम्पत्ति समावेशित</item>
</plurals>
<string name="no_blossom_apps_found_description">ब्लोस्सम॰ क्रमक प्राप्त नहीं। कृपया एक स्थानीय ब्लोस्सम॰ क्रमक की स्थापना करें इस अभिलेख को देखने के लिए</string>
<string name="no_blossom_apps_found_title">ब्लोस्सम॰ योजक खोला नहीं जा सकता</string>
<string name="no_blossom_apps_found_description">ब्लोस्सम क्रमक प्राप्त नहीं। कृपया एक स्थानीय ब्लोस्सम क्रमक की स्थापना करें इस अभिलेख को देखने के लिए</string>
<string name="no_blossom_apps_found_title">ब्लोस्सम योजक खोला नहीं जा सकता</string>
<string name="no_camera_app_found">कोई चित्रग्राहक क्रमक उपलब्ध नहीं इस यन्त्र में</string>
<string name="no_lightning_address_set">कोई लैटनिंग पता स्थापित नहीं</string>
<string name="no_payment_app_found">कोई क्रमक प्राप्त नहीं जो इस भुगतान को सम्भाल सके। कृपया अनुकूल धनकोष स्थापित करें।</string>
@@ -4637,8 +4646,8 @@
<item quantity="other">%1$d मतदाता आपकी मौनसूची द्वारा अदृश्यकृत।</item>
</plurals>
<plurals name="poll_results_ignored_votes">
<item quantity="one">%1$d प्रत्युत्तर मान्य विकल्प का चयन नहीं किया तथा बहिष्कृत है।</item>
<item quantity="other">%1$d प्रत्युत्तर मान्य विकल्प का चयन नहीं किए तथा बहिष्कृत हैं।</item>
<item quantity="one">%1$d प्रतिवचन मान्य विकल्प का चयन नहीं किया तथा बहिष्कृत है।</item>
<item quantity="other">%1$d प्रतिवचन मान्य विकल्प का चयन नहीं किए तथा बहिष्कृत हैं।</item>
</plurals>
<plurals name="poll_results_late_votes">
<item quantity="one">%1$d मत आया मतदान समाप्त होने के पश्चात तथा बहिष्कृत है।</item>
@@ -4648,8 +4657,8 @@
<item quantity="one">%1$d मतदाता</item>
<item quantity="other">%1$d मतदाता</item>
</plurals>
<string name="poll_results_partial">%2$d में से %1$d प्रत्युत्तरों का आवहन हो गया %3$d पुनःप्रसारकों से।</string>
<string name="poll_results_partial_approx">लगभग %2$d में से %1$d प्रत्युत्तरों का आवहन हो गया %3$d पुनःप्रसारकों से।</string>
<string name="poll_results_partial">%2$d में से %1$d प्रतिवचनों का आवहन हो गया %3$d पुनःप्रसारकों से।</string>
<string name="poll_results_partial_approx">लगभग %2$d में से %1$d प्रतिवचनों का आवहन हो गया %3$d पुनःप्रसारकों से।</string>
<plurals name="poll_results_selections">
<item quantity="one">%1$d चयन</item>
<item quantity="other">%1$d चयन</item>
@@ -4667,7 +4676,7 @@
<item quantity="other">इस पत्र मे %1$d से अधिक विषयसूचक हैं</item>
</plurals>
<string name="pow_category_comments">टिप्पणियाँ</string>
<string name="pow_category_comments_explainer">निबन्ध अभिलेख तथा अन्य विषयवस्तुओं के लिए प्रत्युत्तर</string>
<string name="pow_category_comments_explainer">निबन्ध अभिलेख तथा अन्य विषयवस्तुओं के लिए प्रतिवचन</string>
<string name="pow_category_gift_wraps">निजी सन्देश कोष</string>
<string name="pow_category_gift_wraps_explainer">सीधासन्देश पुनःप्रसारकों को आगतपेटिका कचरालेख छालन करने में सक्षम करता है। केवल बाहरी कोष का खनन होता है। आपका सन्देश कभी नहीं</string>
<string name="pow_category_long_form">दीर्घरूप तथा उद्दीप्तव्य</string>
@@ -4682,10 +4691,10 @@
<string name="pow_category_reports_explainer">पुनःप्रसारक वृत्तान्तों का तोलन करते हैं उनके परिव्यय के अनुसार</string>
<string name="pow_category_reposts">पुनःप्रकाशन</string>
<string name="pow_category_reposts_explainer">उच्च मात्रा सक्रिय प्रयोक्ताओं के लिए</string>
<string name="pow_category_short_notes">लघु टीकाएँ तथा प्रत्युत्तर</string>
<string name="pow_category_short_notes">लघु टीकाएँ तथा प्रतिवचन</string>
<string name="pow_category_short_notes_explainer">प्राथमिक सार्वजनिक कचरालेख रूप</string>
<string name="pow_category_voice">ध्वनि सन्देश</string>
<string name="pow_category_voice_explainer">सार्वजनिक ध्वनि पत्र तथा प्रत्युत्तर</string>
<string name="pow_category_voice_explainer">सार्वजनिक ध्वनि पत्र तथा प्रतिवचन</string>
<plurals name="pow_estimate_days">
<item quantity="one">%1$d दिन</item>
<item quantity="other">%1$d दिन</item>
@@ -4808,7 +4817,7 @@
</plurals>
<string name="relay_auth_never">कभी नहीं</string>
<string name="relay_auth_never_allow_everywhere">कभी नही तथा सभी पुनःप्रसारक</string>
<string name="relay_auth_never_everywhere_body">अमेथिस्ट पुनःप्रसारकों को आप %1$s हैं बताना रोकेगा तथा आपको पूछना भी रोकेगा। कुछ पुनःप्रसारक आपकी सेवा करना रोकेंगे। सन्देश प्रत्युत्तर तथा सूचनाएँ सम्भाव्यतः प्राप्त नहीं होंगे। पुनःप्रसारक जिनपर आपने सर्वदा प्रवेशांकन करने का विकल्प स्थापित किए हैं वैसे ही रहेंगे। आप इसका परिवर्तन कर सकते हैं पुनःप्रसारक प्रवेशांकन के नीचे।</string>
<string name="relay_auth_never_everywhere_body">अमेथिस्ट पुनःप्रसारकों को आप %1$s हैं बताना रोकेगा तथा आपको पूछना भी रोकेगा। कुछ पुनःप्रसारक आपकी सेवा करना रोकेंगे। सन्देश प्रतिवचन तथा सूचनाएँ सम्भाव्यतः प्राप्त नहीं होंगे। पुनःप्रसारक जिनपर आपने सर्वदा प्रवेशांकन करने का विकल्प स्थापित किए हैं वैसे ही रहेंगे। आप इसका परिवर्तन कर सकते हैं पुनःप्रसारक प्रवेशांकन के नीचे।</string>
<string name="relay_auth_never_everywhere_title">क्या किसी भी पुनःप्रसारक में प्रवेशांकन ना करें।</string>
<string name="relay_auth_policy_always">सर्वदा प्रवेशांकन</string>
<string name="relay_auth_policy_always_desc">प्रत्येक पुनःप्रसारक जो पूछता है।</string>
@@ -4818,7 +4827,7 @@
<string name="relay_auth_policy_never_desc">कुछ पुनःप्रसारक आपकी सेवा करना नहीं स्वीकारेंगे।</string>
<string name="relay_auth_purpose_my_inbox">आपकी आगतपेटिका</string>
<string name="relay_auth_purpose_my_own_relay">आपका अपना पुनःप्रसारक</string>
<string name="relay_auth_purpose_notify_inbox">आपके प्रत्युत्तर</string>
<string name="relay_auth_purpose_notify_inbox">आपके प्रतिवचन</string>
<string name="relay_auth_purpose_other">इस पुनःप्रसारक</string>
<string name="relay_auth_purpose_post_venue">एक शाला जिसमें आप पत्र प्रकाशित करते हैं</string>
<string name="relay_auth_purpose_read_outbox">लोग जिन्हें आप पढते हैं</string>
@@ -4834,7 +4843,7 @@
<string name="relay_explain_community_chats">पुनःप्रसारक जिनपर प्रत्येक समुदाय अपने समतलों को प्रकाशित करते हैं।</string>
<string name="relay_explain_direct_messages">आपके सीधासन्देश पुनःप्रसारक। जहाँ उपहारकोषयुक्त सन्देश भेजे जाते हैं।</string>
<string name="relay_explain_encrypted_groups">समूह सन्देश तथा कुंचिकापेटलियाँ। प्रत्येक समूह के पुनःप्रसारकों पर।</string>
<string name="relay_explain_engagement">घटनाओं का अवलोकन करता है जो वर्तमान में प्रदर्शित हो रहे हैं नए प्रत्युत्तर प्रतिक्रियाएँ उद्धरण ज्साप तथा वृत्तान्तों के लिए जिससे गिनतियों का नवीकरण होता है जब आप पढ रहे हैं।</string>
<string name="relay_explain_engagement">घटनाओं का अवलोकन करता है जो वर्तमान में प्रदर्शित हो रहे हैं नए प्रतिवचन प्रतिक्रियाएँ उद्धरण ज्साप तथा वृत्तान्तों के लिए जिससे गिनतियों का नवीकरण होता है जब आप पढ रहे हैं।</string>
<string name="relay_explain_ephemeral_chats">चर्चाशालाएँ जो कोई इतिहास नहीं रखते। सन्देश केवल तब तक रहते हैं जब तक आप संयोजित हैं। इसलिए ये ग्राहकता बनाए रखते हैं कुछ भी प्राप्त होने के लिए।</string>
<string name="relay_explain_follows">अनुचरण सूचियाँ। आपकी सूचनावली तथा आपका विश्वासजाल का निर्माण के लिए उपयुक्त।</string>
<string name="relay_explain_geohash_chats">स्थान आधारित शालाएँ उन क्षेत्रों के लिए जिनका आप अनुगमन करते हैं। पृष्ट उन पुनःप्रसारको से जो इनके जालावास हैं।</string>
@@ -4848,7 +4857,7 @@
<string name="relay_explain_nwc">आपके संयोजित धनकोष से सूचनाएँ।</string>
<string name="relay_explain_profiles">वर्तमानतः पटल पर लोगों के परिचय।</string>
<string name="relay_explain_public_chats">मुख्य पुनःप्रसारक प्रत्येक चर्चा का जिन्हें आप खोल रखे हैं अथवा जिनसे आप जुड चुके हैं।</string>
<string name="relay_explain_referenced">घटनाओं को सूचक अनुसार ले आता है जिसका उल्लेख आपके पटल पर अमुक करता है पर जिसकी प्राप्ती अभी नहीं हुई। एक उद्धरण अथवा एक प्रत्युत्तर का पूर्वपत्र अथवा एक सूत्र का मूल।</string>
<string name="relay_explain_referenced">घटनाओं को सूचक अनुसार ले आता है जिसका उल्लेख आपके पटल पर अमुक करता है पर जिसकी प्राप्ती अभी नहीं हुई। एक उद्धरण अथवा एक प्रतिवचन का पूर्वपत्र अथवा एक सूत्र का मूल।</string>
<string name="relay_explain_relay_groups">निप॰२९ समूह जिनसे आप जुडे हैं। प्रत्येक समूह एक जालावास पुनःप्रसारक में रहता है। इसलिए क्रमक प्रत्येक पुनःप्रसारक से संयोजन करता है जो आपके किसी समूह का जालावास है।</string>
<string name="relay_explain_relay_lists">खोजता है किन पुनःप्रसारकों पर प्रत्येक व्यक्ति प्रकाशन करता है। जिससे कि उनके पत्र सम्यक स्थल से प्राप्प हो।</string>
<string name="relay_explain_wallet">आपके अपने धनकोष घटनाएँ। पुनःपठित उन पुनःप्रसारकों से जिनपर आपने उनके प्रकाशन किए।</string>
@@ -327,6 +327,7 @@
<string name="relay_discovery_geohash">Helyszín</string>
<string name="calendar_rsvp_going">Ott leszek</string>
<string name="calendar_rsvp_maybe">Talán</string>
<string name="calendar_rsvp_loading_event">Esemény betöltése…</string>
<string name="calendar_rsvp_not_going">Nem leszek ott</string>
<plurals name="calendar_collection_count">
<item quantity="one">%1$d esemény</item>
@@ -3424,6 +3425,13 @@
<string name="calendar_reminder_settings_title">Naptár-emlékeztetők</string>
<string name="calendar_view_day">Nap</string>
<string name="calendar_view_feed">Hírfolyam</string>
<string name="calendar_view_follows_going">A barátai részt vesznek rajta</string>
<string name="calendar_follows_going_empty_title">Még nincsenek tervek</string>
<string name="calendar_follows_going_empty_subtitle">Amikor a listán szereplő személyek jelzik, hogy részt vesznek egy közelgő eseményen, az itt jelenik meg.</string>
<plurals name="calendar_follows_going_count">
<item quantity="one">%1$d ember vesz részt rajta</item>
<item quantity="other">%1$d ember vesz részt rajta</item>
</plurals>
<string name="calendar_view_month">Hónap</string>
<string name="calendar_view_week">Hét</string>
<string name="call_accept">Elfogadás</string>
@@ -3864,6 +3872,7 @@
<string name="home_content_type_podcasts">Podcastok</string>
<string name="home_content_type_polls">Szavazások</string>
<string name="home_content_type_ratings">Értékelések és megjegyzések</string>
<string name="home_content_type_calendar_rsvps">Esemény-visszajelzések</string>
<string name="home_content_type_reposts">Továbbosztások</string>
<string name="home_content_type_shorts">Rövid videók</string>
<string name="home_content_type_text_notes">Szöveges jegyzetek</string>
@@ -4126,7 +4135,7 @@
<string name="login_with_a_private_key_to_like_posts">Ö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</string>
<string name="longs">Videók</string>
<string name="m">p</string>
<string name="manage_emoji_packs">Saját emodzsicsomagok</string>
<string name="manage_emoji_packs">Emodzsicsomagok</string>
<string name="marmot_add_photo">Fotó hozzáadása</string>
<string name="marmot_adding_user">%1$s hozzáadása…</string>
<string name="marmot_admin_granted">Adminisztrátori jogosultságok megadva</string>
@@ -4208,9 +4217,9 @@
<string name="mute_button">Hang bekapcsolva. Koppintson a némításhoz</string>
<string name="mute_notifications">Értesítések némítása</string>
<string name="muted_button">Némítva. Koppintson a némitás megszüntetéséhez</string>
<string name="my_blossom_data">Az én Blossom fájljaim</string>
<string name="my_blossom_data">Blossom fájlok</string>
<string name="my_fitness">Fitnesz</string>
<string name="my_lists">Saját listák</string>
<string name="my_lists">Listák</string>
<string name="name_is_required">Név megadása kötelező</string>
<string name="namecoin_response_time">%1$d ms</string>
<string name="namecoin_settings">Namecoin-beállítások</string>
@@ -339,6 +339,7 @@
<string name="relay_discovery_geohash">Lokalizacja</string>
<string name="calendar_rsvp_going">Będę</string>
<string name="calendar_rsvp_maybe">Może</string>
<string name="calendar_rsvp_loading_event">Ładowanie wydarzenia…</string>
<string name="calendar_rsvp_not_going">Nie mogę iść</string>
<plurals name="calendar_collection_count">
<item quantity="one">%1$d wydarzenie</item>
@@ -601,6 +602,7 @@
<string name="concord_edit_banner_hint">Dodaj baner</string>
<string name="concord_edit_relays_desc">Miejsce, w którym publikowane i czytane są zaszyfrowane plany tej społeczności.</string>
<string name="concord_dissolved_read_only">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.</string>
<string name="concord_private_channel_no_key">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.</string>
<string name="concord_create_name">Nazwa</string>
<string name="concord_create_about">Informacje (opcjonalne)</string>
<string name="concord_ban_user">Banuj</string>
@@ -3484,6 +3486,15 @@
<string name="calendar_reminder_settings_title">Przypomnienia z kalendarza</string>
<string name="calendar_view_day">Dzień</string>
<string name="calendar_view_feed">Notatki</string>
<string name="calendar_view_follows_going">Przyjaciele zbierają się</string>
<string name="calendar_follows_going_empty_title">Brak planów</string>
<string name="calendar_follows_going_empty_subtitle">Gdy osoby z tej listy potwierdzą udział w nadchodzącym wydarzeniu, informacja ta pojawi się tutaj.</string>
<plurals name="calendar_follows_going_count">
<item quantity="one">%1$d idzie</item>
<item quantity="few">%1$d idzie</item>
<item quantity="many">%1$d idzie</item>
<item quantity="other">%1$d idą</item>
</plurals>
<string name="calendar_view_month">Miesiąc</string>
<string name="calendar_view_week">Tydzień</string>
<string name="call_accept">Akceptuj</string>
@@ -3960,6 +3971,7 @@
<string name="home_content_type_podcasts">Podcasty</string>
<string name="home_content_type_polls">Ankiety</string>
<string name="home_content_type_ratings">Oceny i recenzje</string>
<string name="home_content_type_calendar_rsvps">Wydarzenie RSVPs</string>
<string name="home_content_type_reposts">Reposty</string>
<string name="home_content_type_shorts">Filmiki</string>
<string name="home_content_type_text_notes">Notatki tekstowe</string>
@@ -5781,6 +5793,7 @@
<string name="browser_pill_perm_allow">Zezwól podczas odwiedzin</string>
<string name="browser_pill_perm_once">Tylko tym razem</string>
<string name="browser_pill_perm_deny">Nie zezwalaj</string>
<string name="browser_pill_dialog_says">%1$s oznajmia</string>
<string name="browser_pill_dialog_generic">Na tej stronie napisano</string>
<string name="browser_pill_dialog_block">Nie pozwól tej stronie wyświetlać więcej dialogów</string>
<string name="browser_pill_dialog_answer">Twoja odpowiedź</string>
@@ -6270,6 +6283,12 @@
<string name="cordn_coordinators_server_silent">Odpowiedział, ale niczego nie nazwał.</string>
<string name="cordn_coordinators_show_all">Pokaż wszystkie %1$d</string>
<string name="cordn_coordinators_show_fewer">Pokaż mniej</string>
<plurals name="cordn_coordinators_show_older">
<item quantity="one">Pokaż %1$d który przestał nadawać komunikaty</item>
<item quantity="few">Pokaż %1$d, które przestały nadawać komunikaty</item>
<item quantity="many">Pokaż %1$d, które przestały nadawać komunikaty</item>
<item quantity="other">Pokaż %1$d, które przestały nadawać komunikaty</item>
</plurals>
<string name="cordn_coordinators_stale_note">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.</string>
<string name="cordn_create_action">Utwórz Grupę</string>
<plurals name="cordn_create_action_invite">
@@ -6347,6 +6366,69 @@
<string name="cordn_delivery_accepted">Zaakceptowane przez koordynatora</string>
<string name="cordn_exposure_close">Zrozumiałe</string>
<string name="cordn_exposure_open">Co ten koordynator może zobaczyć</string>
<string name="cordn_group_info">Informacje o grupie</string>
<string name="cordn_group_unavailable">Ta grupa jest niedostępna. Być może jej koordynator został usunięty.</string>
<string name="cordn_group_unavailable_detail">Dodaj koordynatora w Ustawieniach, aby zacząć korzystać z grup cordn.</string>
<plurals name="cordn_groups_count">
<item quantity="one">%1$d grupa</item>
<item quantity="few">%1$d grup</item>
<item quantity="many">%1$d grup</item>
<item quantity="other">%1$d grupy</item>
</plurals>
<string name="cordn_groups_down_note">Nic nie pojawia się z tych grup. Amethyst nieustannie próbuje.</string>
<string name="cordn_groups_manage">Koordynatorzy</string>
<string name="cordn_groups_none">Brak grup cordn</string>
<string name="cordn_groups_none_detail">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.</string>
<string name="cordn_groups_start">Otwórz grupę</string>
<string name="cordn_info_add_member">Dodaj kogoś</string>
<string name="cordn_info_add_member_none">Nie znaleziono nikogo o tym imieniu.</string>
<string name="cordn_info_add_member_note">Można ich dodać tylko wtedy, gdy opublikowali pakiet kluczy dla tego koordynatora.</string>
<string name="cordn_info_add_member_placeholder">Nazwa, npub lub nazwa@domena</string>
<string name="cordn_info_admin_badge">Administrator</string>
<plurals name="cordn_info_admins_kept">
<item quantity="one">Administrator grupy %1$d pozostaje bez zmian.</item>
<item quantity="few">Administratorów grupy %1$d pozostaje bez zmian.</item>
<item quantity="many">Administratorów grupy %1$d pozostaje bez zmian.</item>
<item quantity="other">Administratorzy grupy %1$d pozostają bez zmian.</item>
</plurals>
<string name="cordn_info_coordinator">Koordynator</string>
<string name="cordn_info_coordinator_key">Klucz koordynatora</string>
<string name="cordn_info_coordinator_nprofile">Link koordynatora</string>
<string name="cordn_info_copy_nprofile">Skopiuj nprofil koordynatora</string>
<string name="cordn_info_edit_details">Edytuj szczegóły</string>
<string name="cordn_info_egalitarian">Ta grupa nie ma administratorów: każdy może dodawać, usuwać i zmieniać nazwę, na stałe.</string>
<string name="cordn_info_epoch">Epoka</string>
<string name="cordn_info_gid">Id grupy</string>
<string name="cordn_info_has_key_package">Można to dodać w tym koordynatorze</string>
<string name="cordn_info_members">Członkowie</string>
<string name="cordn_info_remove_confirm_body">%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.</string>
<string name="cordn_info_remove_confirm_title">Usunąć z grupy?</string>
<string name="cordn_info_remove_member">Usuń</string>
<string name="cordn_info_save">Zapisz</string>
<string name="cordn_info_technical">Szczegóły techniczne</string>
<string name="cordn_invitations_accept">Dołącz</string>
<string name="cordn_invitations_decline">Odrzuć</string>
<string name="cordn_invitations_decline_warning">Odmowa jest ostateczna. Powrót oznacza, że trzeba ponownie otrzymać zaproszenie.</string>
<string name="cordn_invitations_explainer">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.</string>
<string name="cordn_invitations_load_failed">Nie można odczytać zaproszeń.</string>
<plurals name="cordn_invitations_members">
<item quantity="one">%1$d osoba jest już w tej grupie</item>
<item quantity="few">%1$d osób jest już w tej grupie</item>
<item quantity="many">%1$d osób jest już w tej grupie</item>
<item quantity="other">%1$d osoby są już w tej grupie</item>
</plurals>
<string name="cordn_invitations_members_more">+%1$d więcej</string>
<string name="cordn_invitations_no_coordinators">Nie masz jeszcze wyznaczonego koordynatora. Zaproszenia do cordn przychodzą za pośrednictwem koordynatora, więc na razie nie ma gdzie ich szukać.</string>
<string name="cordn_invitations_none">Brak oczekujących zaproszeń.</string>
<string name="cordn_invitations_refresh">Sprawdź ponownie</string>
<string name="cordn_invitations_skipped">Zaproszenie zostało wysłane na inne urządzenie</string>
<string name="cordn_invitations_title">Zaproszenia do Cordn</string>
<string name="cordn_invitations_unreachable">%1$s nie odpowiedział</string>
<string name="cordn_invitations_via">poprzez %1$s</string>
<string name="cordn_keypackages_disclosure">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.</string>
<string name="cordn_keypackages_explainer">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.</string>
<string name="cordn_keypackages_failed">Nie można odczytać pakietów kluczy.</string>
<string name="cordn_keypackages_last_resort_no">Brak pakietu awaryjnego. Gdy skończą się zaproszenia jednorazowego użytku, wysyłanie zaproszeń nie będzie możliwe.</string>
<string name="cordn_keypackages_last_resort_yes">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.</string>
<plurals name="cordn_keypackages_orphans">
<item quantity="one">%1$d pakiet, którego to urządzenie nie może otworzyć</item>
@@ -6357,4 +6439,63 @@
<string name="cordn_keypackages_orphans_body">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ć.</string>
<string name="cordn_keypackages_publish">Opublikuj jeden</string>
<string name="cordn_keypackages_publish_last_resort">Opublikuj ostatnią instancję</string>
<plurals name="cordn_keypackages_summary">
<item quantity="one">%1$d dostępny jednorazowy pakiet.</item>
<item quantity="few">%1$d dostępnych pakietów jednorazowych.</item>
<item quantity="many">%1$d dostępnych pakietów jednorazowych.</item>
<item quantity="other">%1$d dostępne jednorazowe pakiety.</item>
</plurals>
<string name="cordn_keypackages_topup_note">Po opublikowaniu tutaj Amethyst po cichu wymienia pakiety w miarę ich zużywania. Nigdy nie publikuje za Ciebie pierwszego z nich.</string>
<string name="cordn_keypackages_withdraw_all">Wycofaj wszystkie</string>
<string name="cordn_keypackages_withdraw_orphans">Wycofaj te</string>
<string name="cordn_link_request">Poproś o dołączenie do grupy</string>
<string name="cordn_link_request_disclosure">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.</string>
<string name="cordn_link_request_failed">Nie można poprosić o dołączenie.</string>
<string name="cordn_link_request_sent">Zapytano. Jeden z członków grupy musi Cię dodać; zaproszenie pojawi się w sekcji „Zaproszenia cordn”.</string>
<string name="cordn_link_scan">Skanuj</string>
<string name="cordn_media_download_failed">Nie można otworzyć tego pliku.</string>
<string name="cordn_media_no_server">Dla tego konta nie skonfigurowano żadnego serwera multimediów, więc nie ma gdzie umieścić pliku. Wybierz serwer w sekcji „Ustawienia”.</string>
<string name="cordn_media_unreadable">Nie można odczytać tego pliku.</string>
<string name="cordn_media_upload_failed">Nie można wysłać tego pliku.</string>
<plurals name="cordn_member_count">
<item quantity="one">%1$d członek</item>
<item quantity="few">%1$d członków</item>
<item quantity="many">%1$d członków</item>
<item quantity="other">%1$d członkowie</item>
</plurals>
<string name="cordn_message_deleted">Wiadomość usunięta</string>
<string name="cordn_message_details_coordinator">Koordynator</string>
<string name="cordn_message_details_cursor">Kursor</string>
<string name="cordn_message_details_sent_at">Wysłano</string>
<string name="cordn_message_details_title">Szczegóły wiadomości</string>
<string name="cordn_message_edited">edytowano</string>
<string name="cordn_pinned_by">Przypięte przez %1$s</string>
<plurals name="cordn_pinned_count">
<item quantity="one">%1$d przypięta wiadomość w tej grupie</item>
<item quantity="few">%1$d przypiętych wiadomości w tej grupie</item>
<item quantity="many">%1$d przypiętych wiadomości w tej grupie</item>
<item quantity="other">%1$d przypięte wiadomości w tej grupie</item>
</plurals>
<string name="cordn_pinned_next">Następna przypięta wiadomość</string>
<string name="cordn_pinned_previous">Poprzednia przypięta wiadomość</string>
<string name="cordn_pinned_show_all">Pokaż wszystkie przypięte wiadomości</string>
<string name="cordn_pinned_title">Przypięte wiadomości</string>
<string name="cordn_reactions_title">Reakcje</string>
<string name="cordn_requests_accept">Dodaj</string>
<string name="cordn_requests_admin_only">Tylko administrator może wyrazić zgodę. W grupie, w której nie ma administratorów, są to wszyscy członkowie.</string>
<string name="cordn_requests_check">Sprawdzanie żądań</string>
<string name="cordn_requests_decline">Zignoruj</string>
<string name="cordn_requests_failed">Nie można odczytać żądań.</string>
<string name="cordn_requests_none">Nikt nie czeka na dołączenie.</string>
<string name="cordn_requests_title">Prośby o dołączenie</string>
<string name="cordn_send">Wyślij</string>
<string name="cordn_send_failed">Nie udało się wysłać.</string>
<string name="cordn_send_no_session">Koordynator tej grupy nie jest dostępny, więc na razie nie można niczego wysłać.</string>
<string name="cordn_share_copied">Skopiowano</string>
<string name="cordn_share_copy">Kopiuj link</string>
<string name="cordn_share_explainer">Każdy, kto posiada ten link, może poprosić o dołączenie do grupy. Administrator musi jednak zatwierdzić tę prośbę.</string>
<string name="cordn_share_title">Udostępnij tę grupę</string>
<string name="cordn_voice_play">Odtwórz wiadomość głosową</string>
<string name="cordn_voice_record">Nagraj wiadomość głosową</string>
<string name="cordn_voice_stop">Zatrzymaj i wyślij</string>
</resources>
@@ -590,6 +590,14 @@
<string name="concord_invite_revoke_title">Revoke this link?</string>
<string name="concord_invite_revoke_explainer">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.</string>
<string name="concord_invite_revoke_confirm">Revoke</string>
<string name="concord_invite_revoke_privatize_warning">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.</string>
<string name="concord_invite_revoked_privatized">Link revoked. The community is now Private and its keys were rotated.</string>
<string name="concord_invite_revoked_privatize_pending">Link revoked. The community is now Private, but its keys were not rotated: an admin who can ban members needs to rotate them.</string>
<string name="concord_mode_private">Private</string>
<plurals name="concord_mode_public">
<item quantity="one">Public · %1$d live invite link</item>
<item quantity="other">Public · %1$d live invite links</item>
</plurals>
<string name="concord_invite_preview_unknown_name">Community name is only revealed after you join</string>
<string name="concord_invite_preview_explainer">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.</string>
<string name="concord_invite_preview_relays">Relays this invite will contact: %1$s</string>
@@ -598,8 +606,25 @@
<string name="concord_open_channel">Open channel</string>
<string name="concord_edit_banner_hint">Add a banner</string>
<string name="concord_edit_relays_desc">Where this community's encrypted planes are published and read.</string>
<string name="concord_timer_title">Disappearing messages</string>
<string name="concord_timer_desc">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.</string>
<string name="concord_timer_off">Off</string>
<string name="concord_timer_notice_set">%1$s set disappearing messages to %2$s</string>
<string name="concord_timer_notice_off">%1$s turned off disappearing messages</string>
<string name="concord_timer_active">Messages disappear after %1$s</string>
<string name="concord_dissolved_read_only">This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted.</string>
<string name="concord_private_channel_no_key">This is a private channel and you don't hold its key, so you can't read it or post here.</string>
<string name="concord_pinned_title">Pinned messages</string>
<string name="concord_pinned_empty">No pinned messages in this channel yet.</string>
<string name="concord_pinned_unavailable">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.</string>
<string name="concord_pinned_budget">%1$d of %2$d pins · %3$d% of the size budget used</string>
<string name="concord_pinned_open_hint">Tap a pin to jump to it</string>
<string name="concord_pin_failed_title">Pins</string>
<string name="concord_pin_failed_unavailable">The pinned list is sealed under a key you don't hold. Changing it now would drop pins you can't see.</string>
<string name="concord_pin_failed_too_many">This channel already has 25 pins. Unpin one first.</string>
<string name="concord_pin_failed_too_large">The pinned list is out of room. Unpin a message first.</string>
<string name="concord_pin_failed_message">This message's original signature isn't held here, so it can't be proven and pinned.</string>
<string name="concord_pin_failed_generic">You can't change this channel's pins right now.</string>
<string name="concord_create_name">Name</string>
<string name="concord_create_about">About (optional)</string>
<string name="concord_ban_user">Ban</string>
@@ -3618,6 +3643,23 @@
<string name="concord_create_relays">Relays</string>
<string name="concord_create_relays_desc">Relays that store this community's encrypted messages. Leave empty to use your own.</string>
<string name="concord_create_title">New Concord Channel</string>
<string name="concord_direct_invite_accept">Accept</string>
<string name="concord_direct_invite_accept_failed">Could not reach this community. Try again in a moment.</string>
<string name="concord_direct_invite_action">Invite by npub…</string>
<string name="concord_direct_invite_catch_up">New channels for a community you are in: %1$s</string>
<string name="concord_direct_invite_decline">Decline</string>
<string name="concord_direct_invite_explainer">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.</string>
<string name="concord_direct_invite_expired">This invite has expired</string>
<string name="concord_direct_invite_failed">The invite could not be delivered to this person's inbox relays.</string>
<string name="concord_direct_invite_failed_banned">This person is banned from this community.</string>
<string name="concord_direct_invite_failed_loading">This community is still loading. Try again in a moment.</string>
<string name="concord_direct_invite_failed_member">You can't invite people to this community.</string>
<string name="concord_direct_invite_from">Invited by %1$s</string>
<string name="concord_direct_invite_hint">Name, npub or NIP-05</string>
<string name="concord_direct_invite_send">Send invite to %1$s</string>
<string name="concord_direct_invite_sent">Invite sent.</string>
<string name="concord_direct_invite_title">Invite someone directly</string>
<string name="concord_direct_invites_title">Community invites</string>
<string name="concord_edit_title">Edit community</string>
<string name="concord_editing_banner">Editing message</string>
<string name="concord_home_title">Concord Channels</string>
@@ -4110,6 +4152,9 @@
<string name="kind_profile_gallery">Profile Gallery</string>
<string name="kind_proxy_relays">Proxy Relays</string>
<string name="kind_public_message">Public Message</string>
<string name="kind_push_registration">Push Registration</string>
<string name="kind_push_deregistration">Push Deregistration</string>
<string name="kind_push_preferences">Push Preferences</string>
<string name="kind_reactions">Reactions</string>
<string name="kind_relay_auth">Relay Auth</string>
<string name="kind_relay_discovery">Relay Discovery</string>
@@ -4134,6 +4179,7 @@
<string name="kind_video">Video</string>
<string name="kind_video_collaboration">Video Collaboration</string>
<string name="kind_video_list">Video List</string>
<string name="kind_video_views">Video Views</string>
<string name="kind_video_repl">Video (Repl)</string>
<string name="kind_video_subtitles">Video Subtitles</string>
<string name="kind_voice_msg">Voice Msg</string>
@@ -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 =
@@ -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) }
}
}
@@ -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<User?>(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))
}
}
}
}
@@ -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<ConcordChannelPins?> {
val account = accountViewModel.account
return produceState<ConcordChannelPins?>(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<String>() }
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() }
}
@@ -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))
}
}
}
}
@@ -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
@@ -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,
+6 -6
View File
@@ -129,18 +129,18 @@
"Polish"
]
},
{
"user": "summoner001",
"languages": [
"Hungarian"
]
},
{
"user": "rajs19420616",
"languages": [
"Hindi"
]
},
{
"user": "summoner001",
"languages": [
"Hungarian"
]
},
{
"user": "markkks",
"languages": [
@@ -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 |
@@ -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:<destination-pubkey>:<d>' (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", <podcast-author-pubkey>, <role>]'; 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 = <channel_id>:<cursor>. 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:<author>:<i>; 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:<pubkey>:<d>-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<HexKey> 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, "<host>'<id>" 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:<relay-self>:<id> 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:<author>:<value>, hashtags(), geohashes() | NIP-29 group metadata, signed by the relay. The group node IS this event's ADDRESS (39000:<relay-self>:<id>), which is the NIP's own group reference. Subgroup parent and child are on the same relay, so their addresses are exact: 39000:<author>:<value>. 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:<author>:dTag() (groupId() = dTag()), admins() (GroupAdminTag::parse) | NIP-29 group admins. Relay-signed with d = group id, so the group is exactly Address 39000:<author>:<d>. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). |
| 39002 | `GroupMembersEvent` | d (derived) -> GROUP (A); p -> MEMBER (U) | Address 39000:<author>: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:<author>:<d>. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). |
| 39003 | `GroupRolesEvent` | d (derived) -> GROUP (A) | Address 39000:<author>: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:<author>:<d>. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). |
| 39004 | `GroupParticipantsEvent` | d (derived) -> GROUP (A); participant -> PARTICIPANT (U) | Address 39000:<author>: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:<author>:<d>. 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:<author>: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:<author>:<d>. 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 <root-patch-event-id>, 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:<pubkey>:<id>\|<git-url>', '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:<author>:<d>) | 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', <room>, 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:<author>:<role> 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:<challenger>:<gameId>) 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:<challenger>:<gameId>, 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', <auction event id>], 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', <bid id>], ['e', <auction id>]], 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:<author>:<stall_id>), 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', <kind40 id>, 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', <kind42 id>]. 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` | <kind:tag> 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-hex>:<naddr>] finder -> FINDER (U); a[<finder-hex>:<naddr>] cache -> VERIFIED (A) | finder() / verifiedCache() (FinderCacheTag::parseFinder / parseCache) | NIP-CC. The a tag is a non-standard composite '<finder-hex>:<naddr>', 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, <schema-hash>, <tool>]` + `[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. |
@@ -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<HexKey>`, 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, `<tag>_<kind>` (`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<String, Any>? = null, // relation-specific values (a report's type, a rank)
)
interface LinkProvider { fun links(): List<Link> }
```
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.
@@ -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] == ':'
}
// -----------
@@ -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]
}
@@ -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])
}
@@ -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<String>): 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]
}
@@ -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<String>): 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]
}
@@ -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<PrivateChannelKey>) =
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,
)
}
@@ -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<String, RoleEntity>,
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<HexKey, List<HexKey>> = emptyMap(),
) {
/** The aggregate active-set of live public links: every honored registry's link signers (CORD-05 §5). */
val liveInviteLinks: Set<HexKey> 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<HexKey>): 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<HexKey> = 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<HexKey>): Boolean = !isPublic(targets)
/** [creator]'s honored registry (their live link signers), empty when they publish none. */
fun registryOf(creator: HexKey): List<HexKey> = 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<HexKey>): 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<HexKey, List<HexKey>>()
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,
)
}
}
@@ -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<Long> =
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.
@@ -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", "<seconds>"]` 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<Array<String>>,
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
}
}
@@ -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<RoleEntity>(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
}
@@ -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()
}
@@ -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,
)
}
@@ -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<Event>(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<CommunityInvite>(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<CommunityInvite>(rumor.content)?.let { ConcordInviteBundle.bound(it) }
}
): CommunityInvite? = open(wrap, recipientSigner)?.invite
}
@@ -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
* ["<link_signer pubkey hex>", "<link_signer pubkey hex>"]
* ```
*
* 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<HexKey>): 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<HexKey>? {
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<HexKey>,
list: ConcordInviteListDocument?,
communityIdHex: HexKey,
nowSecs: Long,
minted: Collection<HexKey> = emptyList(),
retired: Collection<HexKey> = emptyList(),
): List<HexKey> {
val dead = retired.mapTo(HashSet()) { it.lowercase() }
val live = LinkedHashSet<HexKey>()
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<HexKey>,
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,
)
}
@@ -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<String> =
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<PrivateChannelKey>,
authority: AuthorityResolver,
memberHex: HexKey?,
): List<PrivateChannelKey> {
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<PrivateChannelKey>): List<InviteChannel> = 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<HexKey> {
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()
}
@@ -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<Array<String>> = 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<Array<String>> = 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<Array<String>> = 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
@@ -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<HexKey> = tags.mapNotNull(ETag::parseId)
override fun addressHints(): List<AddressHint> = tags.mapNotNull(ATag::parseAsHint)
// The attested assertion is an `a`; the request it answers (kind 31872) is a `request` tag.
override fun addressHints(): List<AddressHint> = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(RequestTag::parseAsHint)
override fun linkedAddressIds(): List<String> = tags.mapNotNull(ATag::parseAddressId)
override fun linkedAddressIds(): List<String> = tags.mapNotNull(ATag::parseAddressId) + tags.mapNotNull(RequestTag::parseAddressId)
fun status() = tags.status()
@@ -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<String>) = 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>:<d>` 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<String>) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], REQUEST_KIND_STR)
fun isTagged(
tag: Array<String>,
@@ -53,7 +60,7 @@ class RequestTag(
fun isTagged(
tag: Array<String>,
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<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds
fun parse(tag: Array<String>): ApprovedAddressTag? {
fun parse(tag: Array<String>): 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>): 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>): 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<String>): 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])
@@ -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 {
@@ -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()
@@ -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
@@ -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", <address>, <relay>, "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<String>): Address? {
if (tag.size < 4 || tag[0] != "a") return null
if (tag[3] != MarkedETag.MARKER.FORK.code) return null
return Address.parse(tag[1])
}
@@ -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()
}
@@ -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<InteractiveStoryReadingStateEvent>.storyImage(imageUrl: Stri
fun TagArrayBuilder<InteractiveStoryReadingStateEvent>.storyImages(imageUrls: List<String>) = addAll(imageUrls.map { ImageTag.assemble(it) })
fun TagArrayBuilder<InteractiveStoryReadingStateEvent>.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<InteractiveStoryReadingStateEvent>.rootScene(scene: ATag) = addUnique(RootSceneTag.assemble(scene.toTag(), scene.relay))
fun TagArrayBuilder<InteractiveStoryReadingStateEvent>.currentScene(scene: ATag) = addUnique(scene.toATagArray())
@@ -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)
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], NipTextEvent.KIND_STR)
fun isTagged(
tag: Array<String>,
addressId: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == addressId
fun isTagged(
tag: Array<String>,
address: ForkTag,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag()
fun isIn(
tag: Array<String>,
addressIds: Set<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds
fun parse(tag: Array<String>): 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>): 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<String>): 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>): 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<String>): 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)
}
}
@@ -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)
@@ -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),
@@ -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] == ':'
}
}
@@ -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()
@@ -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(
@@ -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)
@@ -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 }
@@ -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<Entity>.pubKeyHints(): List<PubKeyHint> =
}
}.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<Entity>.pubKeys(): List<HexKey> =
mapNotNull { entity ->
when (entity) {
is NProfile -> entity.hex
is NPub -> entity.hex
is NSec -> entity.hex
else -> null
}
}
@@ -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<HexKey> {
val channel = channelId()
return tags.taggedEventIds().filter { it != channel }
}
companion object {
const val KIND = 43
@@ -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<HexKey> = tags.mapValueTagged(TAG_NAME) { it }
fun participants(): List<HexKey> = tags.mapValueTagged(TAG_NAME) { it.takeIf { value -> value.isValid() } }
companion object {
const val KIND = 39004
@@ -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<String> = mapNotNull(ChildTag::parse)
fun TagArray.userPubKeys(): List<HexKey> = mapNotNull(PTag::parseKey)
fun TagArray.deletedEventIds(): List<HexKey> = mapValueTagged("e") { it }
fun TagArray.deletedEventIds(): List<HexKey> = 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<GroupPin> = mapNotNull(GroupPin::parse)
@@ -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.
@@ -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)
}
@@ -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<LiveActivitiesChatMessageEvent> {
activity()?.let { aTag(it) }
@@ -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", <room>, <relay>, "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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
@@ -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<MeetingRoomPresenceEvent>.roomMeeting(rep: MeetingSpaceTag) = addUnique(rep.toTagArray())
// NIP-53: a presence points at its room with the `root` marker.
fun TagArrayBuilder<MeetingRoomPresenceEvent>.roomMeeting(rep: MeetingSpaceTag) = addUnique(rep.toRootTagArray())
fun TagArrayBuilder<MeetingRoomPresenceEvent>.roomMeeting(rep: EventHintBundle<MeetingRoomEvent>) = addUnique(rep.toATag().toATagArray())
fun TagArrayBuilder<MeetingRoomPresenceEvent>.roomMeeting(rep: EventHintBundle<MeetingRoomEvent>) = addUnique(MeetingSpaceTag(rep.event.address(), rep.relay).toRootTagArray())
fun TagArrayBuilder<MeetingRoomPresenceEvent>.handRaised(raised: Boolean) = addUnique(HandRaisedTag.assemble(raised))
@@ -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)
@@ -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 }
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isValid()
fun parse(tag: Array<String>): 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]
}
@@ -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 <T : AddressableVideoEvent> TagArrayBuilder<VideoViewEvent>.video(video: EventHintBundle<T>) = addUnique(video.toATag().toATagArray()).addUnique(video.toETagArray())
fun TagArrayBuilder<VideoViewEvent>.phase(phase: ViewPhase) = addUnique(PhaseTag.assemble(phase))
fun TagArrayBuilder<VideoViewEvent>.viewed(range: ViewedRange) = addUnique(ViewedTag.assemble(range))
fun TagArrayBuilder<VideoViewEvent>.loops(loops: Double) = addUnique(LoopsTag.assemble(loops))
fun TagArrayBuilder<VideoViewEvent>.source(source: ViewSource) = addUnique(SourceTag.assemble(source))

Some files were not shown because too many files have changed in this diff Show More