diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 2248fe8e99..4b1fd7f9f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -92,6 +93,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.NoteActionHandlers import com.vitorpamplona.amethyst.commons.ui.note.elements.ShareOptionsBottomSheet import com.vitorpamplona.amethyst.commons.ui.note.elements.noteActionSections import com.vitorpamplona.amethyst.commons.ui.note.elements.observeBookmarksFollowsAndAccount +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordExpiringPinDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.navigateToReloadMint import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -108,12 +110,15 @@ import com.vitorpamplona.amethyst.ui.note.observeZapRailCapability import com.vitorpamplona.amethyst.ui.note.payViaIntentOrManualSplit import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlin.uuid.ExperimentalUuidApi // null amount = open the on-chain dialog with no prefill. @@ -379,16 +384,37 @@ fun ChatMessageActionSheet( // 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) } + // Read off the main thread: it verifies the channel's whole Pin List. + val concordPinState by produceState(null, note) { + value = withContext(Dispatchers.Default) { accountViewModel.account.concord.concordPinState(note) } + } + val concordPinned = concordPinState if (concordPinned != null && !note.isDraft()) { + var confirmExpiringPin by remember(note) { mutableStateOf(false) } 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() + // Pinning a disappearing message (CORD-08) keeps its words past the timer: ask first. + val expires = note.event?.let { ConcordDisappearing.expirationOf(it) } != null + if (!concordPinned && expires) { + confirmExpiringPin = true + } else { + accountViewModel.toggleConcordPin(note) + onDismiss() + } } } + if (confirmExpiringPin) { + ConcordExpiringPinDialog( + onConfirm = { + confirmExpiringPin = false + accountViewModel.toggleConcordPin(note) + onDismiss() + }, + onDismiss = { confirmExpiringPin = false }, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index c416ed5f61..b3f8a06fc6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -207,7 +207,7 @@ fun ConcordChannelScreen( Scaffold( topBar = { TopAppBar( - actions = { ConcordPinnedButton(pins) { showPins = true } }, + actions = { ConcordPinnedButton(communityId, pins, accountViewModel) { showPins = true } }, title = { Column { Text(channel.toBestDisplayName(), maxLines = 1) @@ -331,7 +331,11 @@ private fun ConcordTimerIndicator( communityId: String, accountViewModel: AccountViewModel, ) { - val session = remember(communityId) { accountViewModel.account.concordSessions.sessionFor(communityId) } ?: return + // Re-resolved on every session-set change: the session may not exist yet at first composition, and + // a Refounding replaces it (a captured one would keep reading the dead epoch's fold). + val sessions = accountViewModel.account.concordSessions + val revision by sessions.revision.collectAsStateWithLifecycle() + val session = remember(communityId, revision) { sessions.sessionFor(communityId) } ?: return val state by session.state.collectAsStateWithLifecycle() val secs = state?.metadata?.messageExpirationSecs() ?: return Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index d7ba2251db..9b3e4a8f50 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -79,7 +79,8 @@ 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.RefreshConcordDirectInvites +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 @@ -119,6 +120,11 @@ fun ConcordHomeScreen( // the stock relays for users who actually use Concord. LaunchedEffect(Unit) { accountViewModel.importConcordCommunities() } + // Direct Invites (CORD-05 §6): one inbox sweep per visit, kept out of the lazy list so it does + // not re-run each time the invites scroll back into view. + RefreshConcordDirectInvites(accountViewModel) + val invites by account.concord.pendingConcordDirectInvites.collectAsStateWithLifecycle() + // Per-community expansion, cycled on tap: absent = CLOSED → UNREAD (peek only the channels with // new messages) → OPEN (all channels) → CLOSED. Multi-open, so several can be expanded at once. // rememberSaveable so the chevron states survive opening a channel and coming back to the hub. @@ -160,16 +166,18 @@ fun ConcordHomeScreen( ) { padding -> if (communities.isEmpty()) { // 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), - ) + // above the empty state rather than being hidden by it — in a lazy list, so many scroll. + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + concordPendingDirectInvites(invites, accountViewModel, nav) + item(key = "concord-home-empty") { + Box(Modifier.fillParentMaxWidth().fillParentMaxHeight(if (invites.isEmpty()) 1f else 0.5f), 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 @@ -194,7 +202,7 @@ 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) } + concordPendingDirectInvites(invites, accountViewModel, nav) sorted.forEach { entry -> val state = diff --git a/cli/README.md b/cli/README.md index 3dd2d75fe1..38a8b85331 100644 --- a/cli/README.md +++ b/cli/README.md @@ -698,8 +698,8 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. Private-channel keys follow the Grant (CORD-03/06): every channel it opens to a member is vended to them by Direct Invite carrying only those channels (`channel_keys_vended`); every channel it closes is rotated (`channels_rotated`, or `channels_not_rotated` with the reason). Only keys this account holds can move (`channels_not_held`). | | `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 kick COMMUNITY USER` | Cooperative Kick (CORD-04 §6): strips the member's roles first (when you may — MANAGE_ROLES + outrank), then publishes the Guestbook KICK (kind 3309) citing your Grant. Needs KICK and a strict outrank. Reports `roles_stripped`; changes no key — the member can rejoin, and a compliant client leaves on its own. | -| `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 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). Expired (CORD-08) pinned messages are hidden like deleted ones. | +| `amy concord pin COMMUNITY CHANNEL RUMOR_ID [--force]` / `unpin COMMUNITY CHANNEL RUMOR_ID` | Pin / unpin a message (PIN_MESSAGES or owner, plus the control write key). A disappearing message (one carrying a CORD-08 `expiration`) is refused with `expiring_message` unless `--force`: the pin would keep its words past the timer. 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. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPinCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPinCommands.kt index bbbf4e946c..a1581ecd21 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPinCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPinCommands.kt @@ -35,10 +35,12 @@ 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.cord03Channels.ConcordDisappearing 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.nip40Expiration.isExpirationBefore import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -91,7 +93,8 @@ object ConcordPinCommands { ?.key ?.conversationKey }, - isKilled = view.evidence::isKilled, + // CORD-08 §3: an expired message never shows, pinned or not (the proof stays valid, the rumor says it is gone). + isKilled = { view.evidence.isKilled(it) || it.tags.isExpirationBefore(TimeUtils.now()) }, newestEdit = view.evidence::newestEdit, ) } @@ -168,6 +171,7 @@ object ConcordPinCommands { val handle = args.positional(0, "community") val channelRef = args.positional(1, "channel") val rumorId = args.positional(2, "rumor_id").lowercase() + val force = pin && args.bool("force") 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) @@ -202,9 +206,14 @@ object ConcordPinCommands { if (pin) { val refused = ConcordPinning.refusal(pinCtx) val source = if (refused == null) ConcordPinning.sourceFrom(view.wraps, view.planes, rumorId) else null + val expiresAt = source?.let { ConcordDisappearing.expirationOf(it.opened.rumor) } when { refused != null -> ConcordPinWrite(refused) source == null -> ConcordPinWrite(ConcordPinOutcome.MESSAGE_UNAVAILABLE) + // A pin carries the message's words in its proof: pinning a disappearing message + // makes it outlive its timer (CORD-08), so that takes an explicit --force. + expiresAt != null && !force -> + return Output.error("expiring_message", "that message disappears at $expiresAt (CORD-08) and a pin would keep its words past the timer; pass --force to pin it anyway") else -> ConcordPinning.pin(pinCtx, source, TimeUtils.now()) } } else { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt index b9ec3a7116..41fbd498c2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordInviteInboxStore.kt @@ -23,6 +23,7 @@ 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 com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox import java.io.File /** amy's bookkeeping for Concord Direct Invites (CORD-05 §6): the wrap ids the user declined. */ @@ -50,6 +51,8 @@ class ConcordInviteInboxStore( 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))) + // Bounded like the app's store: the newest declines are kept, a long-expired one is not worth a line. + val next = (current.declined + wrapId).takeLast(ConcordDirectInviteInbox.DECLINED_CAP) + SecureFileIO.writeTextAtomic(file, Output.mapper.writeValueAsString(current.copy(declined = next))) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 6ed53eacf2..d458ba1c2e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -35,7 +35,6 @@ import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring 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 @@ -507,6 +506,11 @@ object ConcordActions { * Builds an encrypted-seal **edit** wrap (kind-3302 [ChannelChat.edit] of [target]) on the * [channel] plane. [newText] replaces [target]'s content on receivers that apply the edit overlay; * only the original author's edits take effect, so restrict callers to their own messages. + * + * The Edit carries [expiration] verbatim — by default [target]'s own NIP-40 deadline, and none + * when [target] has none — never `now + timer`: an Edit stamped with a fresh deadline would + * outlive (or cut short) the message it revises, so the revised words could survive the message + * the timer already erased (CORD-08 §2; the reference client keeps `expirationOf(original)`). */ suspend fun buildChannelEdit( authorSigner: NostrSigner, @@ -517,9 +521,10 @@ object ConcordActions { newText: String, createdAt: Long, extraTags: Array> = emptyArray(), - timerSecs: Long? = null, + expiration: Long? = ConcordDisappearing.expirationOf(target), ): Event { - val rumor = ChannelChat.edit(authorSigner.pubKey, channelId, epoch, target.id, newText, createdAt, withTimer(extraTags, ConcordChatEditEvent.KIND, createdAt, timerSecs)) + val tags = ConcordDisappearing.withExpiration(extraTags.filterNot { it.isNotEmpty() && it[0] == "expiration" }.toTypedArray(), expiration) + val rumor = ChannelChat.edit(authorSigner.pubKey, channelId, epoch, target.id, newText, createdAt, tags) return wrapChat(rumor, channel, authorSigner) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt index f7112d9b7e..a47da15d01 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap +import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity @@ -98,7 +99,8 @@ object ConcordModeration { communityId: ByteArray, entityId: ByteArray, owner: HexKey, - ): ControlEdition? = ConcordCommunityState.authorizedHeads(current, communityId, owner)[entityId.toHexKey()]?.known + floors: Map = emptyMap(), + ): ControlEdition? = ConcordCommunityState.authorizedHeads(current, communityId, owner, floors)[entityId.toHexKey()]?.known /** version/prevHash to chain onto the current head of [entityId], or a genesis at version 1 (CORD-04 §1). */ private fun versioning(head: ControlEdition?): Pair = if (head != null) (head.version + 1) to head.hash else 1L to null @@ -140,8 +142,9 @@ object ConcordModeration { createdAt: Long, citation: AuthorityCitation?, owner: HexKey, + floors: Map = emptyMap(), ): Event { - val head = headOf(current, communityId, entityId, owner) + val head = headOf(current, communityId, entityId, owner, floors) val content = ConcordJson.encodePreserving(serializer, value, head?.content) return wrap(actor, controlPlane, communityId, kind, entityId, head, content, current, createdAt, citation, owner) } @@ -207,7 +210,8 @@ object ConcordModeration { createdAt: Long, citation: AuthorityCitation? = null, owner: HexKey, - ): Event = editMetadata(actor, controlPlane, communityId, standing.withMessageExpiration(secs), current, createdAt, citation, owner) + floors: Map = emptyMap(), + ): Event = editMetadata(actor, controlPlane, communityId, standing.withMessageExpiration(secs), current, createdAt, citation, owner, floors) /** * Replaces the community metadata (name / icon / description / relays). The @@ -224,10 +228,11 @@ object ConcordModeration { createdAt: Long, citation: AuthorityCitation? = null, owner: HexKey, + floors: Map = emptyMap(), ): Event { require(ConcordLimits.nameFits(metadata.name)) { "community name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes" } require(ConcordLimits.descriptionFits(metadata.description)) { "description exceeds ${ConcordLimits.DESCRIPTION_MAX_BYTES} bytes" } - return edit(actor, controlPlane, communityId, ControlEntityKind.METADATA, communityId, MetadataEntity.serializer(), metadata, current, createdAt, citation, owner) + return edit(actor, controlPlane, communityId, ControlEntityKind.METADATA, communityId, MetadataEntity.serializer(), metadata, current, createdAt, citation, owner, floors) } /** @@ -390,9 +395,12 @@ object ConcordModeration { createdAt: Long, citation: AuthorityCitation? = null, owner: HexKey, + floors: Map = emptyMap(), ): Event { val entityId = ConcordInviteRegistry.coordinate(communityId, actor.pubKey) - val head = headOf(current, communityId, entityId, owner) + // Floor-aware: after a Refounding the honored head may be the prior epoch's (the anti-rollback + // floor), and chaining onto the current epoch's editions alone would fork below it. + val head = headOf(current, communityId, entityId, owner, floors) return wrap(actor, controlPlane, communityId, ControlEntityKind.INVITE_REGISTRY, entityId, head, ConcordInviteRegistry.encode(linkSigners), current, createdAt, citation, owner) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinning.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinning.kt index 9f9bf8b85d..e167d1cb7c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinning.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinning.kt @@ -37,6 +37,7 @@ 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.nip40Expiration.ExpirationTag import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.serialization.json.JsonObject import kotlin.random.Random @@ -92,6 +93,11 @@ class ConcordChannelPins( val sealedForm: Boolean, /** Entries that failed verification and were dropped alone. */ val invalidEntries: Int, + /** + * False while the Control Plane has not been swept whole yet: [head] is whatever the partial fold + * holds, so an empty list may only mean "not served yet". No edition may be built from it (§7). + */ + val complete: Boolean = true, ) { val count: Int get() = pins.size @@ -100,8 +106,20 @@ class ConcordChannelPins( /** 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 } + /** Every rumor id the list carries, shown or erased — what a delete or an Edit must name to change this read. */ + val rumorIds: Set by lazy { (alive + killed).mapTo(HashSet()) { it.rumorId } } + + /** + * The soonest NIP-40 deadline (unix seconds) after [now] among the shown pins, or null: when this + * read goes stale, since an expired message leaves the list (CORD-08 §3). + */ + fun nextExpiry(now: Long): Long? = alive.mapNotNull { pin -> pin.tags.firstNotNullOfOrNull(ExpirationTag::parse) }.filter { it > now }.minOrNull() + companion object { - fun none(channelIdHex: HexKey) = ConcordChannelPins(channelIdHex, null, emptyList(), emptyList(), emptyList(), sealedUnavailable = false, violating = false, sealedForm = false, invalidEntries = 0) + fun none( + channelIdHex: HexKey, + complete: Boolean = true, + ) = ConcordChannelPins(channelIdHex, null, emptyList(), emptyList(), emptyList(), sealedUnavailable = false, violating = false, sealedForm = false, invalidEntries = 0, complete = complete) } } @@ -136,6 +154,38 @@ class ConcordPinVerifier( } return verdict } + + private val lists = LinkedHashMap() + + /** List parses (and sealed-form decrypts) actually performed (cache misses) — for tests. */ + var listReads: Int = 0 + private set + + /** + * [head]'s content read as a Pin List, memoized by the head's rumor id: an edition's bytes never + * change, so re-reading the pins on every trigger (a fold, a delete landing, an expiry) parses and + * decrypts the list once. A read that found the list sealed under a key not held is not cached, + * so the key arriving later (a Private Channel key delivered on grant) opens it. + */ + fun readList( + head: ControlEdition, + unsealKey: (epoch: Long) -> ByteArray?, + ): ConcordPins.PinListRead { + lock.withLock { lists[head.rumorId] }?.let { return it } + val read = ConcordPins.read(head.content, unsealKey) + lock.withLock { + listReads++ + if (!read.sealedUnavailable) { + lists[head.rumorId] = read + while (lists.size > MAX_LISTS) lists.remove(lists.keys.first()) + } + } + return read + } + + companion object { + private const val MAX_LISTS = 64 + } } /** The proof material for pinning one opened message: its original seal and the plane key of its epoch. */ @@ -214,6 +264,9 @@ enum class ConcordPinOutcome { UNVERIFIABLE, TOO_MANY_PINS, TOO_LARGE, + + /** The edition was built but no relay acknowledged it, so it may not have landed. */ + NOT_CONFIRMED, } class ConcordPinWrite( @@ -283,9 +336,10 @@ object ConcordPinning { verifier: ConcordPinVerifier = ConcordPinVerifier(), isKilled: (VerifiedPin) -> Boolean = { false }, newestEdit: (VerifiedPin) -> ConcordLocalEdit? = { null }, + complete: Boolean = true, ): ConcordChannelPins { - if (head == null) return ConcordChannelPins.none(channelIdHex) - val read = ConcordPins.read(head.content, unsealKey) + if (head == null) return ConcordChannelPins.none(channelIdHex, complete) + val read = verifier.readList(head, unsealKey) val alive = ArrayList() val killed = ArrayList() var invalid = 0 @@ -314,11 +368,23 @@ object ConcordPinning { pins = shown, sealedUnavailable = read.sealedUnavailable, violating = read.violating, - sealedForm = ConcordPins.isSealedForm(head.content), + sealedForm = read.sealedForm, invalidEntries = invalid, + complete = complete, ) } + /** + * [pins]' shown entries a reader displays: an entry by a [isBanned] author (the community declines + * to show a banned member's posts, CORD-04 §4) or by someone the reader [isHidden]s (mutes or + * blocks) is left out. Display only — the list itself, and every write built from it, is untouched. + */ + fun visible( + pins: ConcordChannelPins, + isBanned: (HexKey) -> Boolean, + isHidden: (HexKey) -> Boolean, + ): List = pins.pins.filterNot { isBanned(it.author) || isHidden(it.author) } + /** True when [edit] is newer than whatever Edit [pin]'s proof already carries. */ private fun isNewer( edit: ConcordLocalEdit, @@ -363,6 +429,9 @@ object ConcordPinning { when { !ctx.authorized -> ConcordPinOutcome.NOT_AUTHORIZED !ctx.controlPlane.canWrite -> ConcordPinOutcome.NO_WRITE_KEY + // §7: never write from a list the fold was not served — a partial fold's head (or its + // absence) is not the list a replace-entire edition would overwrite. + !ctx.pins.complete -> ConcordPinOutcome.NOT_FOLDED // §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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt index 0f85ef314f..97c8f612e6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt @@ -383,6 +383,9 @@ import kotlin.coroutines.cancellation.CancellationException import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash +/** How long past a disappearing message's deadline the sweep waits, to purge nearby deadlines in one pass (CORD-08). */ +private const val CONCORD_EXPIRY_COALESCE_MS = 2_000L + @OptIn(DelicateCoroutinesApi::class) @Stable class Account( @@ -765,6 +768,8 @@ class Account( val expired = concordSessions.sweepExpired(now) for (rumors in expired.values) { for (gone in rumors) { + // Its attachments' decryption keys go with it: a cached key would keep the blob readable. + gone.attachmentUrls.forEach { encryptionKeyCache.remove(it) } cache.getNoteIfExists(gone.rumorId)?.let { note -> note.detachFromChildren() cache.pruner.unlinkAndRemove(note) @@ -4225,7 +4230,9 @@ class Account( scope.launch(Dispatchers.IO) { concordSessions.nextExpiry.collectLatest { at -> if (at == null) return@collectLatest - val waitMs = (at - TimeUtils.now()) * 1000 + // Coalesced: sleep a little past the deadline and sweep everything due by then, so a + // burst of messages sent seconds apart expires in one pass instead of one sweep each. + val waitMs = (at - TimeUtils.now()) * 1000 + CONCORD_EXPIRY_COALESCE_MS if (waitMs > 0) delay(waitMs) runCatching { sweepExpiredConcordMessages() }.onFailure { Log.w("Concord", "expired-message sweep failed", it) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 191dfdc8e9..632530df14 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing import com.vitorpamplona.quartz.concord.cord03Channels.concordEpoch import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitations @@ -67,6 +68,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity 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.CommunityInvite @@ -110,6 +112,7 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -124,7 +127,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit /** Name of the default Concord community Admin role minted by "Make admin". */ private const val CONCORD_ADMIN_ROLE = "Admin" @@ -137,6 +142,9 @@ private const val CONCORD_ADMIN_ROLE = "Admin" */ private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L +/** How many times a Pin List write re-applies itself on top of a concurrent edition that won the fold. */ +private const val PIN_REHEAL_RETRIES = 2 + /** * How many recipients one Refounding will re-key. See `AccountConcordActions.boundRecipients`. * @@ -376,24 +384,30 @@ class AccountConcordActions( retired: List = emptyList(), ): Boolean { val session = account.concordSessions.sessionFor(entry.id) ?: return false + // Only from a drained fold: `published` is read off our honored head, and a partial fold + // would read "no registry" and chain a fresh v1 over the real one (dropped by every reader). + val state = session.foldForWrite() ?: return false + // A dissolved community has no future (CORD-02 §9): no registry edit can change anything. + if (state.dissolved) 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 published = state.registryOf(me) val next = ConcordInviteRegistry.nextLinks(published, list, entry.id, TimeUtils.now(), minted, retired) - val hasHead = state?.inviteRegistries?.containsKey(me.lowercase()) == true + val hasHead = state.inviteRegistries.containsKey(me.lowercase()) if (next == published.sorted() && (hasHead || next.isEmpty())) return false - // The writer chains off the same authorized head the fold honors (ConcordModeration.headOf). + // The writer chains off the same authorized, floor-aware head the fold honors. val wrap = try { - ConcordModeration.setInviteRegistry(account.signer, cp, entry.id.hexToByteArray(), next, session.controlEditions(), TimeUtils.now(), owner = entry.owner) + ConcordModeration.setInviteRegistry(account.signer, cp, entry.id.hexToByteArray(), next, session.controlEditions(), TimeUtils.now(), owner = entry.owner, floors = session.controlFloors()) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.w("Concord", "invite registry build failed for ${entry.id}", e) return false } - publishConcordWrap(entry, wrap) - return true + // Confirmed: the registry is the community's Public/Private source of truth (CORD-05 §5). + return publishConcordWrapConfirmed(entry, wrap) } /** @@ -813,15 +827,41 @@ class AccountConcordActions( * 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) + val directInviteInbox = + ConcordDirectInviteInbox( + account.signer, + // Muted/blocked senders never park; followed ones outrank strangers when the inbox is full. + isHidden = { account.isHidden(it) }, + isFollowed = { it in account.followingKeySet() }, + ) /** - * 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]). + * The parked Direct Invites a UI should show, followed senders first, then newest: invites for + * communities we don't hold (nor left after they were sent), plus catch-ups for ones we do + * ([ConcordDirectInviteInbox.visible]). */ val pendingConcordDirectInvites: StateFlow> = - combine(directInviteInbox.pending, account.concordChannelList.liveCommunities) { pending, joined -> - ConcordDirectInviteInbox.visible(pending.values, joined) + combine( + directInviteInbox.pending, + account.concordChannelList.liveCommunities, + account.concordChannelList.removedAt, + account.hiddenUsers.flow, + // A fold landing can make a parked catch-up admissible or not (the sender's standing). + combine(account.kind3FollowList.flow, account.concordSessions.revision) { follows, _ -> follows }, + ) { pending, joined, removedAt, _, follows -> + ConcordDirectInviteInbox.visible( + pending.values, + joined, + removedAt = removedAt, + isFollowed = { it in follows.authors }, + isHidden = { account.isHidden(it) }, + heldStateOf = { id -> + account.concordSessions + .sessionFor(id) + ?.state + ?.value + }, + ) }.stateIn(account.scope, SharingStarted.WhileSubscribed(5_000), emptyList()) /** @@ -1146,7 +1186,8 @@ class AccountConcordActions( .findEmojiTags(newText) .map { it.toTagArray() } .toTypedArray() - val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, plane.epoch, target, newText, TimeUtils.now(), emojiTags, session.messageExpirationSecs()) + // CORD-08 §2: the Edit keeps the ORIGINAL message's deadline (none if it has none), never now + timer. + val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, plane.epoch, target, newText, TimeUtils.now(), emojiTags, expiration = ConcordDisappearing.expirationOf(target)) publishConcordWrap(entry, wrap) return true } @@ -1252,6 +1293,31 @@ class AccountConcordActions( if (relays.isNotEmpty()) account.client.publish(wrap, relays) } + /** + * [publishConcordWrap] for a Control Plane edition whose caller must know it landed: waits for a + * relay OK (`publish` only queues and never reports acceptance) and folds the wrap into the + * session only then, so a refused write never shows as done locally. False when no relay of the + * community accepted it. + */ + private suspend fun publishConcordWrapConfirmed( + entry: ConcordCommunityListEntry, + wrap: Event, + ): Boolean { + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isEmpty()) return false + val landed = + try { + account.client.publishAndConfirm(wrap, relays) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("Concord", "Control edition publish failed for ${entry.id}", e) + false + } + if (landed) account.concordSessions.ingest(wrap) + return landed + } + /** * Echo an own Concord channel message into its feed and publish it, driving * the bubble's send state as it goes. @@ -1751,6 +1817,11 @@ class AccountConcordActions( /** * 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. + * + * The publish waits for a relay OK ([ConcordPinOutcome.NOT_CONFIRMED] otherwise). Then, like a + * ban, the write re-heals: a concurrent curator's edition at the same version may win the fold + * (CORD-04 §1), silently dropping this change, so when the refolded head is not ours the same + * [op] runs again on top of the winner — at most [PIN_REHEAL_RETRIES] times. */ private suspend fun writeConcordPins( communityId: String, @@ -1759,29 +1830,38 @@ class AccountConcordActions( ): 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 + repeat(1 + PIN_REHEAL_RETRIES) { + 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) + // A retry that finds the winner already says what we meant (ALREADY_PINNED, NOT_PINNED, + // NOTHING_TO_DO) ends here too. + val wrap = write.wrap ?: return@withLock write.outcome + if (!publishConcordWrapConfirmed(session.entry, wrap)) return@withLock ConcordPinOutcome.NOT_CONFIRMED + val ours = ConcordStreamEnvelope.openOrNull(wrap, ctx.controlPlane)?.let { ControlEdition.fromOpened(it) }?.rumorId + if (ours == null || session.pinHeads.value[channelIdHex]?.rumorId == ours) return@withLock ConcordPinOutcome.PUBLISHED + Log.i("Concord") { "Pin List edit in $communityId lost the fold to a concurrent edition; re-applying on the winner" } + } + // Landed every time but kept losing the tie-break: it is on the relays, just not the head. + ConcordPinOutcome.PUBLISHED } /** Pin Concord message [note] into its channel's Pin List, proving it with its original seal. */ @@ -2424,12 +2504,15 @@ class AccountConcordActions( if (!account.isWriteable()) return false val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_METADATA) ?: return false // Start from the folded metadata so a field this form doesn't edit — the CORD-08 timer, above - // all — is carried forward instead of reset (CORD-02 §6 round-trip). - val standing = session.state.value?.metadata ?: MetadataEntity() + // all — is carried forward instead of reset (CORD-02 §6 round-trip). Only from a drained fold: + // minting over a head we were never served would chain a fresh v1 (or a stale version) that + // wipes the name, relays and timer — the owner included, who may otherwise act before the fold. + val folded = session.foldForWrite() ?: return false + val standing = folded.metadata ?: MetadataEntity() val metadata = standing.copy(name = name, icon = icon, banner = banner, description = description, relays = relays) // CORD-02 §6 caps are fold gates too: an edition past them would be dropped by every reader. if (!ConcordLimits.metadataFits(metadata)) return false - val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner, floors = session.controlFloors()) publishConcordWrap(session.entry, wrap) return true } @@ -2449,9 +2532,11 @@ class AccountConcordActions( 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() + // Same rule as editConcordMetadata: never lay the timer over a head we were not served. + val folded = session.foldForWrite() ?: return false + val standing = folded.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) + val wrap = ConcordModeration.setMessageExpiration(account.signer, cp, communityId.hexToByteArray(), standing, timer, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner, floors = session.controlFloors()) publishConcordWrap(session.entry, wrap) postConcordTimerNotices(session, timer ?: 0) return true @@ -2923,7 +3008,7 @@ class AccountConcordActions( * never asked for again and stays invisible. This sweep uses **no `since`**: it re-fetches the * whole plane every run. * - **Per-filter cap:** a relay caps a REQ's result (~100/filter on relay.dreamith.to), which can - * crop a busy Control Plane. This **pages past the cap** ([fetchAllPagesFromPool] walks `until` + * crop a busy Control Plane. This **pages past the cap** ([fetchAllPages] walks `until` * cursors until a plane is drained), so the fold sees every edition regardless of the cap. * * Current + every held-prior epoch's Control Plane is swept (the anti-rollback floor folds from the @@ -2943,9 +3028,74 @@ class AccountConcordActions( if (authorsByRelay.isEmpty()) return // No `since`, no `limit` → fetchAllPages treats each filter as unbounded and pages until a // plane is fully drained (empty page), so the whole Control Plane lands regardless of the cap. - val byRelay = authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors.toList())) } - var drained = 0 - account.client.fetchAllPagesFromPool(filters = byRelay) { _, _ -> drained++ } - Log.d("Concord") { "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), drained $drained control wrap(s)" } + // Paged per relay (8 at a time) rather than through the pool helper, because the drained flag + // below needs each relay's ending: only DRAINED proves the relay had nothing more. + val gate = Semaphore(8) + val perRelay = + coroutineScope { + authorsByRelay + .map { (relay, authors) -> + async { + gate.withPermit { + val wraps = ArrayList() + val end = + try { + account.client.fetchAllPages(relay, listOf(ConcordActions.planeFilterFor(authors.toList()))) { wraps.add(it) }.end + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("Concord", "Control Plane sweep failed on $relay", e) + null + } + Triple(authors, end == PagedFetchResult.End.DRAINED, wraps) + } + } + }.awaitAll() + } + // Fold the swept wraps in before flagging anything drained: the global cache connector also + // ingests them, but asynchronously, and the flag must never run ahead of the fold (the session + // dedups by wrap id, so the second delivery is a no-op). + var swept = 0 + for ((_, _, wraps) in perRelay) { + for (wrap in wraps) { + account.concordSessions.ingest(wrap) + swept++ + } + } + val drainedAddresses = perRelay.filter { it.second }.flatMapTo(HashSet()) { it.first } + // One relay drained whole is enough for everyday writes (a dead relay must not freeze pins and + // metadata forever); the Refounding compaction keeps its own majority rule. + for (entry in entries) { + val session = account.concordSessions.sessionFor(entry.id) ?: continue + if (session.controlPlaneAddress in drainedAddresses) session.markControlDrained() + } + Log.d("Concord") { "syncConcordControlPlanes: paged ${authorsByRelay.size} relay(s), swept $swept control wrap(s), ${drainedAddresses.size} plane(s) drained" } + pruneExpiredConcordRegistries(entries) + } + + // Communities (at an epoch) whose registry was already checked for elapsed links this process. + private val registryPruneChecked = ConcurrentSet() + + /** + * CORD-05 §5: once a community's Control Plane has drained, republish this account's Invite + * Registry there pruned when it still lists a link the Invite List says has expired (or no longer + * holds). Otherwise an elapsed link — which nothing else ever retires — keeps the community Public + * forever, and a Private ban would never Refound. Once per community and epoch per process; the + * Invite List is read only when some drained community actually has a registry of ours. + */ + private suspend fun pruneExpiredConcordRegistries(entries: List) { + if (!account.isWriteable()) return + val me = account.signer.pubKey + val due = + entries.filter { entry -> + val state = account.concordSessions.sessionFor(entry.id)?.foldForWrite() ?: return@filter false + !state.dissolved && state.registryOf(me).isNotEmpty() && registryPruneChecked.add("${entry.id}@${entry.rootEpoch}") + } + if (due.isEmpty()) return + val list = readConcordInviteList() ?: return + for (entry in due) { + // Publishes only when the pruned list differs from the honored one. + if (publishConcordInviteRegistry(entry, list)) Log.i("Concord") { "Pruned elapsed invite links from this account's registry in ${entry.id}" } + } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt index 5ca3c6f8f7..946bb4953f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DecryptAndIndexProcessor.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.IEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable +import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent @@ -346,7 +347,21 @@ class GiftWrapEventHandler( eventNote: Note, publicNote: Note, ) { - val innerGift = event.unwrapOrNull(account.signer) ?: return + val innerGift = + try { + event.unwrapThrowing(account.signer) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // A Direct Invite-tagged wrap (CORD-05 §6) that will never open is written off in the + // invite inbox too, so its sweep does not decrypt it again; a signer that merely + // could not answer now leaves it to be retried. + if (ConcordDirectInvite.isInviteTagged(event) && !ConcordDirectInvite.isTransientSignerFailure(e)) { + account.concord.directInviteInbox.markNotInvite(event.id) + } + Log.d("GiftWrapEvent") { "Couldn't Decrypt the content " + event.toNostrUri() } + return + } eventNote.event = event.copyNoContent() @@ -535,18 +550,33 @@ class SealEventHandler( eventNote: Note, publicNote: Note, ) { - val innerRumor = event.unsealOrNull(account.signer) ?: return + // Decrypted once, kept as the seal carries it (claimed author intact) so a Direct Invite can be + // checked against NIP-59 anti-spoofing without a second decrypt. + val rumor = + try { + event.unsealRumorThrowing(account.signer) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("RumorEvent", "Fail to decrypt or parse Rumor", e) + return + } + val innerRumor = event.unsealed(rumor) // 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. + // the seal and its rumor to the Concord invite inbox, which runs 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. if (innerRumor.kind == ConcordDirectInvite.KIND) { - account.concord.directInviteInbox.offerSeal(publicNote.event ?: event, event) + account.concord.directInviteInbox.offerRumor(publicNote.event ?: event, event, rumor) eventNote.event = event.copyNoContent() return } + // Tagged as an invite but carrying something else: never one, so the inbox sweep skips it. + (publicNote.event as? GiftWrapEvent)?.let { wrap -> + if (ConcordDirectInvite.isInviteTagged(wrap)) account.concord.directInviteInbox.markNotInvite(wrap.id) + } eventNote.event = event.copyNoContent() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt index 18f087e773..0939d33665 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt @@ -196,6 +196,19 @@ class ConcordChannelListState( emptyList(), ) + /** + * When this account left each community it no longer holds (community id → the List + * tombstone's `removed_at`, unix ms, CORD-02 §8). A Direct Invite sent at or before that moment + * stays buried instead of resurfacing right after the leave. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val removedAt: StateFlow> = + listChanges + .transformLatest { emit(document().residue.removals()) } + .onStart { emit(document().residue.removals()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + /** The distinct community ids across the joined list — the "servers" rail. */ @OptIn(ExperimentalCoroutinesApi::class) val liveServers: StateFlow> = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 1e7d8bf56e..8e0e16f65d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -77,6 +77,8 @@ data class ExpiredConcordRumor( val wrapId: HexKey, val rumorId: HexKey, val expiresAt: Long, + /** The URLs of the rumor's encrypted attachments, whose decryption keys go with it (CORD-08 §3). */ + val attachmentUrls: List = emptyList(), ) /** @@ -313,6 +315,46 @@ class ConcordCommunitySession( private val _state = MutableStateFlow(null) val state: StateFlow = _state + private val _controlDrained = MutableStateFlow(false) + + /** + * True once this session's current Control Plane has been swept whole at least once — every + * relay page drained and ingested ([markControlDrained]) — so [state] is a fold of the full plane + * rather than of whatever the live subscription delivered first. + * + * Until then the fold may be partial (a cropped first page, an edition below the live `since` + * cursor), and a write built on it can erase what it never saw: a Pin List edition replaces the + * list entire (CORD-04 §7: never write from a list the fold was not served), a metadata edition + * minted without the head resets the name and relays, a registry edit chains onto a stale head. + * Writers refuse while this is false; readers may show a partial fold but must not call an + * absent entity "none". One way: a session never un-drains (a Refounding builds a new session). + */ + val controlDrained: StateFlow = _controlDrained + + /** Records that the whole current Control Plane has been paged in and ingested (see [controlDrained]). */ + fun markControlDrained() { + _controlDrained.value = true + } + + /** + * The fold a Control Plane write may be built from: [state] once the plane has drained, else + * null. Every writer that lays its edition over a folded head (metadata, timer, registry, pins) + * goes through this, the owner included — the owner may act before the fold, but not write over + * a head they have not been served. + */ + fun foldForWrite(): ConcordCommunityState? = if (_controlDrained.value) _state.value else null + + // The anti-rollback floors the last fold used, so a writer can chain onto the same floor-aware head. + @Volatile + private var lastFloors: Map = emptyMap() + + /** + * The per-entity anti-rollback floors (from the prior epochs' Control Planes) the current fold + * honors — what a writer passes so it chains onto the head readers fold to, not the head of the + * current epoch's editions alone. + */ + fun controlFloors(): Map = lastFloors + private val _pinHeads = MutableStateFlow>(emptyMap()) /** @@ -717,6 +759,8 @@ class ConcordCommunitySession( ingestTyping(wrap, channelIdHex, key, epoch) return ConcordIngestOutcome.NON_STRUCTURAL } + // An expired wrap this session already swept, delivered again: ours, but never opened again. + if (wasSwept(wrap.id)) return ConcordIngestOutcome.NON_STRUCTURAL val isNew = lock.withLock { channelWrapsById.getOrPut(channelIdHex) { LinkedHashMap() }.put(wrap.id, wrap) == null @@ -771,6 +815,7 @@ class ConcordCommunitySession( val wraps = controlWraps.values.toList() val editions = editionsLocked(wraps, controlKeys) val floors = controlFloorsLocked() + lastFloors = floors val folded = ConcordCommunityState.fold(editions, communityIdBytes, entry.owner, floors) val prevAddresses = channelKeysByAddress.keys.toHashSet() @@ -907,7 +952,7 @@ class ConcordCommunitySession( // 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) + trackExpiring(wrap.id, channelIdHex, rumor.id, expiresAt, ChannelChat.encryptedImagesOf(rumor).map { it.url }) if (expiresAt <= now) continue } authors.add(rumor.pubKey.lowercase()) @@ -989,42 +1034,86 @@ class ConcordCommunitySession( */ fun messageExpirationSecs(): Long? = _state.value?.metadata?.messageExpirationSecs() + /** + * The same entries as [expiringByWrapId], kept sorted by `expiresAt` (then wrap id), so a sweep + * pops only what is due instead of scanning every tracked message, and the next deadline is the + * head. Common code has no priority queue; a binary-searched insert into an array list is the + * same order of cost here. + */ + private val expiringByDeadline = ArrayList() + + /** + * Wrap ids this session already swept, newest last and bounded: relays keep re-delivering an + * expired wrap (a relay that ignores NIP-40, a backfill page), and each would otherwise be opened + * again only to be refused and swept again. + */ + private val sweptWrapIds = LinkedHashSet() + + private val deadlineOrder = compareBy({ it.expiresAt }, { it.wrapId }) + private fun trackExpiring( wrapId: HexKey, channelIdHex: HexKey, rumorId: HexKey, expiresAt: Long, + attachmentUrls: List = emptyList(), ) { - lock.withLock { - expiringByWrapId[wrapId] = ExpiredConcordRumor(channelIdHex, wrapId, rumorId, expiresAt) - _nextExpiry.update { if (it == null || expiresAt < it) expiresAt else it } - } + lock.withLock { trackLocked(ExpiredConcordRumor(channelIdHex, wrapId, rumorId, expiresAt, attachmentUrls)) } } + private fun trackLocked(entry: ExpiredConcordRumor) { + val prior = expiringByWrapId[entry.wrapId] + if (prior != null) { + // A re-projection re-emits the same wrap: same rumor, same deadline — nothing to move. + if (prior.expiresAt == entry.expiresAt) return + val at = expiringByDeadline.binarySearch(prior, deadlineOrder) + if (at >= 0) expiringByDeadline.removeAt(at) + } + expiringByWrapId[entry.wrapId] = entry + val at = expiringByDeadline.binarySearch(entry, deadlineOrder) + expiringByDeadline.add(if (at < 0) -at - 1 else at, entry) + _nextExpiry.value = expiringByDeadline.first().expiresAt + } + + /** True when [wrapId] was already swept as expired: a re-delivery is dropped before it is opened. */ + private fun wasSwept(wrapId: HexKey): Boolean = lock.withLock { wrapId in sweptWrapIds } + /** * 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. + * the rumor's note and the wrap's note from its store. A wrap re-delivered later is dropped at + * ingest without being opened. */ fun sweepExpired(now: Long = TimeUtils.now()): List = lock.withLock { - if (expiringByWrapId.isEmpty()) return@withLock emptyList() + if (expiringByDeadline.isEmpty() || expiringByDeadline.first().expiresAt > now) return@withLock emptyList() val out = ArrayList() - val it = expiringByWrapId.values.iterator() - while (it.hasNext()) { - val expiring = it.next() - if (expiring.expiresAt <= now) { - channelWrapsById[expiring.channelIdHex]?.remove(expiring.wrapId) - wrapIdByRumorId.remove(expiring.rumorId) - out.add(expiring) - it.remove() - } + while (expiringByDeadline.isNotEmpty() && expiringByDeadline.first().expiresAt <= now) { + val expiring = expiringByDeadline.removeAt(0) + expiringByWrapId.remove(expiring.wrapId) + channelWrapsById[expiring.channelIdHex]?.remove(expiring.wrapId) + wrapIdByRumorId.remove(expiring.rumorId) + sweptWrapIds.add(expiring.wrapId) + out.add(expiring) } - _nextExpiry.value = expiringByWrapId.values.minOfOrNull { it.expiresAt } + while (sweptWrapIds.size > MAX_SWEPT_WRAP_IDS) sweptWrapIds.remove(sweptWrapIds.first()) + _nextExpiry.value = expiringByDeadline.firstOrNull()?.expiresAt out } + /** Every disappearing rumor this session still tracks — handed to the session that replaces it. */ + fun trackedExpiring(): List = lock.withLock { expiringByDeadline.toList() } + + /** + * Adopts [entries] tracked by the session this one replaces (a Refounding rebuilds the session): + * their rumors are already in the store, and without this nothing would ever purge them once + * their deadline passes, since the new session never sees the old epoch's wraps again. + */ + fun carryExpiring(entries: Collection) { + if (entries.isEmpty()) return + lock.withLock { entries.forEach { trackLocked(it) } } + } + /** True while [channelIdHex]'s buffer holds [wrapId] — for tests of the sweep. */ internal fun isBuffered( channelIdHex: HexKey, @@ -1059,7 +1148,9 @@ class ConcordCommunitySession( // 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) + // Not drained yet: the head may simply not have been served, so the result is marked partial + // (shown, never written from — CORD-04 §7). + return ConcordPinning.read(_pinHeads.value[channelIdHex], channelIdHex, { pinUnsealKey(channelIdHex, it) }, pinVerifier, hidden, newestEdit, complete = _controlDrained.value) } /** @@ -1093,5 +1184,8 @@ class ConcordCommunitySession( /** WebXDC signals held per channel (the reference client scans its newest 2000 for peer signals). */ const val MAX_WEBXDC_PER_CHANNEL = 2000 + + /** How many swept (expired) wrap ids a session remembers to drop their re-deliveries unopened. */ + const val MAX_SWEPT_WRAP_IDS = 4096 } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt index 2474d8ff70..fd7cbc13b5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt @@ -31,8 +31,11 @@ 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.rumors.Rumor +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -52,6 +55,12 @@ class ConcordDirectInviteView( val opened: OpenedDirectInvite, val catchUp: Boolean, val expired: Boolean, + /** For a [catchUp]: the ids of the Private Channels it would newly add (not every key it carries). */ + val newChannelIds: List = emptyList(), + /** The rumor's `sentAt` clamped to the time it was ranked, so a future date buys no rank. */ + val clampedSentAt: Long = opened.sentAt, + /** True when this account follows the sender: ranked above strangers. */ + val followedSender: Boolean = false, ) { val wrapId: HexKey get() = opened.wrapId val sender: HexKey get() = opened.sender @@ -60,11 +69,17 @@ class ConcordDirectInviteView( val name: String get() = opened.invite.name val icon: ImagePointer? get() = opened.invite.icon - /** Names of the Private Channels the bundle carries (what a catch-up would add). */ - val channelNames: List get() = - opened.invite.channels - .filter { it.key.isNotBlank() } - .map { it.name } + /** + * Names of the Private Channels a catch-up would newly add — [newChannelIds] only, named by + * [foldedName] (the held community's folded channel name) when it knows the channel, else by the + * bundle's own label. + */ + fun newChannelNames(foldedName: (HexKey) -> String? = { null }): List { + val ids = newChannelIds.mapTo(HashSet()) { it.lowercase() } + return opened.invite.channels + .filter { it.id.lowercase() in ids } + .map { foldedName(it.id)?.takeIf { name -> name.isNotBlank() } ?: it.name } + } } /** What accepting a Direct Invite does; see [ConcordDirectInviteInbox.acceptPlan]. */ @@ -101,23 +116,37 @@ sealed interface DirectInviteAcceptPlan { * 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. + * The inbox opens each wrap once (two NIP-44 decrypts; none more when the DM pipeline already + * unsealed it, [offerRumor]), 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. + * A wrap is written off ([seen]) only on a definitive outcome — opened, not an invite for us, or + * expired. A signer that could not answer (timed out, busy, not approved) leaves it to be retried by + * the next delivery or sweep. + * + * Bounded: at most [MAX_PENDING] invites are parked; past it the lowest-ranked one goes (a sender + * [isFollowed] outranks a stranger, then newer outranks older). Invites from senders [isHidden] + * (muted or blocked) are never parked. + * + * Declined wrap ids are remembered ([declined], restorable via [restoreDeclined], at most + * [DECLINED_CAP]) 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 isHidden: (HexKey) -> Boolean = { false }, + private val isFollowed: (HexKey) -> Boolean = { false }, ) { private val mutex = Mutex() - /** Wrap ids already handled this session (opened, refused, or expired), oldest first. */ + /** Wrap ids with a definitive outcome this session (opened, refused, or expired), oldest first. */ private val seen = LinkedHashSet() + /** Wrap ids being opened right now, so a concurrent delivery of the same wrap is not decrypted twice. */ + private val inFlight = HashSet() + private val _pending = MutableStateFlow>(emptyMap()) /** Parked invites by wrap id, as opened. See [visible] for what a UI should show. */ @@ -125,7 +154,7 @@ class ConcordDirectInviteInbox( private val _declined = MutableStateFlow>(emptySet()) - /** Wrap ids the user declined; persisted by the front end so they stay declined across restarts. */ + /** Wrap ids the user declined, oldest first; persisted by the front end so they stay declined across restarts. */ val declined: StateFlow> = _declined.asStateFlow() /** The newest wrap `created_at` offered so far (the sweep cursor), or null on a cold inbox. */ @@ -136,21 +165,27 @@ class ConcordDirectInviteInbox( /** The `since` for the next sweep: the cursor rewound by the backdate window (null = everything). */ fun since(): Long? = ConcordDirectInvite.inboxSince(newestWrapCreatedAt) - /** Replaces the declined set — used to restore it from disk at startup. Drops any pending one. */ - fun restoreDeclined(wrapIds: Set) { - _declined.value = wrapIds - _pending.update { current -> current.filterKeys { it !in wrapIds } } + /** + * Replaces the declined set — used to restore it from disk at startup — keeping the newest + * [DECLINED_CAP]. Drops any pending one. Serialized with [offer] so a restore can't race a park. + */ + suspend fun restoreDeclined(wrapIds: Set) { + mutex.withLock { + _declined.value = bounded(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. + * bundle, an expired handoff, a hidden sender, a wrap the user already declined — or a signer + * that could not answer now (retried on the next offer). Never throws but for cancellation. */ suspend fun offer( wrap: Event, nowSecs: Long = TimeUtils.now(), - ): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.open(wrap, signer) } + ): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.openOrRetry(wrap, signer) } /** * [offer] for a pipeline that already peeled [wrap] down to its kind-13 [seal] (the NIP-17 @@ -161,7 +196,27 @@ class ConcordDirectInviteInbox( wrap: Event, seal: Event, nowSecs: Long = TimeUtils.now(), - ): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.openSeal(wrap.id, seal, signer) } + ): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.openSealOrRetry(wrap.id, seal, signer) } + + /** + * [offerSeal] for a pipeline that already decrypted [seal] into [rumor] (the rumor as the seal + * carries it, its claimed author intact — [SealEvent.unsealRumorThrowing]): validated without + * any further decrypt. + */ + suspend fun offerRumor( + wrap: Event, + seal: Event, + rumor: Rumor, + nowSecs: Long = TimeUtils.now(), + ): OpenedDirectInvite? = admit(wrap, nowSecs) { ConcordDirectInvite.openRumor(wrap.id, seal, rumor) } + + /** + * Records [wrapId] as definitively not an invite for us (it failed to open for a reason no retry + * changes, or opened to something else), so a sweep that fetches it again skips the decrypt. + */ + suspend fun markNotInvite(wrapId: HexKey) { + mutex.withLock { if (wrapId !in _pending.value) remember(wrapId) } + } private suspend fun admit( wrap: Event, @@ -170,20 +225,54 @@ class ConcordDirectInviteInbox( ): OpenedDirectInvite? { if (wrap.kind != GiftWrapEvent.KIND) return null mutex.withLock { + // The cursor only advances to a time that has happened (plus the skew allowance): a + // future-dated wrap would otherwise push `since` past every invite sent until then. + val stamp = minOf(wrap.createdAt, nowSecs + FUTURE_SKEW_SECS) val newest = newestWrapCreatedAt - if (newest == null || wrap.createdAt > newest) newestWrapCreatedAt = wrap.createdAt + if (newest == null || stamp > newest) newestWrapCreatedAt = stamp _pending.value[wrap.id]?.let { return it } - if (wrap.id in _declined.value || wrap.id in seen) return null - remember(wrap.id) + if (wrap.id in _declined.value || wrap.id in seen || wrap.id in inFlight) return null + inFlight.add(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) } + try { + // An expired handoff is never decrypted or surfaced (NIP-40 on the wrap mirrors expires_at). + if (ConcordDirectInvite.isWrapExpired(wrap, nowSecs)) { + mutex.withLock { remember(wrap.id) } + return null + } + val opened = + try { + open() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // The signer could not answer now: not written off, so the next offer retries it. + return null + } + mutex.withLock { + remember(wrap.id) + if (opened == null || wrap.id in _declined.value) return null + // Muted or blocked senders never reach the inbox. + if (isHidden(opened.sender)) return null + _pending.update { park(it, opened, nowSecs) } + } + return _pending.value[wrap.id] + } finally { + mutex.withLock { inFlight.remove(wrap.id) } } - return opened + } + + /** [current] plus [opened], shedding the lowest-ranked invite past [MAX_PENDING]. */ + private fun park( + current: Map, + opened: OpenedDirectInvite, + nowSecs: Long, + ): Map { + val next = current + (opened.wrapId to opened) + if (next.size <= MAX_PENDING) return next + val rank = compareBy({ isFollowed(it.sender) }, { clampedSentAt(it, nowSecs) }, { it.wrapId }) + val drop = next.values.minWithOrNull(rank) ?: return next + return next - drop.wrapId } /** The parked invite behind [wrapId], if any. */ @@ -193,7 +282,7 @@ class ConcordDirectInviteInbox( fun decline(wrapId: HexKey): Boolean { val id = get(wrapId)?.wrapId ?: return false _pending.update { it - id } - _declined.update { it + id } + _declined.update { bounded(it + id) } return true } @@ -214,6 +303,23 @@ class ConcordDirectInviteInbox( /** Cap on remembered wrap ids; the oldest half is shed past it (a sweep re-dedupes deeper). */ const val SEEN_CAP = 4096 + /** Cap on parked invites (the reference client's bound): a flood can't grow the inbox without end. */ + const val MAX_PENDING = 256 + + /** Cap on remembered declines, newest kept: a declined wrap older than that has long expired or been buried. */ + const val DECLINED_CAP = 4096 + + /** Clock skew tolerated on a wrap's `created_at` before it stops moving the sweep cursor. */ + const val FUTURE_SKEW_SECS = 15 * 60L + + private fun bounded(ids: Set): Set = if (ids.size <= DECLINED_CAP) ids else ids.toList().takeLast(DECLINED_CAP).toCollection(LinkedHashSet()) + + /** [opened]'s `sentAt` (the sender's word) clamped to [nowSecs]: a future date buys no rank. */ + fun clampedSentAt( + opened: OpenedDirectInvite, + nowSecs: Long, + ): Long = minOf(opened.sentAt, nowSecs) + /** * What accepting [opened] should do (CORD-05 §6), given the community entry this account * already [held] (if any) and its folded [heldState]: @@ -250,39 +356,70 @@ class ConcordDirectInviteInbox( /** * What a UI shows out of [pending], given the communities this account already holds - * ([joined]): newest first, with + * ([joined]) and the ones it left ([removedAt], community id → the Community List + * tombstone's `removed_at` in unix ms): followed senders first, then 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 + * - an invite sent at or before the user left that community hidden (it would otherwise + * resurface right after leaving; a fresh re-invite still shows — Armada `tombstonedAt`); + * - invites from [isHidden] (muted/blocked) senders hidden; + * - one invite per community and sender (newest clamped `sentAt`, ties by wrap id), so a + * future-dated invite can only ever shadow its own sender's; catch-ups keyed by their * channel set too since each may vend a key no other wrap carries (Armada - * `dedupeParkedInvites`). + * `dedupeParkedInvites`). `sentAt` is the sender's word, so it is clamped to now for both + * ordering and the tombstone check. */ fun visible( pending: Collection, joined: List, nowMs: Long = TimeUtils.nowMillis(), + removedAt: Map = emptyMap(), + isFollowed: (HexKey) -> Boolean = { false }, + isHidden: (HexKey) -> Boolean = { false }, + heldStateOf: (communityId: HexKey) -> ConcordCommunityState? = { null }, ): List { + val nowSecs = nowMs / 1000 val heldById = joined.associateBy { it.id.lowercase() } + val removedById = removedAt.mapKeys { it.key.lowercase() } val byKey = LinkedHashMap() for (opened in pending) { + if (isHidden(opened.sender)) continue val communityId = opened.invite.communityId.lowercase() + val sentAt = clampedSentAt(opened, nowSecs) + val buriedAt = removedById[communityId] + if (buriedAt != null && sentAt * 1000 <= buriedAt) continue val held = heldById[communityId] - val newChannels = ConcordInviteVend.catchUpChannelIds(held, opened.invite) + // For a held community whose fold is in, only what accepting would actually adopt: + // a catch-up from a non-staff sender, for channels the fold doesn't know as Private, + // or into a dissolved community is refused by [acceptPlan], so it is not offered. + val heldState = held?.let { heldStateOf(it.id) } + val newChannels = + when { + held == null -> emptyList() + heldState == null -> ConcordInviteVend.catchUpChannelIds(held, opened.invite) + heldState.dissolved -> emptyList() + else -> ConcordInviteVend.admissibleCatchUpIds(held, opened.invite, heldState.authority, heldState.privateChannelIds, opened.sender) + } 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 base = communityId + "|" + opened.sender.lowercase() + val key = if (catchUp) base + "|" + newChannels.sorted().joinToString(",") else base + val view = ConcordDirectInviteView(opened, catchUp, opened.isExpired(nowMs), newChannels.toList(), sentAt, isFollowed(opened.sender)) val existing = byKey[key] if (existing == null || - opened.sentAt > existing.opened.sentAt || - (opened.sentAt == existing.opened.sentAt && opened.wrapId < existing.opened.wrapId) + sentAt > existing.clampedSentAt || + (sentAt == existing.clampedSentAt && opened.wrapId < existing.opened.wrapId) ) { byKey[key] = view } } - return byKey.values.sortedWith(compareByDescending { it.opened.sentAt }.thenBy { it.wrapId }) + return byKey.values.sortedWith( + compareByDescending { it.followedSender } + .thenByDescending { it.clampedSentAt } + .thenBy { it.wrapId }, + ) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt index 82c43a895e..05b886afde 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt @@ -79,6 +79,9 @@ class ConcordSessionManager( */ val nextExpiry: StateFlow = _nextExpiry + // Guards the compute-and-assign of [nextExpiry]. Declared before `init` for the same reason. + private val expiryLock = KmpLock() + private val lock = KmpLock() private val stateWatchers = HashMap() // communityId -> state collector @@ -118,7 +121,11 @@ class ConcordSessionManager( } private fun recomputeNextExpiry() { - _nextExpiry.value = registry.sessions().mapNotNull { it.nextExpiry.value }.minOrNull() + // Computed and assigned under one lock: two watchers racing could otherwise let a slower, + // staler computation overwrite an earlier deadline, and the sweep would sleep past it. + expiryLock.withLock { + _nextExpiry.value = registry.sessions().mapNotNull { it.nextExpiry.value }.minOrNull() + } } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt index 3ae9882837..a38341d551 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt @@ -76,7 +76,10 @@ class ConcordSessionRegistry( for ((id, entry) in wanted) { val existing = sessions[id] if (existing == null || existing.entry.root != entry.root || existing.entry.rootEpoch != entry.rootEpoch) { - sessions[id] = ConcordCommunitySession(entry, myPubKey, onRumor) + // CORD-08 §3: the disappearing messages the old session tracked are already in the + // store, and the new session never sees their wraps again — carry their deadlines + // over, or nothing would ever purge them. + sessions[id] = ConcordCommunitySession(entry, myPubKey, onRumor).also { fresh -> existing?.let { fresh.carryExpiring(it.trackedExpiring()) } } created += id } else { // Same epoch, but the Control Plane write key may have just arrived — a diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/EncryptionKeyCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/EncryptionKeyCache.kt index 0776c12eb4..8312fff7ee 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/EncryptionKeyCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/EncryptionKeyCache.kt @@ -49,6 +49,11 @@ class EncryptionKeyCache { ) = add(url, DecryptInformation(cipher, expectedMimeType)) fun get(url: String): DecryptInformation? = cache.get(url) + + /** Forgets [url]'s key, e.g. when the message that carried it expired (CORD-08). */ + fun remove(url: String) { + cache.remove(url) + } } class DecryptInformation( diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordInviteRegistryPublishTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordInviteRegistryPublishTest.kt index 2ae9fbd741..982138608a 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordInviteRegistryPublishTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordInviteRegistryPublishTest.kt @@ -118,4 +118,30 @@ class ConcordInviteRegistryPublishTest { add(ConcordModeration.setInviteRegistry(inviter, cp, cid, emptyList(), editions, createdAt = 7L, owner = community.ownerPubKey)) assertFalse(fold().isPublic) } + + @Test + fun aRegistryEditChainsOntoTheFloorAwareHeadAcrossARefounding() = + runTest { + // The prior epoch reached v2 of the owner's registry; the current epoch has not (yet) + // re-wrapped it, so the honored head is the floor, not "no registry". + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val cp = community.controlPlane + val cid = community.communityId + val prior = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + prior += ConcordActions.controlEditions(listOf(ConcordModeration.setInviteRegistry(owner, cp, cid, listOf(link1), prior, 2L, owner = community.ownerPubKey)), cp) + prior += ConcordActions.controlEditions(listOf(ConcordModeration.setInviteRegistry(owner, cp, cid, listOf(link1, link2), prior, 3L, owner = community.ownerPubKey)), cp) + val v2 = prior.last() + assertEquals(2L, v2.version) + val floors = ConcordCommunityState.authorizedHeads(prior, cid, community.ownerPubKey) + + val current = ConcordActions.controlEditions(community.genesisWraps, cp) + val naive = ConcordActions.controlEditions(listOf(ConcordModeration.setInviteRegistry(owner, cp, cid, listOf(link2), current, 4L, owner = community.ownerPubKey)), cp).single() + assertEquals(1L, naive.version, "ignoring the floor forks a fresh v1 below the honored v2") + + val chained = ConcordActions.controlEditions(listOf(ConcordModeration.setInviteRegistry(owner, cp, cid, listOf(link2), current, 4L, owner = community.ownerPubKey, floors = floors)), cp).single() + assertEquals(3L, chained.version) + assertEquals(v2.hashHex, chained.prevHash?.toHexKey()) + // And the fold with the same floors honors it. + assertEquals(listOf(link2), ConcordCommunityState.fold(current + chained, cid, community.ownerPubKey, floors).registryOf(owner.pubKey)) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinnedMediaTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinnedMediaTest.kt index 9705961af0..03faa5545d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinnedMediaTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinnedMediaTest.kt @@ -65,6 +65,8 @@ class ConcordPinnedMediaTest { val rumors = mutableListOf() val session = ConcordCommunitySession(entry, owner.pubKey) { _, _, rumor, _ -> rumors += rumor } community.genesisWraps.forEach { session.ingest(it) } + // The genesis wraps are the whole plane; pin writes wait for a drained fold (CORD-04 §7). + session.markControlDrained() val general = community.generalChannelIdHex val plane = assertNotNull(session.currentChannelPlane(general)) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinningTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinningTest.kt index 18fbf29889..f83af59cd1 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinningTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinningTest.kt @@ -76,9 +76,10 @@ class ConcordPinningTest { val community: NewConcordCommunity, entry: ConcordCommunityListEntry, me: HexKey, + drained: Boolean = true, ) { val rumors = mutableListOf() - val session = ConcordCommunitySession(entry, me) { _, _, rumor, _ -> rumors += rumor } + val session = ConcordCommunitySession(entry, me) { _, _, rumor, _ -> rumors += rumor }.also { if (drained) it.markControlDrained() } fun pins(channelIdHex: HexKey) = ConcordPinEvidence(rumors).let { evidence -> assertNotNull(session.readPins(channelIdHex, evidence::isKilled, evidence::newestEdit)) } @@ -139,6 +140,60 @@ class ConcordPinningTest { return h } + @Test + fun noPinWriteIsBuiltBeforeTheControlPlaneHasDrained() = + runTest { + val h = harness() + val general = h.community.generalChannelIdHex + val message = h.post(alice, general, "ship it", 10L) + assertEquals(ConcordPinOutcome.PUBLISHED, h.pin(owner, general, message, 11L).outcome) + + // A second device that has folded only part of the plane: here the genesis without the pin. + val partial = Harness(h.community, entryFor(h.community), owner.pubKey, drained = false) + h.community.genesisWraps.forEach { partial.session.ingest(it) } + val read = partial.pins(general) + assertFalse(read.complete, "no head yet reads as not-yet-served, not as an empty list") + assertNull(read.head) + + // A replace-entire write from that read would erase the pin it never saw (§7). + val refused = ConcordPinning.unpin(partial.ctx(owner, general), message.id, 12L) + assertEquals(ConcordPinOutcome.NOT_FOLDED, refused.outcome) + assertNull(refused.wrap) + + // Once the plane is drained (the pin landed), writes proceed from the full list. + h.session.controlPlaneWraps().forEach { partial.session.ingest(it) } + partial.session.markControlDrained() + assertTrue(partial.pins(general).complete) + assertEquals(ConcordPinOutcome.PUBLISHED, ConcordPinning.unpin(partial.ctx(owner, general), message.id, 12L).outcome) + } + + @Test + fun aPinThatLosesAConcurrentTieReappliesOnTopOfTheWinner() = + runTest { + val h = harness() + val general = h.community.generalChannelIdHex + val one = h.post(alice, general, "one", 10L) + val two = h.post(alice, general, "two", 11L) + + // Two curators read the same (empty) head and each write v1 at once. + val ctx = h.ctx(owner, general) + val a = ConcordPinning.pin(ctx, h.session.pinSource(general, one.id)!!, 12L) + val b = ConcordPinning.pin(ctx, h.session.pinSource(general, two.id)!!, 12L) + h.session.ingest(a.wrap!!) + h.session.ingest(b.wrap!!) + val folded = h.pins(general) + assertTrue(folded.isPinned(one.id) xor folded.isPinned(two.id), "one edition wins the tie, the other's pin is gone") + val loser = if (folded.isPinned(one.id)) two else one + + // The re-heal: the loser runs its write again on the refolded head, chaining onto the winner. + val heal = ConcordPinning.pin(h.ctx(owner, general), h.session.pinSource(general, loser.id)!!, 13L) + assertEquals(ConcordPinOutcome.PUBLISHED, heal.outcome) + h.session.ingest(heal.wrap!!) + val healed = h.pins(general) + assertTrue(healed.isPinned(one.id) && healed.isPinned(two.id)) + assertEquals(2L, healed.head!!.version) + } + @Test fun aPinRoundTripsThroughTheControlPlaneAndUnpinRemovesIt() = runTest { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt index dd56764a26..2a4178d508 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt @@ -200,4 +200,24 @@ class ConcordCommunitySessionTest { community.genesisWraps.forEach { session.ingest(it) } assertTrue(session.state.value!!.dissolved) } + + @Test + fun noWriteIsBuiltFromAFoldThatHasNotDrained() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val session = ConcordCommunitySession(entryFor(community), owner.pubKey) + assertEquals(null, session.foldForWrite(), "nothing folded") + + // The live subscription delivered part of the plane: readable, but not a base for a write. + session.ingest(community.genesisWraps.first()) + assertTrue(session.state.value != null) + assertFalse(session.controlDrained.value) + assertEquals(null, session.foldForWrite()) + + // The sweep paged the whole plane in and flagged it: writes may chain onto this fold. + community.genesisWraps.forEach { session.ingest(it) } + session.markControlDrained() + assertEquals(session.state.value, session.foldForWrite()) + assertTrue(session.controlFloors().isEmpty(), "no prior epoch held, no floor") + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt index f803207754..372c3e7ff7 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt @@ -30,10 +30,17 @@ import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.InviteChannel +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.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -147,6 +154,30 @@ class ConcordDirectInviteInboxTest { assertSame(opened, inbox.offer(wrap)) } + @Test + fun theDmPipelineRumorPathParksWithoutAnotherDecryptAndTheSweepSkipsIt() = + runTest { + val c = community() + val counting = FlakySigner(me) + val inbox = ConcordDirectInviteInbox(counting) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + // What the NIP-17 pipeline did already: one decrypt per layer. + val seal = assertIs(wrap.unwrapOrNull(me)) + val rumor = seal.unsealRumorThrowing(me) + val opened = assertNotNull(inbox.offerRumor(wrap.copyNoContent(), seal, rumor)) + assertEquals(sender.pubKey, opened.sender) + assertEquals(0, counting.decrypts, "the inbox never decrypted again") + // The sweep fetching the same wrap later costs nothing either. + assertSame(opened, inbox.offer(wrap)) + assertEquals(0, counting.decrypts) + + // A spoofed rumor (claimed author differs from the seal's) is refused on this path too. + val spoofed = Rumor(rumor.id, stranger.pubKey, rumor.createdAt, rumor.kind, rumor.tags, rumor.content) + val other = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + val otherSeal = assertIs(other.unwrapOrNull(me)) + assertNull(inbox.offerRumor(other.copyNoContent(), otherSeal, spoofed)) + } + @Test fun declineDiscardsAndTheWrapNeverResurfaces() = runTest { @@ -200,22 +231,184 @@ class ConcordDirectInviteInboxTest { 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) + assertEquals(listOf("vip"), byWrap.getValue(catchUp.wrapId).newChannelNames()) + // Named by the held fold when it knows the channel. + assertEquals(listOf("VIP lounge"), byWrap.getValue(catchUp.wrapId).newChannelNames { if (it == vip) "VIP lounge" else null }) } @Test - fun visibleKeepsOneInvitePerCommunity() = + fun aCatchUpThatAcceptWouldRefuseIsNotOffered() = + runTest { + val c = community() + val editions = ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList() + editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineChannel(owner, c.controlPlane, c.communityId, vip.hexToByteArray(), ChannelEntity(name = "vip", private = true), editions, 2L, owner = c.ownerPubKey)), c.controlPlane) + val state = ConcordCommunityState.fold(editions, c.communityId, c.ownerPubKey) + val grant = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")) + val inbox = ConcordDirectInviteInbox(me) + // A plain member hands over a key: accept refuses it (not staff), so it is not a card. + val fromMember = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant)))) + val fromOwner = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, channels = grant)))) + val held = listOf(heldEntryOf(c)) + + val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, held, heldStateOf = { state }) + assertEquals(listOf(fromOwner.wrapId), views.map { it.wrapId }) + assertIs(ConcordDirectInviteInbox.acceptPlan(fromOwner, held.single(), state, me.pubKey)) + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(fromMember, held.single(), state, me.pubKey)) + + // Before the fold is in, the verdict is unknown: both stay (accept waits for the roster). + assertEquals(2, ConcordDirectInviteInbox.visible(inbox.pending.value.values, held).size) + // A dissolved community takes no keys at all. + assertTrue(ConcordDirectInviteInbox.visible(inbox.pending.value.values, held, heldStateOf = { state.withDissolved(true) }).isEmpty()) + } + + @Test + fun visibleKeepsOneInvitePerCommunityAndSender() = 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 newer = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = 1_700_000_100L))) + val fromStranger = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = 1_700_000_050L))) + assertEquals(3, inbox.pending.value.size) val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList()) - assertEquals(listOf(newer.wrapId), views.map { it.wrapId }) + assertEquals(listOf(newer.wrapId, fromStranger.wrapId), views.map { it.wrapId }) assertFalse(older.wrapId in views.map { it.wrapId }) } + @Test + fun aFutureDatedInviteNeitherHidesALegitOneNorOutranksIt() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val now = 1_700_000_000L + val legit = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = now - 10), nowSecs = now)) + // A stranger's invite dated a year ahead: it may show, but it cannot shadow the sender's. + val future = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = now + 365 * 86_400L), nowSecs = now)) + val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList(), nowMs = now * 1000) + assertEquals(setOf(legit.wrapId, future.wrapId), views.map { it.wrapId }.toSet()) + assertEquals(now, views.first { it.wrapId == future.wrapId }.clampedSentAt, "ranked as if sent now, not a year ahead") + + // Nor does it drag the sweep cursor into the future (D6): `since` stays near now. + assertTrue(inbox.newestWrapCreatedAt!! <= now + ConcordDirectInviteInbox.FUTURE_SKEW_SECS) + } + + @Test + fun anInviteSentBeforeTheUserLeftTheCommunityStaysBuried() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me) + val now = 1_700_000_000L + assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = now - 100), nowSecs = now)) + val leftAtMs = (now - 50) * 1000 + val removed = mapOf(c.communityIdHex to leftAtMs) + assertTrue(ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList(), nowMs = now * 1000, removedAt = removed).isEmpty()) + + // A fresh re-invite sent after leaving shows. + val fresh = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = now - 10), nowSecs = now)) + assertEquals(listOf(fresh.wrapId), ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList(), nowMs = now * 1000, removedAt = removed).map { it.wrapId }) + } + + @Test + fun followedSendersRankFirstAndHiddenSendersAreNeverParked() = + runTest { + val c = community() + val other = community() + val inbox = ConcordDirectInviteInbox(me, isHidden = { it == stranger.pubKey }, isFollowed = { it == owner.pubKey }) + val now = 1_700_000_000L + assertNull(inbox.offer(ConcordActions.buildDirectInvite(stranger, me.pubKey, inviteFor(c), createdAt = now), nowSecs = now), "a muted sender never parks") + val newer = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = now), nowSecs = now)) + val followed = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(other), createdAt = now - 1_000), nowSecs = now)) + val views = ConcordDirectInviteInbox.visible(inbox.pending.value.values, emptyList(), nowMs = now * 1000, isFollowed = { it == owner.pubKey }) + assertEquals(listOf(followed.wrapId, newer.wrapId), views.map { it.wrapId }) + } + + @Test + fun theInboxIsBoundedAndShedsTheLowestRanked() = + runTest { + val c = community() + val inbox = ConcordDirectInviteInbox(me, isFollowed = { it == owner.pubKey }) + val now = 1_700_000_000L + val followed = assertNotNull(inbox.offer(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c), createdAt = now - 10_000), nowSecs = now)) + repeat(ConcordDirectInviteInbox.MAX_PENDING) { i -> + inbox.offer(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c), createdAt = now - 5_000 + i), nowSecs = now) + } + assertEquals(ConcordDirectInviteInbox.MAX_PENDING, inbox.pending.value.size) + assertTrue(followed.wrapId in inbox.pending.value, "the followed sender's older invite outranks strangers' newer ones") + } + + @Test + fun aSignerThatCannotAnswerNowLeavesTheWrapToBeRetried() = + runTest { + val c = community() + val flaky = FlakySigner(me) + val inbox = ConcordDirectInviteInbox(flaky) + val wrap = ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c)) + + flaky.failNext = true + assertNull(inbox.offer(wrap), "the signer timed out: nothing parked") + // Not written off: the next delivery opens it. + assertNotNull(inbox.offer(wrap)) + + // A definitive failure (not for us) is written off: a later offer never decrypts again. + val notOurs = ConcordActions.buildDirectInvite(sender, stranger.pubKey, inviteFor(c)) + assertNull(inbox.offer(notOurs)) + val before = flaky.decrypts + assertNull(inbox.offer(notOurs)) + assertEquals(before, flaky.decrypts) + } + + /** Delegates to [inner], throwing a transient signer failure on the next decrypt when [failNext]. */ + private class FlakySigner( + private val inner: NostrSignerInternal, + ) : NostrSigner(inner.pubKey) { + var failNext = false + var decrypts = 0 + + override fun isWriteable() = inner.isWriteable() + + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T = inner.sign(createdAt, kind, tags, content) + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = inner.nip04Encrypt(plaintext, toPublicKey) + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = inner.nip04Decrypt(ciphertext, fromPublicKey) + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = inner.nip44Encrypt(plaintext, toPublicKey) + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String { + decrypts++ + if (failNext) { + failNext = false + throw SignerExceptions.TimedOutException("signer busy") + } + return inner.nip44Decrypt(ciphertext, fromPublicKey) + } + + override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent = inner.decryptZapEvent(event) + + override suspend fun deriveKey(nonce: HexKey) = inner.deriveKey(nonce) + + override suspend fun signPsbt(psbtHex: String) = inner.signPsbt(psbtHex) + + override fun hasForegroundSupport() = inner.hasForegroundSupport() + } + @Test fun acceptRefusesAnExpiredInvite() = runTest { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDisappearingSessionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDisappearingSessionTest.kt index 4b073861e0..1dc1422d5e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDisappearingSessionTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDisappearingSessionTest.kt @@ -36,6 +36,7 @@ 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 com.vitorpamplona.quartz.utils.ciphers.AESGCM import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -114,7 +115,6 @@ class ConcordDisappearingSessionTest { 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) { @@ -142,6 +142,32 @@ class ConcordDisappearingSessionTest { assertEquals(listOf("p"), off.tags.map { it[0] }) } + @Test + fun anEditKeepsTheOriginalMessagesDeadlineNotNowPlusTimer() = + runTest { + val (community, session) = session(day) + val general = community.generalChannelIdHex + val plane = session.currentChannelPlane(general)!! + val sentAt = 1_000_000L + val original = ChannelChat.message(owner.pubKey, general, plane.epoch, "hi", sentAt, ConcordDisappearing.withExpiration(emptyArray(), sentAt + 7 * day)) + val editedAt = sentAt + 3 * day + + // Default: the target's own deadline, verbatim, inside and outside. + val edit = ConcordActions.buildChannelEdit(owner, plane.key, general, plane.epoch, original, "hi!", editedAt) + assertEquals(sentAt + 7 * day, ConcordDisappearing.expirationOf(opened(edit, plane.key))) + assertEquals((sentAt + 7 * day).toString(), wrapExpiration(edit)) + + // A message sent without a timer stays timer-free when edited, even though a timer is on now. + val forever = ChannelChat.message(owner.pubKey, general, plane.epoch, "forever", sentAt) + val editForever = ConcordActions.buildChannelEdit(owner, plane.key, general, plane.epoch, forever, "still forever", editedAt) + assertNull(ConcordDisappearing.expirationOf(opened(editForever, plane.key))) + assertNull(wrapExpiration(editForever)) + + // A smuggled expiration in extraTags never overrides the original's. + val smuggled = ConcordActions.buildChannelEdit(owner, plane.key, general, plane.epoch, forever, "x", editedAt, arrayOf(arrayOf("expiration", "5"))) + assertNull(ConcordDisappearing.expirationOf(opened(smuggled, plane.key))) + } + @Test fun anAlreadyExpiredRumorIsRefusedAndItsWrapPurged() = runTest { @@ -195,6 +221,79 @@ class ConcordDisappearingSessionTest { assertNull(session.nextExpiry.value) } + @Test + fun theSweepPopsOnlyWhatIsDueInDeadlineOrderAndCarriesAttachmentUrls() = + runTest { + val (community, session) = session(day) + val general = community.generalChannelIdHex + val plane = session.currentChannelPlane(general)!! + val now = TimeUtils.now() + val image = listOf(ChannelChat.encryptedImageImeta("https://blossom.example/blob", "image/png", null, null, AESGCM(), null)) + val late = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "late", now, timerSecs = 3 * day) + val soon = ConcordActions.buildChannelImageMessage(owner, plane.key, general, plane.epoch, "soon", image, now, timerSecs = day) + val mid = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "mid", now, timerSecs = 2 * day) + listOf(late, soon, mid).forEach { session.ingest(it) } + assertEquals(now + day, session.nextExpiry.value, "the head of the deadline order") + + val first = session.sweepExpired(now + 2 * day) + assertEquals(listOf(soon.id, mid.id), first.map { it.wrapId }, "due ones only, soonest first") + // CORD-08 §3: the image's decryption key must go with the message. + assertEquals(listOf("https://blossom.example/blob"), first.first().attachmentUrls) + assertEquals(now + 3 * day, session.nextExpiry.value) + assertEquals(listOf(late.id), session.sweepExpired(now + 3 * day).map { it.wrapId }) + assertNull(session.nextExpiry.value) + } + + @Test + fun aSweptWrapDeliveredAgainIsNotOpenedAgain() = + runTest { + val captured = mutableListOf() + val (community, session) = session(day, captured) + val general = community.generalChannelIdHex + val plane = session.currentChannelPlane(general)!! + val stale = ConcordActions.buildChannelMessage(owner, plane.key, general, plane.epoch, "stale", TimeUtils.now() - 2 * day, timerSecs = day) + session.ingest(stale) + assertEquals(listOf(stale.id), session.sweepExpired().map { it.wrapId }) + + // A relay serving it again: claimed, dropped unopened — no new deadline, no re-sweep loop. + assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(stale)) + assertNull(session.nextExpiry.value) + assertFalse(session.isBuffered(general, stale.id)) + assertTrue(captured.none { it.content == "stale" }) + } + + @Test + fun aRefoundingCarriesTheTrackedDeadlinesIntoTheNewSession() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val registry = ConcordSessionRegistry() + val entry = entryFor(community) + registry.sync(listOf(entry), owner.pubKey) + val old = registry.sessionFor(community.communityIdHex)!! + community.genesisWraps.forEach { old.ingest(it) } + val plane = old.currentChannelPlane(community.generalChannelIdHex)!! + val now = TimeUtils.now() + val live = ConcordActions.buildChannelMessage(owner, plane.key, community.generalChannelIdHex, plane.epoch, "bye", now, timerSecs = day) + old.ingest(live) + + // The root rolls: the registry rebuilds the session, which never sees the old wrap again. + val rolled = + ConcordCommunityListEntry( + id = entry.id, + owner = entry.owner, + ownerSalt = entry.ownerSalt, + root = KeyPair().pubKey.toHexKey(), + rootEpoch = entry.rootEpoch + 1, + relays = entry.relays, + name = entry.name, + ) + registry.sync(listOf(rolled), owner.pubKey) + val fresh = registry.sessionFor(community.communityIdHex)!! + assertTrue(fresh !== old) + assertEquals(now + day, fresh.nextExpiry.value) + assertEquals(listOf(live.id), fresh.sweepExpired(now + day).map { it.wrapId }) + } + @Test fun theManagerSchedulesOnTheEarliestDeadlineAndSweepsPerCommunity() = runTest { diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 34ceee9caa..60a9a7bfec 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -619,6 +619,9 @@ This channel's pins are sealed under a key you don't hold, so they can't be shown here, and pinning is paused until they can be read. %1$d of %2$d pins · %3$d% of the size budget used Tap a pin to jump to it + Pin a disappearing message? + This message is set to disappear. Pinning it keeps its words in the channel's pin list after the timer erases the message itself. + Pin anyway Pins The pinned list is sealed under a key you don't hold. Changing it now would drop pins you can't see. This channel already has 25 pins. Unpin one first. diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt index e373ccb0f7..a9d317da50 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordDirectInvites.kt @@ -21,11 +21,14 @@ package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box 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.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ElevatedCard @@ -83,6 +86,8 @@ import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserS import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch /** @@ -177,29 +182,48 @@ private fun sendResultMessage(result: ConcordDirectInviteSendResult) = } /** - * 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. + * Sweeps the inbox relays for Direct Invites once when the hub opens (wraps the DM pipeline sees + * arrive on their own). Call it once per screen, outside any lazy list: inside a lazy item it would + * re-run every time the item scrolled back into view. + */ +@Composable +fun RefreshConcordDirectInvites(accountViewModel: AccountViewModel) { + val concord = accountViewModel.account.concord + LaunchedEffect(concord) { + try { + concord.refreshConcordDirectInvites() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("ConcordDirectInvites", "Direct Invite sweep failed", e) + } + } +} + +/** + * The Direct Invites waiting for this account (CORD-05 §6), as lazy items with Accept / Decline — + * shown at the top of the Concord communities list. Adds 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( +fun LazyListScope.concordPendingDirectInvites( + invites: List, 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 -> + item(key = "concord-direct-invites-title") { + Text( + stringRes(Res.string.concord_direct_invites_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp), + ) + } + items(invites, key = { "concord-direct-invite-" + it.wrapId }) { invite -> + Box(Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) { ConcordDirectInviteCard(invite, accountViewModel, nav) } } @@ -219,7 +243,20 @@ private fun ConcordDirectInviteCard( 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" }) + invite.catchUp -> { + // Only the channels it newly adds, named as the held community folds them. + val names = + remember(invite) { + val folded = + accountViewModel.account.concordSessions + .sessionFor(invite.communityId) + ?.state + ?.value + ?.channels + invite.newChannelNames { id -> folded?.get(id)?.definition?.name } + } + stringRes(Res.string.concord_direct_invite_catch_up, names.joinToString(", ") { "#$it" }) + } else -> stringRes(Res.string.concord_direct_invite_from, senderName) } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordPinnedMessages.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordPinnedMessages.kt index fd31299ada..627e92a3ba 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordPinnedMessages.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordPinnedMessages.kt @@ -31,6 +31,7 @@ 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.AlertDialog import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api @@ -39,6 +40,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.State @@ -51,8 +53,10 @@ 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 androidx.lifecycle.compose.collectAsStateWithLifecycle 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 @@ -61,6 +65,10 @@ import com.vitorpamplona.amethyst.commons.model.nip92IMeta.appendMissingImetaUrl import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.concord_pin_expiring_body +import com.vitorpamplona.amethyst.commons.resources.concord_pin_expiring_confirm +import com.vitorpamplona.amethyst.commons.resources.concord_pin_expiring_title 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 @@ -79,7 +87,13 @@ 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 com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -88,9 +102,15 @@ 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. + * head, the fold changes or finishes draining, a delete / Edit naming a pinned message lands in the + * cache (a held delete hides its entry at once; a held newer Edit marks it edited), or a pinned + * message's disappearing-message deadline passes (CORD-08 §3). Null until the community has folded + * the channel. + * + * The session is looked up again on every session-set change: it may not exist at first + * composition, and a Refounding replaces it — a captured one would read the dead epoch forever. */ +@OptIn(ExperimentalCoroutinesApi::class) @Composable fun rememberConcordChannelPins( communityId: String, @@ -99,28 +119,100 @@ fun rememberConcordChannelPins( ): State { val account = accountViewModel.account return produceState(null, account, communityId, channelId) { - val session = account.concordSessions.sessionFor(communityId) ?: return@produceState - val evidence = - account.cache.live.newEventBundles.filter { notes -> - notes.any { it.event is DeletionRequestEvent || it.event is ConcordChatEditEvent } + account.concordSessions.revision + .map { account.concordSessions.sessionFor(communityId) } + .distinctUntilChanged { a, b -> a === b } + .collectLatest { session -> + if (session == null) { + value = null + return@collectLatest + } + // Only deletes and Edits that name a message this list carries can change the read. + val evidence = + account.cache.live.newEventBundles.filter { notes -> + val carried = value?.rumorIds ?: return@filter true + notes.any { note -> + val event = note.event + (event is DeletionRequestEvent || event is ConcordChatEditEvent) && + event.tags.any { it.size >= 2 && it[0] == "e" && it[1] in carried } + } + } + merge(session.pinHeads.map { }, session.state.map { }, session.controlDrained.map { }, evidence.map { }) + .conflate() + .collectLatest { + // Re-read, then sleep until the next pinned message expires: an expired one + // leaves the list, and nothing else would trigger that re-read. A new trigger + // cancels the wait. + while (true) { + val pins = withContext(Dispatchers.Default) { account.concord.concordChannelPins(communityId, channelId) } + value = pins + val now = TimeUtils.now() + val next = pins?.nextExpiry(now) ?: break + delay((next - now) * 1000 + 250) + } + } } - merge(session.pinHeads.map { }, session.state.map { }, evidence.map { }).collect { - value = withContext(Dispatchers.Default) { account.concord.concordChannelPins(communityId, channelId) } - } } } +/** + * [pins] as a reader should see them: entries by a banned author (CORD-04 §4 — every client declines + * to show their posts) or by someone this account mutes or blocks are left out. + */ +@Composable +fun rememberVisibleConcordPins( + communityId: String, + pins: ConcordChannelPins, + accountViewModel: AccountViewModel, +): List { + val account = accountViewModel.account + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + val hidden by account.hiddenUsers.flow.collectAsStateWithLifecycle() + return remember(pins, revision, hidden) { + val authority = + account.concordSessions + .sessionFor(communityId) + ?.state + ?.value + ?.authority + ConcordPinning.visible(pins, isBanned = { authority?.isBanned(it) == true }, isHidden = { account.isHidden(it) }) + } +} + +/** + * The Pin action on a disappearing message (one carrying a CORD-08 `expiration`) asks first: the pin + * carries the message's words in its proof, so it keeps them readable after the timer erased the + * message everywhere else. + */ +@Composable +fun ConcordExpiringPinDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(Res.string.concord_pin_expiring_title)) }, + text = { Text(stringRes(Res.string.concord_pin_expiring_body)) }, + confirmButton = { TextButton(onClick = onConfirm) { Text(stringRes(Res.string.concord_pin_expiring_confirm)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } }, + ) +} + /** The channel header's pinned-messages entry point: a pin with a count badge. Hidden when there is nothing to show. */ @Composable fun ConcordPinnedButton( + communityId: String, pins: ConcordChannelPins?, + accountViewModel: AccountViewModel, onClick: () -> Unit, ) { - if (pins == null || (pins.count == 0 && !pins.sealedUnavailable)) return + if (pins == null) return + val count = rememberVisibleConcordPins(communityId, pins, accountViewModel).size + if (count == 0 && !pins.sealedUnavailable) return IconButton(onClick = onClick) { BadgedBox( badge = { - if (pins.count > 0) Badge { Text(pins.count.toString()) } + if (count > 0) Badge { Text(count.toString()) } }, ) { Icon(symbol = MaterialSymbols.PushPin, contentDescription = stringRes(Res.string.relay_group_pinned_content_description)) @@ -146,7 +238,11 @@ fun ConcordPinnedMessagesSheet( onDismiss: () -> Unit, ) { val canPin = remember(pins) { accountViewModel.account.concord.canPinConcord(communityId) } - val session = remember(communityId) { accountViewModel.account.concordSessions.sessionFor(communityId) } + val revision by accountViewModel.account.concordSessions.revision + .collectAsStateWithLifecycle() + val session = remember(communityId, revision) { accountViewModel.account.concordSessions.sessionFor(communityId) } + // Banned authors and muted/blocked users stay out of the sheet, as they do from the feed. + val shown = rememberVisibleConcordPins(communityId, pins, accountViewModel) ModalBottomSheet( onDismissRequest = onDismiss, @@ -174,7 +270,7 @@ fun ConcordPinnedMessagesSheet( modifier = Modifier.padding(horizontal = 16.dp), ) } - if (pins.count > 0) { + if (shown.isNotEmpty()) { Text( text = stringRes(Res.string.concord_pinned_open_hint), style = MaterialTheme.typography.labelSmall, @@ -186,12 +282,13 @@ fun ConcordPinnedMessagesSheet( if (pins.sealedUnavailable) { PinNotice(Res.string.concord_pinned_unavailable, MaterialSymbols.Lock) - } else if (pins.count == 0) { + } else if (shown.isEmpty() && pins.complete) { + // Only a drained fold may say "no pins"; before that the list may simply not be served yet. PinNotice(Res.string.concord_pinned_empty, MaterialSymbols.PushPin) } LazyColumn { - items(pins.pins, key = { it.rumorId }) { pinned -> + items(shown, key = { it.rumorId }) { pinned -> val jumpable = remember(pinned.rumorId, session) { session?.holdsRumor(pinned.rumorId) == true } PinnedRow( pinned = pinned, diff --git a/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index c4e5cf1680..cae024b0b3 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -93,6 +93,44 @@ Ranked security > interop > feature inside each group. | F9 | 04 §6 | Kick (kind 3309) | **fixed** — quartz `Guestbook.kick` writes the examples' shape (`ms`, `p`, `vac`), `Guestbook.parse` reads Kicks too (target, author, `ms` basis, rumor id, citation; malformed `ms`/target dropped; a 3306 "kick" verb is not a Kick), `Guestbook.canKick` honors one only from a KICK holder who strictly outranks the target with a synced `vac` (block-until-synced), and `Guestbook.coalesce` is the CORD-02 §5 fold (latest per npub by ms, lower rumor id on a tie, >1 h future dropped, banned authors dropped) — the old projection was createdAt-only and ignored Kicks. Commons: the session coalesces against its roster and re-coalesces on every control fold (a parked Kick lands when its Grant folds), exposes `guestbook`, `departedMembers()` (Leave/Kick not followed by newer observed activity — observation counts forward only) and `kickedMe()` (honored Kick postdating the entry's `added_at`); `allMembers()` drops the departed; guestbook seals must be encrypted. `kickConcordMember` strips roles first (MANAGE_ROLES + outrank, best-effort, as Armada) then publishes the directive; the revision tick's `drainConcordKicks` leaves the community locally (List tombstone, like Leave; network-silent) and emits a `ConcordKickNotice` the app toasts. Re-join works: `follow` bumps `added_at` past the tombstone, which also puts the old Kick behind the new membership. UI: Kick (KICK + outrank, confirm dialog) next to Ban in the members roster; departed members show a Kicked/Left badge. `amy concord kick COMMUNITY USER`. Not done: Armada's double-read debounce before self-removal, the dissolution ordering rule for Kicks (we refuse to *write* one on a dissolved community but do not date-check received ones), and Guestbook snapshots (kind 3312) | | F10 | 03 | WebXDC (kind 3310) | **fixed (plumbing only)** — 3310 was refused by the Chat gate and silently dropped. quartz `ConcordWebxdc` builds/parses what Armada puts on the kind (the spec leaves the payload opaque, examples §2.6): app **state updates** (`["i", ]`, `["alt","Webxdc update"]`, optional `info`/`document`/`summary`, JSON content) and realtime **peer signals** (`{"op":"ad","topic":<52-char base32>,"addr":…}` / `{"op":"left","topic":…}`, addr ≤ 2048, as Vector bounds it), both under the usual `channel`/`epoch`/`ms` binding; `ChannelChat.PLANE_KINDS` = `CHAT_KINDS` + 3310 and `acceptOpened(…, kinds)`, so 3310 stays out of `CHAT_KINDS` (never a row, never pinnable, still refused by `EventCache.consumeConcordRumor`). The session opens its planes with `PLANE_KINDS` and routes 3310 into a bounded per-channel buffer (`webxdcSignals(channel)`, `webxdcRevision`; 2000 per channel, CORD-08-expired ones filtered) instead of the chat store, so feeds, previews and unread counts never see it; its author still counts as observed. Amethyst has no WebXDC host. **Full support** (Armada `useConcordAppSync` + `XdcAttachment`, interoperating with Vector) would take: rendering an `.xdc` attachment (`application/x-webxdc` imeta carrying a `webxdc` session/topic id, or a topic derived from URL + message id) as an app card; a sandboxed WebView runtime exposing the webxdc JS API (`sendUpdate`/`setUpdateListener` → durable 3310 state updates for the session, read back by `#i`, with the CORD-08 `expiration`; `joinRealtimeChannel` → realtime); and for realtime an iroh gossip transport (Vector's frame format with the 36-byte `seq‖sender` trailer, topic = base32(sha256(…))) advertised/withdrawn by 3310 peer signals and folded latest-per-author (ties to `left`, >1 h future refused) — there is no relay fallback by design. The `:napplet` WebView sandbox is the natural host; iroh has no Kotlin implementation (our `:quic` is plain QUIC/HTTP3) | +## Audit 2 (2026-09-29) — batch A (non-Refounding) + +Root cause shared by P1/P13/R7: nothing told a session its Control Plane had finished its +initial drain. Now `ConcordCommunitySession.controlDrained` is set by +`syncConcordControlPlanes` once a relay pages the whole current plane (`DRAINED`) and the +swept wraps are ingested; `foldForWrite()` is the fold writers may build on (null before). +Batch B can reuse it for the ban/privatize decisions. + +| # | Status | +|---|---| +| P1 | **fixed** — `ConcordChannelPins.complete`; `ConcordPinning.refusal` → `NOT_FOLDED` until drained; the sheet never says "no pins" before the drain | +| P2 | **fixed** — `buildChannelEdit(expiration = expirationOf(target))`: an Edit carries the original's deadline verbatim (none if it has none); a smuggled `expiration` in `extraTags` is dropped (Armada `useTransport.ts`; the spec's "computed from the rumor's own created_at" reads otherwise — noted) | +| P3 | **fixed** — pin writes `publishAndConfirm` (`NOT_CONFIRMED` otherwise) and re-apply the same op atop a concurrent winner, ≤2 retries | +| P4 | **fixed** — `rememberConcordChannelPins`, the pinned sheet and `ConcordTimerIndicator` re-resolve the session on `concordSessions.revision` | +| P5 | **fixed** — the pins re-read at the soonest pinned message's NIP-40 deadline (`ConcordChannelPins.nextExpiry`) | +| P6 | **fixed** — list reads memoized by head rumor id (`ConcordPinVerifier.readList`); `PinListRead.sealedForm` (no second parse); evidence trigger filtered to deletes/Edits naming a pinned rumor; action-sheet pin state via `produceState` on `Dispatchers.Default`. The per-channel verifier cache stays the session-wide 512-entry one | +| P7 | **fixed** — deadlines kept sorted (binary-searched insert; no PriorityQueue in common code); sweeps pop only what is due; the account sweep waits 2 s past a deadline to coalesce | +| P8 | **fixed** — bounded (4096) set of swept wrap ids; re-deliveries dropped before opening | +| P9 | **fixed** — `ConcordSessionRegistry.sync` carries `trackedExpiring()` into the rebuilt session | +| P10 | **fixed** — sheet and badge leave out banned authors and muted/blocked users (`ConcordPinning.visible`) | +| P11 | **fixed** — confirm dialog before pinning a message carrying `expiration`; `amy concord pin` refuses (`expiring_message`) without `--force` | +| P12 | **fixed** — `amy concord pins` hides expired pinned messages | +| P13 | **fixed** — `editConcordMetadata` / `setConcordMessageExpiration` return false until drained (the owner too) and chain onto the floor-aware head | +| P14 | **fixed** — `ExpiredConcordRumor.attachmentUrls`; the sweep evicts them from `encryptionKeyCache` | +| recomputeNextExpiry | **fixed** — computed and assigned under a lock | +| D3 | **fixed** — `SealEvent.unsealRumorThrowing` + `ConcordDirectInvite.openRumor`/`offerRumor`: the NIP-17 seal handler decrypts once; k=3313 wraps that never open are marked seen (GiftWrap and Seal handlers), so the hub's sweep skips them. Chosen over a persisted cursor: the DM pipeline sees every invite wrap first in the same process, so the sweep re-decrypts nothing it already handled; a cold start still re-sweeps from `since = null` once per process | +| D4 | **fixed** — ≤256 parked (followed senders outrank strangers, then newer), hidden senders never park and are filtered, followed senders listed first, invites rendered as lazy items (the empty state scrolls) | +| D5 | **fixed** — an invite whose clamped `sentAt` is at or before the Community List tombstone's `removed_at` stays hidden (`ConcordChannelListState.removedAt`) | +| D6 | **fixed** — the cursor advances to `min(created_at, now + 15 min)` | +| D7 | **fixed** — dedupe per (community, sender), ranked by `sentAt` clamped to now | +| D8 | **fixed** — written off only on a definitive outcome; `openOrRetry` rethrows a transient signer failure (timeout, not approved, backgrounded, not found) and the inbox leaves the wrap for a retry | +| D10 | **fixed** — declines capped at 4096 (app + amy); `restoreDeclined` is `suspend` under the inbox mutex; the sweep runs once per hub visit outside the lazy list and rethrows cancellation; catch-up cards list only newly adopted channels, by folded name. Also (F7 note): `visible()` hides a catch-up `acceptPlan` would refuse against the held fold | +| R3 | **fixed** — a readable Invite List is authoritative in `nextLinks` (no resurrected links); registry edits are `publishAndConfirm`ed | +| R7 | **fixed** — `publishConcordInviteRegistry` skips until drained and chains onto the floor-aware head (`ConcordModeration.setInviteRegistry(floors = session.controlFloors())`) | +| R8 | **fixed** — no registry edit on a dissolved community; `retiringWouldPrivatize` is false once dissolved, so a revoke there reports `REVOKED` | +| R9 | **fixed** — after a drain, this account's registry is republished pruned when it lists an elapsed/unbacked link (once per community and epoch per process) | +| R11 | **fixed** — `invite_links_locator` memoized per (community, author) in `AuthorityResolver` | + ## Spec issues to raise upstream - CORD-06 §1 counts rekey capacity in blobs ("up to 120 participants per event"), but 120 base diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index 7f4d0d6661..ca1fd39066 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -142,6 +142,18 @@ class ConcordListResidue( return ConcordListResidue(extras, tombstones.filterNot { it === prior } + next, unparsedEntries) } + /** The latest `removed_at` (unix ms) per community this residue tombstones. */ + fun removals(): Map { + val out = HashMap() + for (t in tombstones) { + val id = (t["community_id"] as? JsonPrimitive)?.contentOrNull ?: continue + val at = (t["removed_at"] as? JsonPrimitive)?.longOrNull ?: continue + val prev = out[id] + if (prev == null || at > prev) out[id] = at + } + return out + } + /** The latest `removed_at` this residue holds for [communityId], or null when it was never left. */ fun removedAt(communityId: String): Long? = tombstones diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt index da3654d054..2d430392d0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt @@ -117,9 +117,11 @@ data class ConcordCommunityState( /** * 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). + * Never for a [dissolved] community: death wins every race (CORD-02 §9), so there is nothing + * left to Refound and a revoke there is only a revoke. */ fun retiringWouldPrivatize(linkSigners: Collection): Boolean { - if (!isPublic) return false + if (dissolved || !isPublic) return false val retiring = linkSigners.mapTo(HashSet()) { it.lowercase() } return liveInviteLinks.all { it in retiring } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt index 24aa85b7d1..c5175cc830 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.cache.ConcurrentLruCache /** * Resolves the owner-rooted authority state of a Concord community from its @@ -280,6 +281,25 @@ data class AuthorityResolver private constructor( return g.takeIf { coordinate == edition.entityIdHex } } + /** + * `invite_links_locator(community, author)` as hex, memoized: it is an HKDF per call, and the + * well-formedness gate asks it for every registry edition on every refold of every community. + * The derivation is a pure function of its inputs, so a cached value can never go stale. + */ + private val inviteLinksCoordinates = ConcurrentLruCache(1024) + + internal fun inviteLinksCoordinateHex( + communityId: ByteArray, + communityIdHex: String, + author: String, + ): String { + val key = communityIdHex + author.lowercase() + inviteLinksCoordinates.get(key)?.let { return it } + val coordinate = ConcordKeyDerivation.inviteLinksCoordinate(communityId, author.hexToByteArray()).toHexKey() + inviteLinksCoordinates.put(key, coordinate) + return coordinate + } + /** See [isWellFormed]. */ private fun wellFormed( edition: ControlEdition, @@ -297,7 +317,7 @@ data class AuthorityResolver private constructor( // 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 == inviteLinksCoordinateHex(communityId, communityIdHex, edition.author) && ConcordInviteRegistry.isWellFormed(edition.content) else -> true } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt index 316dc9aa9a..d78778c90c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt @@ -106,10 +106,13 @@ object ConcordPins { val sealedUnavailable: Boolean, /** True when the content broke a cap or the format, so every reader treats it as empty. */ val violating: Boolean, + /** True when the content is the sealed form (`{"epoch","sealed"}`) — the same answer as [isSealedForm]. */ + val sealedForm: Boolean = false, ) { companion object { val EMPTY = PinListRead(emptyList(), sealedUnavailable = false, violating = false) val VIOLATING = PinListRead(emptyList(), sealedUnavailable = false, violating = true) + val VIOLATING_SEALED = PinListRead(emptyList(), sealedUnavailable = false, violating = true, sealedForm = true) } } @@ -137,18 +140,23 @@ object ConcordPins { val entries = root["entries"] if (entries != null) return entriesOf(entries) + // From here the content is the sealed form exactly when [isSealedForm] says so — reported on + // the read so a caller need not parse the content a second time. + val sealedForm = root["sealed"] != null + val violating = if (sealedForm) PinListRead.VIOLATING_SEALED else PinListRead.VIOLATING val epoch = (root["epoch"] as? JsonPrimitive)?.takeIf { it.isString }?.content val sealed = (root["sealed"] as? JsonPrimitive)?.takeIf { it.isString }?.content - if (epoch == null || sealed == null || !DECIMAL.matches(epoch)) return PinListRead.VIOLATING - val epochValue = epoch.toLongOrNull() ?: return PinListRead.VIOLATING - val key = unsealKey(epochValue) ?: return PinListRead(emptyList(), sealedUnavailable = true, violating = false) + if (epoch == null || sealed == null || !DECIMAL.matches(epoch)) return violating + val epochValue = epoch.toLongOrNull() ?: return violating + val key = unsealKey(epochValue) ?: return PinListRead(emptyList(), sealedUnavailable = true, violating = false, sealedForm = true) val inner = try { parse(Nip44.v2.decrypt(sealed, key)) as? JsonObject } catch (_: Exception) { null - } ?: return PinListRead.VIOLATING - return entriesOf(inner["entries"] ?: return PinListRead.VIOLATING) + } ?: return violating + val read = entriesOf(inner["entries"] ?: return violating) + return PinListRead(read.entries, read.sealedUnavailable, read.violating, sealedForm = true) } private fun entriesOf(element: JsonElement): PinListRead { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt index d3c74c06ba..ae1a79ee96 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt @@ -27,6 +27,7 @@ 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.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor @@ -35,6 +36,7 @@ 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 +import kotlinx.coroutines.CancellationException /** * A Direct Invite opened by its recipient (CORD-05 §6): the bundle plus the seal-verified [sender]. @@ -118,6 +120,9 @@ object ConcordDirectInvite { ) } + /** True when [wrap] is a giftwrap carrying the `["k","3313"]` Direct Invite index tag (a hint, not authority). */ + fun isInviteTagged(wrap: Event): Boolean = wrap.kind == GiftWrapEvent.KIND && wrap.tags.any { it.size >= 2 && it[0] == TAG_K && it[1] == KIND.toString() } + /** * True when [wrap]'s NIP-40 `expiration` (unix seconds) is at or before [nowSecs]: an expired * handoff is never decrypted or surfaced. @@ -144,15 +149,27 @@ object ConcordDirectInvite { suspend fun open( wrap: Event, recipientSigner: NostrSigner, + ): OpenedDirectInvite? = definitiveOrNull { openOrRetry(wrap, recipientSigner) } + + /** + * [open], except that a failure that says nothing about the wrap — the coroutine cancelled, or + * the signer unable to answer right now (timed out, busy, not approved, not found) — is thrown + * instead of reported as "not an invite", so an inbox can leave the wrap to be retried rather + * than write it off for good. Null still means definitively not a valid invite for us. + */ + suspend fun openOrRetry( + wrap: Event, + recipientSigner: NostrSigner, ): OpenedDirectInvite? { if (wrap.kind != GiftWrapEvent.KIND) return null + val plaintext = decryptOrNull { recipientSigner.nip44Decrypt(wrap.content, wrap.pubKey) } ?: return null val seal = try { - Event.fromJson(recipientSigner.nip44Decrypt(wrap.content, wrap.pubKey)) + Event.fromJson(plaintext) } catch (_: Exception) { return null } - return openSeal(wrap.id, seal, recipientSigner) + return openSealOrRetry(wrap.id, seal, recipientSigner) } /** @@ -163,11 +180,41 @@ object ConcordDirectInvite { wrapId: HexKey, seal: Event, recipientSigner: NostrSigner, + ): OpenedDirectInvite? = definitiveOrNull { openSealOrRetry(wrapId, seal, recipientSigner) } + + /** [openSeal] with [openOrRetry]'s transient-failure contract. */ + suspend fun openSealOrRetry( + wrapId: HexKey, + seal: Event, + recipientSigner: NostrSigner, + ): OpenedDirectInvite? { + if (seal !is SealEvent) return null + if (!runCatching { seal.verify() }.getOrDefault(false)) return null + val plaintext = decryptOrNull { recipientSigner.nip44Decrypt(seal.content, seal.pubKey) } ?: return null + val rumor = + try { + Rumor.fromJson(plaintext) + } catch (_: Exception) { + return null + } + return openRumor(wrapId, seal, rumor) + } + + /** + * [openSeal] for a pipeline that already decrypted [seal] into [rumor] — the rumor exactly as the + * seal carries it, its claimed `pubkey` NOT yet overwritten by the seal's (see + * [SealEvent.unsealRumorThrowing]) — so the invite costs no second decrypt. Validates only: the + * seal's signature, the NIP-59 anti-spoofing author check, the rumor kind, the §1 bounds and the + * owner proof. Never throws. + */ + fun openRumor( + wrapId: HexKey, + seal: Event, + rumor: Rumor, ): 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. @@ -189,6 +236,41 @@ object ConcordDirectInvite { } } + /** + * True for a signer failure that says the signer could not answer *now* — timed out, not + * approved (yet), backgrounded without permission, not found — not that the payload is + * undecryptable, so the same wrap may open on a later try. A signer that tried and failed + * ([SignerExceptions.CouldNotPerformException], which is also how a local key reports a payload + * that is not for it) is definitive. + */ + fun isTransientSignerFailure(e: Throwable): Boolean = + e is SignerExceptions.TimedOutException || + e is SignerExceptions.ManuallyUnauthorizedException || + e is SignerExceptions.AutomaticallyUnauthorizedException || + e is SignerExceptions.RunningOnBackgroundWithoutAutomaticPermissionException || + e is SignerExceptions.SignerNotFoundException + + /** [decrypt]'s plaintext, null when the payload is not for us, rethrowing a transient failure. */ + private suspend fun decryptOrNull(decrypt: suspend () -> String): String? = + try { + decrypt() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (isTransientSignerFailure(e)) throw e + null + } + + /** Runs [block], turning a transient failure into null for the callers that never retry. */ + private suspend fun definitiveOrNull(block: suspend () -> OpenedDirectInvite?): OpenedDirectInvite? = + try { + block() + } catch (e: CancellationException) { + throw e + } 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. See [open], which diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistry.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistry.kt index 9d4cd3612c..70a1c5ff69 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistry.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistry.kt @@ -115,13 +115,18 @@ object ConcordInviteRegistry { /** * 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. + * their honored head) and [minted], minus [retired]. * - * 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. + * When the Invite List [list] is readable it is **authoritative**: the result is exactly the + * links it still holds live for [communityIdHex] (not tombstoned, not past `expires_at` at + * [nowSecs]) plus [minted]. A registry entry no live list entry backs is dropped — a retired + * link's list entry is gone after the tombstone merge (so it can no longer be marked dead by its + * token), and carrying the [published] entry forward would resurrect it and keep the community + * Public forever. Every link is recorded in the list before its URL is handed out, so nothing + * live is lost; and the list half heals a registry that fell behind (a link minted before any + * registry was published is re-listed on the next edit). + * + * A null [list] (unreadable) keeps [published] as is: it contributes nothing and prunes nothing. */ fun nextLinks( published: Collection, @@ -133,8 +138,9 @@ object ConcordInviteRegistry { ): List { val dead = retired.mapTo(HashSet()) { it.lowercase() } val live = LinkedHashSet() - published.forEach { live += it.lowercase() } - if (list != null) { + if (list == null) { + published.forEach { live += it.lowercase() } + } else { val tombstoned = list.tombstones.mapTo(HashSet()) { it.token } for (entry in list.entries) { if (!entry.communityId.equals(communityIdHex, ignoreCase = true)) continue diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealEvent.kt index badc0cbc7d..1ea8a13e7c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealEvent.kt @@ -66,9 +66,18 @@ class SealEvent( override fun isContentEncoded() = true - suspend fun unsealThrowing(signer: NostrSigner): Event { - val rumor = Rumor.fromJson(plainContent(signer)) + suspend fun unsealThrowing(signer: NostrSigner): Event = unsealed(unsealRumorThrowing(signer)) + /** + * The rumor exactly as this seal carries it — its claimed `pubkey` NOT yet overwritten by the + * seal's — in one decrypt. For a caller that must run the NIP-59 anti-spoofing check itself + * (rumor author == seal author) and then still wants the merged event: pass the result to + * [unsealed] rather than decrypting again. + */ + suspend fun unsealRumorThrowing(signer: NostrSigner): Rumor = Rumor.fromJson(plainContent(signer)) + + /** [rumor] (decrypted from this seal) merged into the inner event, recorded as [innerEventId]. */ + fun unsealed(rumor: Rumor): Event { val event = rumor.mergeWith(this) innerEventId = event.id diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt index 7aaf8510e3..9d365e0a35 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt @@ -167,6 +167,12 @@ class ConcordPinsTest { val noKey = ConcordPins.read(sealed) { null } assertTrue(noKey.sealedUnavailable, "unreadable is not empty: a writer must not build on it") assertTrue(noKey.entries.isEmpty()) + + // The read reports the form itself, matching isSealedForm, so nobody parses twice. + for (content in listOf(sealed, ConcordPins.serializePublic(listOf(entry)), "not json", """{"sealed":1}""", """{"epoch":"x","sealed":"y"}""")) { + assertEquals(ConcordPins.isSealedForm(content), ConcordPins.read(content) { plane.conversationKey }.sealedForm, content) + assertEquals(ConcordPins.isSealedForm(content), ConcordPins.read(content) { null }.sealedForm, content) + } } @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistryTest.kt index 522c1ca0fc..6e0405742d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRegistryTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.concord.cord05Invites +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind @@ -196,6 +197,8 @@ class ConcordInviteRegistryTest { assertFalse(state.retiringWouldPrivatize(listOf(link1))) assertTrue(state.retiringWouldPrivatize(listOf(link1, link2))) assertFalse(ControlFixtures.fold(emptyList(), owner).retiringWouldPrivatize(listOf(link1)), "already Private: nothing flips") + // A dissolved community is never Refounded (CORD-02 §9): the last retire is just a retire. + assertFalse(state.withDissolved(true).retiringWouldPrivatize(listOf(link1, link2))) } @Test @@ -259,15 +262,47 @@ class ConcordInviteRegistryTest { 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. + // The published registry still lists the expired link and one no list entry backs (link1): the + // readable list is authoritative, so both go; the recorded live link heals in; a 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) + assertEquals(listOf(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))) + // Retiring the recorded live link, with the next mint recorded too, leaves only that mint. + val withMint = ConcordInviteListDocument(entries = doc.entries + entry("05", KeyPair()), tombstones = doc.tombstones) + val link3 = withMint.entries.last().signerPubKeyHex() + assertEquals(listOf(link3), ConcordInviteRegistry.nextLinks(next, withMint, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200, retired = listOf(livePk))) // 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)) } + + @Test + fun theMemoizedRegistryCoordinateIsTheDerivedOne() { + val author = KeyPair().pubKey.toHexKey() + val derived = ConcordInviteRegistry.coordinateHex(ControlFixtures.COMMUNITY_ID_HEX.hexToByteArray(), author) + repeat(2) { + assertEquals(derived, AuthorityResolver.inviteLinksCoordinateHex(ControlFixtures.COMMUNITY_ID_HEX.hexToByteArray(), ControlFixtures.COMMUNITY_ID_HEX, author)) + } + // Keyed by community too: the same author elsewhere is a different coordinate. + val other = "ab".repeat(32) + assertEquals(ConcordInviteRegistry.coordinateHex(other.hexToByteArray(), author), AuthorityResolver.inviteLinksCoordinateHex(other.hexToByteArray(), other, author)) + } + + @Test + fun aRetiredLinkWhoseEntryTheMergeDroppedIsNotResurrectedFromThePublishedRegistry() { + val retired = KeyPair() + val kept = KeyPair() + val retiredPk = retired.pubKey.toHexKey() + val keptPk = kept.pubKey.toHexKey() + // After the tombstone merge, the retired link's entry (and its token) is gone from the list: + // only the tombstone remains, which no longer names the signer. + val merged = + ConcordInviteListDocument( + entries = listOf(entry("0a", kept)), + tombstones = listOf(ConcordInviteListTombstone("0b", ControlFixtures.COMMUNITY_ID_HEX)), + ) + // A later, unrelated registry edit (e.g. the next mint) must not carry the retired signer forward. + assertEquals(listOf(keptPk), ConcordInviteRegistry.nextLinks(listOf(retiredPk, keptPk), merged, ControlFixtures.COMMUNITY_ID_HEX, nowSecs = 200)) + } }