mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+14
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -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.
|
||||
|
||||
+38
-1
@@ -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
|
||||
|
||||
+51
@@ -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
|
||||
|
||||
|
||||
+20
-1
@@ -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
|
||||
|
||||
+16
-7
@@ -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
|
||||
|
||||
+32
-3
@@ -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
@@ -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
|
||||
|
|
||||
|
||||
+4
-3
@@ -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 {
|
||||
|
||||
+148
-11
@@ -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)))
|
||||
}
|
||||
}
|
||||
+185
-19
@@ -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,
|
||||
|
||||
+52
@@ -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,
|
||||
|
||||
+490
@@ -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)
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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 ->
|
||||
|
||||
+548
-36
@@ -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
|
||||
|
||||
+58
@@ -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,
|
||||
}
|
||||
+48
@@ -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
|
||||
}
|
||||
+12
@@ -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)
|
||||
|
||||
+5
-1
@@ -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 -> {
|
||||
|
||||
+171
-8
@@ -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) }
|
||||
|
||||
|
||||
+278
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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
|
||||
|
||||
+82
@@ -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."
|
||||
}
|
||||
}
|
||||
+184
@@ -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)))
|
||||
}
|
||||
}
|
||||
+121
@@ -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)
|
||||
}
|
||||
}
|
||||
+434
@@ -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")
|
||||
}
|
||||
}
|
||||
+263
@@ -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))
|
||||
}
|
||||
}
|
||||
+245
@@ -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>
|
||||
|
||||
+93
@@ -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) }
|
||||
}
|
||||
}
|
||||
+267
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+319
@@ -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() }
|
||||
}
|
||||
+68
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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 |
|
||||
|
||||
+23
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+74
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -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.
|
||||
|
||||
+50
@@ -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
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -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()
|
||||
}
|
||||
|
||||
+19
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+133
-21
@@ -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
|
||||
}
|
||||
|
||||
+174
@@ -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,
|
||||
)
|
||||
}
|
||||
+141
@@ -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()
|
||||
}
|
||||
+11
-4
@@ -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
|
||||
|
||||
+11
-1
@@ -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)
|
||||
|
||||
+42
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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())
|
||||
|
||||
+172
-16
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+273
@@ -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))
|
||||
}
|
||||
}
|
||||
+117
@@ -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())))
|
||||
}
|
||||
}
|
||||
+50
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user