Merge pull request #4264 from vitorpamplona/claude/hopeful-brown-1suxdw

feat(concord): pins, disappearing messages, Invite Registry and Direct Invites
This commit is contained in:
Vitor Pamplona
2026-09-29 15:44:32 -04:00
committed by GitHub
61 changed files with 6401 additions and 211 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.
+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). |
| `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))
@@ -172,7 +173,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 {
@@ -198,7 +199,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)
}
}
@@ -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,8 +103,13 @@ 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.stateIn
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/** Name of the default Concord community Admin role minted by "Make admin". */
private const val CONCORD_ADMIN_ROLE = "Admin"
@@ -278,7 +296,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
@@ -286,20 +305,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
}
/**
@@ -414,7 +475,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(
@@ -428,12 +490,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
}
@@ -472,24 +537,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.
@@ -497,14 +567,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). */
@@ -559,6 +646,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
@@ -620,15 +739,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}" }
joinConcordCommunity(rejoined, inviteCreator, inviteLabel)
joinConcordCommunity(rejoined, creator, label)
_strandedConcordCommunities.value -= rejoined.id
return ConcordInviteResult.Joined(bundle.communityId)
}
@@ -649,15 +767,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,
)
joinConcordCommunity(entry, inviteCreator, inviteLabel)
joinConcordCommunity(entry, creator, label)
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),
@@ -691,19 +960,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
@@ -733,7 +1004,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
}
@@ -767,7 +1038,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
}
@@ -805,7 +1076,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
}
@@ -853,6 +1124,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
}
@@ -1194,7 +1469,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,
@@ -1204,6 +1489,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
}
@@ -1220,6 +1512,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
@@ -1240,6 +1694,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
@@ -1258,7 +1728,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.
@@ -1288,7 +1758,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)
}
@@ -1731,6 +2204,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
@@ -4023,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
@@ -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))
}
}
@@ -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>
@@ -3617,6 +3642,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>
@@ -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,267 @@
/*
* 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.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)
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))
}
}
}
}
@@ -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,
@@ -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 |
@@ -704,4 +704,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()
}
@@ -93,13 +93,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
@@ -114,12 +119,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)
}
/**
@@ -138,7 +144,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,
@@ -146,7 +152,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
@@ -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(
@@ -101,6 +101,7 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent
import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
@@ -471,6 +472,7 @@ class EventFactory {
when (kind) {
AcceptedBadgeSetEvent.KIND -> AcceptedBadgeSetEvent(id, pubKey, createdAt, tags, content, sig)
ConcordChatEditEvent.KIND -> ConcordChatEditEvent(id, pubKey, createdAt, tags, content, sig)
ConcordTimerNoticeEvent.KIND -> ConcordTimerNoticeEvent(id, pubKey, createdAt, tags, content, sig)
AdvertisedRelayListEvent.KIND -> AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig)
CvmServerAnnouncementEvent.KIND -> CvmServerAnnouncementEvent(id, pubKey, createdAt, tags, content, sig)
CvmToolsListEvent.KIND -> CvmToolsListEvent(id, pubKey, createdAt, tags, content, sig)
@@ -20,11 +20,16 @@
*/
package com.vitorpamplona.quartz.concord.cord03Channels
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -69,4 +74,41 @@ class ConcordDisappearingTest {
val bad = ChannelChat.message(author, channel, 4, "", 10, extraTags = arrayOf(arrayOf("timer", "04")))
assertNull(ConcordDisappearing.noticeTimerSecs(bad), "not a 1740")
}
@Test
fun timerNoticeParsesIntoItsOwnType() {
// A 1740 must land in the store as a typed event: an untyped Event is refused as unsupported.
val notice = ConcordDisappearing.timerNotice(author, channel, 4, 604_800, 10)
val parsed = Event.fromJson(notice.toJson())
assertIs<ConcordTimerNoticeEvent>(parsed)
assertEquals(604_800L, parsed.timerSecs())
}
@Test
fun wrapCarriesTheRumorsExpirationBesideItsRandomP() =
runTest {
val alice = NostrSignerInternal(KeyPair())
val plane = ConcordChannelKeys.publicChannel(ByteArray(32) { 0x5A }, ByteArray(32) { 0x42 }, 0)
val channelIdHex = ByteArray(32) { 0x42 }.toHexKey()
val exp = ConcordDisappearing.expirationFor(9, 1_000, 86_400)
val rumor = ChannelChat.message(alice.pubKey, channelIdHex, 0, "gm", 1_000, extraTags = ConcordDisappearing.withExpiration(emptyArray(), exp))
val wrap = ConcordStreamEnvelope.wrap(rumor, plane, alice, encrypted = true, outerTags = ConcordDisappearing.wrapTagsFor(rumor))
// CORD-08 §2: the same value outside as inside, and the ephemeral `p` is still there.
assertEquals(listOf("p", "expiration"), wrap.tags.map { it[0] })
assertEquals(64, wrap.tags[0][1].length)
assertEquals("87400", wrap.tags[1][1])
assertEquals(87_400L, ConcordDisappearing.expirationOf(ConcordStreamEnvelope.open(wrap, plane).rumor))
// A rumor with no expiration gets a plain wrap: only the `p`.
val plain = ChannelChat.message(alice.pubKey, channelIdHex, 0, "forever", 1_000)
assertEquals(0, ConcordDisappearing.wrapTagsFor(plain).size)
assertEquals(listOf("p"), ConcordStreamEnvelope.wrap(plain, plane, alice, encrypted = true).tags.map { it[0] })
}
@Test
fun presetsNeverOfferLessThanADay() {
assertEquals(0L, ConcordDisappearing.PRESET_SECS.first())
assertTrue(ConcordDisappearing.PRESET_SECS.drop(1).all { it >= ConcordDisappearing.MIN_OFFERED_SECS })
}
}
@@ -141,6 +141,8 @@ class ConcordPinsTest {
val pin = ConcordPins.verify(edited, channelA)!!
assertTrue(pin.edited)
assertEquals("the plan", pin.content)
assertEquals(2_000L, pin.editOrderMs, "the proven Edit's send time, to judge a newer local Edit against")
assertNull(ConcordPins.verify(entry, channelA)!!.editOrderMs)
assertEquals(original.id, pin.rumorId, "the identity stays the original's")
val (_, foreign) = sendAndOpen(ChannelChat.edit(mallory.pubKey, channelA, 3, original.id, "pwned", 3), signer = mallory)
@@ -159,6 +161,9 @@ class ConcordPinsTest {
val sealed = ConcordPins.serializeSealed(listOf(entry), plane.conversationKey, 3)
val opened3 = ConcordPins.read(sealed) { epoch -> plane.conversationKey.takeIf { epoch == 3L } }
assertEquals(listOf(entry), opened3.entries)
assertTrue(ConcordPins.isSealedForm(sealed))
assertFalse(ConcordPins.isSealedForm(ConcordPins.serializePublic(listOf(entry))))
assertFalse(ConcordPins.isSealedForm("not json"))
val noKey = ConcordPins.read(sealed) { null }
assertTrue(noKey.sealedUnavailable, "unreadable is not empty: a writer must not build on it")
assertTrue(noKey.entries.isEmpty())
@@ -20,47 +20,203 @@
*/
package com.vitorpamplona.quartz.concord.cord05Invites
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ConcordDirectInviteTest {
private val owner = NostrSignerInternal(KeyPair())
private val sender = NostrSignerInternal(KeyPair())
private val recipient = NostrSignerInternal(KeyPair())
private val stranger = NostrSignerInternal(KeyPair())
private val invite =
CommunityInvite(
communityId = "11".repeat(32),
owner = "0f".repeat(32),
ownerSalt = "aa".repeat(32),
communityRoot = "bb".repeat(32),
name = "Nostrichs",
private suspend fun community(): NewConcordCommunity = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
private fun inviteFor(
community: NewConcordCommunity,
expiresAt: Long? = null,
relays: List<String> = listOf("wss://relay.example"),
channels: List<InviteChannel> = emptyList(),
) = CommunityInvite(
communityId = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
communityRoot = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
channels = channels,
relays = relays,
name = "Nostrichs",
expiresAt = expiresAt,
)
/** Wraps an arbitrary [seal] to [to] exactly like [ConcordDirectInvite.build] does (ephemeral author, p + k tags). */
private suspend fun wrapSeal(
seal: Event,
to: String,
): GiftWrapEvent {
val eph = NostrSignerInternal(KeyPair())
return eph.sign(
createdAt = seal.createdAt,
kind = GiftWrapEvent.KIND,
tags = arrayOf(arrayOf("p", to), arrayOf("k", "3313")),
content = eph.nip44Encrypt(seal.toJson(), to),
)
}
/** A seal from [sealer] carrying a kind-[kind] rumor that CLAIMS [claimedAuthor]. */
private suspend fun forgedSeal(
sealer: NostrSigner,
claimedAuthor: String,
content: String,
kind: Int = ConcordDirectInvite.KIND,
): SealEvent {
val rumor = RumorAssembler.assembleRumor<Event>(claimedAuthor, 1_700_000_000L, kind, emptyArray(), content)
return SealEvent.create(rumor, recipient.pubKey, sealer, createdAt = 1_700_000_000L)
}
private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite)
@Test
fun directInviteRoundTripsToTheRecipient() =
fun directInviteRoundTripsWithTheVerifiedSender() =
runTest {
val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, invite, createdAt = 1_700_000_000L)
val c = community()
val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c), createdAt = 1_700_000_000L)
// Wrap is a giftwrap tagged for the recipient and indexable by k=3313.
// Wrap is a giftwrap tagged for the recipient and indexable by k=3313, from an ephemeral author.
assertEquals(GiftWrapEvent.KIND, wrap.kind)
assertEquals(recipient.pubKey, wrap.tags.first { it[0] == "p" }[1])
assertEquals("3313", wrap.tags.first { it[0] == "k" }[1])
assertFalse(wrap.pubKey == sender.pubKey)
val parsed = ConcordDirectInvite.parse(wrap, recipient)
assertNotNull(parsed)
assertEquals("Nostrichs", parsed.name)
assertEquals("11".repeat(32), parsed.communityId)
val opened = ConcordDirectInvite.open(wrap, recipient)
assertNotNull(opened)
assertEquals(sender.pubKey, opened.sender)
assertEquals(wrap.id, opened.wrapId)
assertEquals(1_700_000_000L, opened.sentAt)
assertEquals("Nostrichs", opened.invite.name)
assertEquals(c.communityIdHex, opened.invite.communityId)
assertEquals(c.controlPkHex, opened.invite.controlPk)
// The legacy parse keeps working.
assertEquals(c.communityIdHex, ConcordDirectInvite.parse(wrap, recipient)?.communityId)
}
@Test
fun strangersCannotOpenIt() =
runTest {
val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, invite, createdAt = 1L)
assertNull(ConcordDirectInvite.parse(wrap, stranger))
val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(community()), createdAt = 1L)
assertNull(ConcordDirectInvite.open(wrap, stranger))
}
@Test
fun aRumorClaimingSomeoneElseIsRefused() =
runTest {
// The attacker seals (and so is the verified sender) a rumor claiming the owner wrote it.
val c = community()
val spoofed = wrapSeal(forgedSeal(stranger, claimedAuthor = owner.pubKey, content = json(inviteFor(c))), recipient.pubKey)
assertNull(ConcordDirectInvite.open(spoofed, recipient))
// The very same rumor claiming its real sealer opens.
val honest = wrapSeal(forgedSeal(stranger, claimedAuthor = stranger.pubKey, content = json(inviteFor(c))), recipient.pubKey)
assertEquals(stranger.pubKey, ConcordDirectInvite.open(honest, recipient)?.sender)
}
@Test
fun theRumorKindIsTheAuthorityNotTheKTag() =
runTest {
// A k=3313-tagged wrap whose rumor is a kind-14 DM is not an invite.
val c = community()
val dm = wrapSeal(forgedSeal(sender, claimedAuthor = sender.pubKey, content = json(inviteFor(c)), kind = 14), recipient.pubKey)
assertNull(ConcordDirectInvite.open(dm, recipient))
}
@Test
fun wrapCarriesNip40ExpirationMatchingExpiresAt() =
runTest {
val c = community()
val expiresAtMs = 1_800_000_123_456L
val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c, expiresAt = expiresAtMs), createdAt = 1_700_000_000L)
assertEquals(1_800_000_123L, wrap.tags.expiration())
assertFalse(ConcordDirectInvite.isWrapExpired(wrap, nowSecs = 1_800_000_122L))
assertTrue(ConcordDirectInvite.isWrapExpired(wrap, nowSecs = 1_800_000_123L))
// No expires_at, no expiration tag.
val open = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c), createdAt = 1_700_000_000L)
assertNull(open.tags.expiration())
assertFalse(ConcordDirectInvite.isWrapExpired(open, nowSecs = Long.MAX_VALUE))
// An expired bundle still opens (a parked invite renders), but reports itself expired.
val opened = ConcordDirectInvite.open(wrap, recipient)
assertNotNull(opened)
assertTrue(opened.isExpired(nowMs = expiresAtMs + 1))
assertFalse(opened.isExpired(nowMs = expiresAtMs - 1))
}
@Test
fun sealAndWrapAreBackdatedWithinTwoDaysButTheRumorKeepsTheRealTime() =
runTest {
val c = community()
val now = 1_700_000_000L
val outer = mutableListOf<Long>()
repeat(6) {
val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c), createdAt = now)
val seal = wrap.unwrapOrNull(recipient)
assertIs<SealEvent>(seal)
for (t in listOf(wrap.createdAt, seal.createdAt)) {
assertTrue(t <= now, "outer timestamp $t is in the future")
assertTrue(t > now - ConcordDirectInvite.MAX_BACKDATE_SECS, "outer timestamp $t is backdated past two days")
outer += t
}
assertEquals(now, ConcordDirectInvite.open(wrap, recipient)?.sentAt)
}
// Twelve independent draws over a two-day range are not all "now".
assertTrue(outer.any { it < now })
}
@Test
fun theSection1BoundsApply() =
runTest {
val c = community()
val sixRelays = (1..6).map { "wss://r$it.example" }
val bounded = ConcordDirectInvite.open(ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c, relays = sixRelays), createdAt = 1L), recipient)
assertEquals(sixRelays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), bounded?.invite?.relays)
val tooMany = (0..ConcordInviteBundle.MAX_BUNDLE_CHANNELS).map { InviteChannel(id = it.toString(16).padStart(64, '0'), key = "cd".repeat(32), epoch = 0) }
assertNull(ConcordDirectInvite.open(ConcordDirectInvite.build(sender, recipient.pubKey, inviteFor(c, channels = tooMany), createdAt = 1L), recipient))
}
@Test
fun aBundleWhoseOwnerProofFailsIsRefused() =
runTest {
// A real community's id with someone else's owner: the id does not self-certify it.
val c = community()
val forged = inviteFor(c).copy(owner = stranger.pubKey)
assertNull(ConcordDirectInvite.open(ConcordDirectInvite.build(sender, recipient.pubKey, forged, createdAt = 1L), recipient))
}
@Test
fun inboxSinceRewindsByTheBackdateWindow() {
assertNull(ConcordDirectInvite.inboxSince(null))
assertNull(ConcordDirectInvite.inboxSince(100L))
assertEquals(1_700_000_000L - ConcordDirectInvite.MAX_BACKDATE_SECS, ConcordDirectInvite.inboxSince(1_700_000_000L))
}
}
@@ -0,0 +1,273 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord05Invites
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.concord.cord04Roles.ControlFixtures
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** CORD-05 §5: the Invite Registry (`vsk 8`) and the Public/Private mode its aggregate defines. */
class ConcordInviteRegistryTest {
private val owner = "0f".repeat(32)
private val alice = "a1".repeat(32)
private val bob = "b2".repeat(32)
private val troll = "77".repeat(32)
private val inviterRole = "11".repeat(32)
private val link1 = "c1".repeat(32)
private val link2 = "c2".repeat(32)
private val link3 = "c3".repeat(32)
private val cid = ControlFixtures.communityId
private fun registryEid(creator: String) = ConcordInviteRegistry.coordinateHex(cid, creator)
private fun edition(
kind: ControlEntityKind,
eid: String,
content: String,
author: String = owner,
version: Long = 0,
prev: ControlEdition? = null,
) = ControlEdition(kind, eid.hexToByteArray(), version, prev?.hash, null, content, author, "r-$eid-$version-$author", version)
private fun registry(
creator: String,
content: String,
version: Long = 0,
prev: ControlEdition? = null,
at: String = registryEid(creator),
) = edition(ControlEntityKind.INVITE_REGISTRY, at, content, creator, version, prev)
private fun list(vararg signers: String) = ConcordInviteRegistry.encode(signers.toList())
private val inviterRoleEdition =
edition(ControlEntityKind.ROLE, inviterRole, """{"role_id":"$inviterRole","name":"Inviter","position":2,"permissions":"${ConcordPermissions.of(ConcordPermissions.CREATE_INVITE).toWire()}"}""")
private fun grant(
member: String,
vararg roles: String,
version: Long = 0,
prev: ControlEdition? = null,
) = edition(
ControlEntityKind.GRANT,
ControlFixtures.grantEid(member),
"""{"member":"$member","role_ids":[${roles.joinToString(",") { "\"$it\"" }}]}""",
version = version,
prev = prev,
)
@Test
fun theCoordinateIsTheSpecDerivation() {
assertEquals(
ConcordKeyDerivation.inviteLinksCoordinate(cid, alice.hexToByteArray()).toHexKey(),
registryEid(alice),
)
}
@Test
fun theBuilderWritesAnEditionAtTheCreatorsCoordinateWithLocatorsOnly() {
val rumor = ConcordInviteRegistry.rumor(alice, cid, listOf(link2, link1.uppercase(), link1, "not-a-key"), version = 3, prevHash = ByteArray(32) { 9 }, createdAt = 1_700_000_000)
val parsed = assertNotNull(ControlEdition.fromRumor(rumor))
assertEquals(ControlEntityKind.INVITE_REGISTRY, parsed.entityKind)
assertEquals("8", parsed.vsk)
assertEquals(registryEid(alice), parsed.entityIdHex)
assertEquals(3, parsed.version)
assertEquals(alice, parsed.author)
// Lowercase, de-duplicated, sorted, junk dropped: a bare array of link-signer pubkeys.
assertEquals("""["$link1","$link2"]""", parsed.content)
}
@Test
fun theOwnersRegistryMakesTheCommunityPublic() {
val state = ControlFixtures.fold(listOf(registry(owner, list(link1, link2))), owner)
assertEquals(mapOf(owner to listOf(link1, link2)), state.inviteRegistries)
assertEquals(setOf(link1, link2), state.liveInviteLinks)
assertTrue(state.isPublic)
}
@Test
fun noRegistryOrAnEmptyOneIsPrivate() {
assertFalse(ControlFixtures.fold(emptyList(), owner).isPublic)
val emptied = ControlFixtures.fold(listOf(registry(owner, "[]")), owner)
assertFalse(emptied.isPublic)
assertEquals(emptyList(), emptied.registryOf(owner))
}
@Test
fun aCreateInviteHolderIsHonoredAndAnUnauthorizedAuthorIsNot() {
val editions =
listOf(
inviterRoleEdition,
grant(alice, inviterRole),
registry(alice, list(link1)),
registry(troll, list(link2)),
)
val state = ControlFixtures.fold(editions, owner)
assertEquals(listOf(link1), state.registryOf(alice))
assertEquals(emptyList(), state.registryOf(troll), "no CREATE_INVITE, no registry")
assertEquals(setOf(link1), state.liveInviteLinks)
}
@Test
fun aRegistryAtAnotherCreatorsCoordinateIsIgnored() {
// Alice holds CREATE_INVITE but writes into Bob's coordinate: the coordinate binds to the author.
val editions =
listOf(
inviterRoleEdition,
grant(alice, inviterRole),
registry(alice, list(link1), at = registryEid(bob)),
)
val state = ControlFixtures.fold(editions, owner)
assertTrue(state.inviteRegistries.isEmpty())
assertFalse(state.isPublic)
}
@Test
fun malformedContentFallsBackToThePreviousEditionAndJunkEntriesAreDropped() {
val v0 = registry(owner, list(link1))
val broken = registry(owner, """{"links":["$link2"]}""", version = 1, prev = v0)
assertEquals(listOf(link1), ControlFixtures.fold(listOf(v0, broken), owner).registryOf(owner), "not an array: the head stays at v0")
val notJson = registry(owner, "[$link2", version = 1, prev = v0)
assertEquals(listOf(link1), ControlFixtures.fold(listOf(v0, notJson), owner).registryOf(owner))
// An array is well-formed whatever it holds; only 64-hex string entries survive, lowercased.
val mixed = registry(owner, """["$link2", 42, null, "abc", "${link3.uppercase()}", "$link2", ["$link1"]]""", version = 1, prev = v0)
assertEquals(listOf(link2, link3), ControlFixtures.fold(listOf(v0, mixed), owner).registryOf(owner))
assertNull(ConcordInviteRegistry.decodeOrNull("nope"))
assertNull(ConcordInviteRegistry.decodeOrNull("""{"a":1}"""))
}
@Test
fun theAggregateSpansCreatorsAndDrivesThePublicHelpers() {
val editions =
listOf(
inviterRoleEdition,
grant(alice, inviterRole),
grant(bob, inviterRole),
registry(owner, list(link1)),
registry(alice, list(link2)),
registry(bob, "[]"),
)
val state = ControlFixtures.fold(editions, owner)
assertEquals(setOf(link1, link2), state.liveInviteLinks)
assertTrue(state.isPublic)
// Banning alice leaves the owner's link: still Public. Banning her with the owner gone would not.
assertTrue(state.isPublic(listOf(alice)))
assertFalse(state.isPublic(listOf(alice, owner)))
// Foreign links are anyone's but the viewer's (and the excluded).
assertTrue(state.hasForeignLiveLinks(owner))
assertFalse(state.hasForeignLiveLinks(owner, listOf(alice)))
assertFalse(state.hasForeignLiveLinks(bob, listOf(alice, owner)))
// Retiring one of two live links keeps it Public; retiring both flips it Private.
assertFalse(state.retiringWouldPrivatize(listOf(link1)))
assertTrue(state.retiringWouldPrivatize(listOf(link1, link2)))
assertFalse(ControlFixtures.fold(emptyList(), owner).retiringWouldPrivatize(listOf(link1)), "already Private: nothing flips")
}
@Test
fun aCreatorWhoLosesCreateInviteDropsOut() {
val g0 = grant(alice, inviterRole)
val before = listOf(inviterRoleEdition, g0, registry(alice, list(link1)))
assertTrue(ControlFixtures.fold(before, owner).isPublic)
// The owner strips alice's roles: her registry is no longer honored, the link no longer counts.
val g1 = grant(alice, version = 1, prev = g0)
val after = ControlFixtures.fold(before + g1, owner)
assertEquals(emptyList(), after.registryOf(alice))
assertFalse(after.isPublic)
}
@Test
fun aBannedCreatorDropsOut() {
val editions =
listOf(
inviterRoleEdition,
grant(alice, inviterRole),
registry(alice, list(link1)),
edition(ControlEntityKind.BANLIST, ControlFixtures.banlistEid(), """["$alice"]"""),
)
assertFalse(ControlFixtures.fold(editions, owner).isPublic)
}
@Test
fun aBanRefoundsOnlyWhenTheCommunityIsPrivateWithoutTheTargetsLinks() {
val editions = listOf(inviterRoleEdition, grant(alice, inviterRole), registry(alice, list(link1)))
val state = ControlFixtures.fold(editions, owner)
assertFalse(state.banRequiresRefounding(listOf(bob)), "Public: the Banlist alone")
assertTrue(state.banRequiresRefounding(listOf(alice)), "banning the only link creator leaves it Private")
assertTrue(ControlFixtures.fold(emptyList(), owner).banRequiresRefounding(listOf(bob)), "Private: a ban Refounds")
}
private fun entry(
token: String,
signer: KeyPair,
community: String = ControlFixtures.COMMUNITY_ID_HEX,
expiresAt: Long? = null,
) = ConcordInviteListEntry(token = token, signerSk = signer.privKey!!.toHexKey(), communityId = community, url = "u", createdAt = 1, expiresAt = expiresAt)
@Test
fun theNextRegistryAddsMintsDropsRetiredAndPrunesExpiredOrTombstonedLinks() {
val live = KeyPair()
val expired = KeyPair()
val tombstoned = KeyPair()
val otherCommunity = KeyPair()
val doc =
ConcordInviteListDocument(
entries =
listOf(
entry("01", live),
entry("02", expired, expiresAt = 100),
entry("03", tombstoned),
entry("04", otherCommunity, community = "ee".repeat(32)),
),
tombstones = listOf(ConcordInviteListTombstone("03", ControlFixtures.COMMUNITY_ID_HEX)),
)
val livePk = live.pubKey.toHexKey()
val expiredPk = expired.pubKey.toHexKey()
// The published registry still lists the expired link and an unrecorded one (link1); a new mint adds link2.
val next = ConcordInviteRegistry.nextLinks(listOf(expiredPk, link1), doc, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200, minted = listOf(link2))
assertEquals(listOf(link1, link2, livePk).sorted(), next)
// Retiring the recorded live link and link1 leaves only the mint.
assertEquals(listOf(link2), ConcordInviteRegistry.nextLinks(next, doc, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200, retired = listOf(livePk, link1)))
// Before its expiry the link is still live; an unreadable list prunes nothing.
assertTrue(expiredPk in ConcordInviteRegistry.nextLinks(emptyList(), doc, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 50))
assertEquals(listOf(expiredPk), ConcordInviteRegistry.nextLinks(listOf(expiredPk), null, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200))
}
}
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord05Invites
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* A Direct Invite for an already-joined community is a catch-up: it may only add Private Channel
* keys on the SAME base (root, epoch, control_pk) — never move the base (Armada `catchUpChannelIds`).
*/
class ConcordInviteVendTest {
private val communityId = "11".repeat(32)
private val root = "22".repeat(32)
private val controlPk = "33".repeat(32)
private val chanA = "a1".repeat(32)
private val chanB = "b2".repeat(32)
private val keyA = "ca".repeat(32)
private val keyB = "db".repeat(32)
private val held =
ConcordCommunityListEntry(
id = communityId,
owner = "44".repeat(32),
ownerSalt = "55".repeat(32),
root = root,
rootEpoch = 3,
controlPk = controlPk,
privateChannels = listOf(PrivateChannelKey(chanA, keyA, 1, "mods")),
relays = listOf("wss://relay.example"),
name = "Nostrichs",
inviteRef = "naddr1ref",
)
private fun bundle(
root: String = this.root,
epoch: Long = 3,
controlPk: String? = this.controlPk,
channels: List<InviteChannel>,
) = CommunityInvite(
communityId = communityId,
owner = held.owner,
ownerSalt = held.ownerSalt,
communityRoot = root,
rootEpoch = epoch,
controlPk = controlPk,
channels = channels,
name = "Nostrichs",
)
@Test
fun aNewPrivateChannelKeyOnTheSameBaseIsACatchUp() {
val b = bundle(channels = listOf(InviteChannel(chanA, keyA, 1, "mods"), InviteChannel(chanB.uppercase(), keyB, 0, "vip")))
assertEquals(listOf(chanB), ConcordInviteVend.catchUpChannelIds(held, b))
val adopted = ConcordInviteVend.adoptCatchUp(held, b)
assertNotNull(adopted)
// The base never moves.
assertEquals(root, adopted.root)
assertEquals(3, adopted.rootEpoch)
assertEquals(controlPk, adopted.controlPk)
assertEquals(held.inviteRef, adopted.inviteRef)
assertEquals(setOf(chanA to keyA, chanB to keyB), adopted.privateChannels.map { it.channelId to it.key }.toSet())
}
@Test
fun aNewerEpochOfAHeldChannelReplacesIt() {
val newer = "ee".repeat(32)
val b = bundle(channels = listOf(InviteChannel(chanA, newer, 2, "mods")))
assertEquals(listOf(chanA), ConcordInviteVend.catchUpChannelIds(held, b))
val adopted = assertNotNull(ConcordInviteVend.adoptCatchUp(held, b))
assertEquals(listOf(Triple(chanA, newer, 2L)), adopted.privateChannels.map { Triple(it.channelId, it.key, it.epoch) })
// Same or older epoch contributes nothing.
assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanA, newer, 1)))).isEmpty())
}
@Test
fun aBundleOnAnotherBaseIsNeverACatchUp() {
val grant = listOf(InviteChannel(chanB, keyB, 0, "vip"))
assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(root = "99".repeat(32), channels = grant)).isEmpty())
assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(epoch = 4, channels = grant)).isEmpty())
assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(controlPk = "98".repeat(32), channels = grant)).isEmpty())
assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(controlPk = null, channels = grant)).isEmpty())
assertNull(ConcordInviteVend.adoptCatchUp(held, bundle(root = "99".repeat(32), channels = grant)))
}
@Test
fun nothingHeldMeansNoCatchUpAndKeylessGrantsDeliverNothing() {
assertTrue(ConcordInviteVend.catchUpChannelIds(null, bundle(channels = listOf(InviteChannel(chanB, keyB, 0)))).isEmpty())
assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanB, "", 0)))).isEmpty())
assertNull(ConcordInviteVend.adoptCatchUp(held, bundle(channels = emptyList())))
}
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip09Deletions
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DeletionIndexByIdTest {
private val alice = NostrSignerInternal(KeyPair())
private val mallory = NostrSignerInternal(KeyPair())
@Test
fun aDeleteIsFoundByIdAndAuthorAloneAndOnlyForItsAuthor() =
runTest {
val message = alice.sign(ChatEvent.build("hello", createdAt = 1))
val index = DeletionIndex()
assertFalse(index.hasBeenDeleted(message.id, alice.pubKey))
// A stranger's delete names the id but is not the author's own.
index.add(mallory.sign(DeletionRequestEvent.build(listOf(message), createdAt = 2)), wasVerified = false)
assertFalse(index.hasBeenDeleted(message.id, alice.pubKey))
assertTrue(index.hasBeenDeleted(message.id, mallory.pubKey))
index.add(alice.sign(DeletionRequestEvent.build(listOf(message), createdAt = 3)), wasVerified = false)
assertTrue(index.hasBeenDeleted(message.id, alice.pubKey), "no loaded event needed: the id and its author suffice")
}
}