mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge branch 'worktree-agent-a61ba32a0b1aecb13' into claude/hopeful-brown-1suxdw
Audit 2 batch A fixes (drained-fold write gate, pins, CORD-08, Direct Invite inbox, Invite Registry). Resolves the overlap with the features batch: the pin duties run from the account scheduler, the pinned sheet keeps the banned/muted filter, and the pinned-media test marks its session drained. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N
This commit is contained in:
+29
-3
@@ -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<Boolean?>(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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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(
|
||||
|
||||
+20
-12
@@ -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 =
|
||||
|
||||
+2
-2
@@ -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. |
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+4
-1
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -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<Array<String>> = 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)
|
||||
}
|
||||
|
||||
|
||||
+13
-5
@@ -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<String, EntityFloor> = 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<Long, ByteArray?> = 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<String, EntityFloor> = 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<String, EntityFloor> = 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<String, EntityFloor> = 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<String, EntityFloor> = 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)
|
||||
}
|
||||
|
||||
|
||||
+73
-4
@@ -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<HexKey> 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<HexKey, ConcordPins.PinListRead>()
|
||||
|
||||
/** 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<VerifiedPin>()
|
||||
val killed = ArrayList<VerifiedPin>()
|
||||
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<ConcordPinnedMessage> = 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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
+196
-46
@@ -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<HexKey> = 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<List<ConcordDirectInviteView>> =
|
||||
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<Event>()
|
||||
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<String>()
|
||||
|
||||
/**
|
||||
* 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<ConcordCommunityListEntry>) {
|
||||
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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-6
@@ -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()
|
||||
|
||||
|
||||
+13
@@ -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<Map<String, Long>> =
|
||||
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<Set<String>> =
|
||||
|
||||
+113
-19
@@ -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<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -313,6 +315,46 @@ class ConcordCommunitySession(
|
||||
private val _state = MutableStateFlow<ConcordCommunityState?>(null)
|
||||
val state: StateFlow<ConcordCommunityState?> = _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<Boolean> = _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<String, EntityFloor> = 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<String, EntityFloor> = lastFloors
|
||||
|
||||
private val _pinHeads = MutableStateFlow<Map<HexKey, ControlEdition>>(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<ExpiredConcordRumor>()
|
||||
|
||||
/**
|
||||
* 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<HexKey>()
|
||||
|
||||
private val deadlineOrder = compareBy<ExpiredConcordRumor>({ it.expiresAt }, { it.wrapId })
|
||||
|
||||
private fun trackExpiring(
|
||||
wrapId: HexKey,
|
||||
channelIdHex: HexKey,
|
||||
rumorId: HexKey,
|
||||
expiresAt: Long,
|
||||
attachmentUrls: List<String> = 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<ExpiredConcordRumor> =
|
||||
lock.withLock {
|
||||
if (expiringByWrapId.isEmpty()) return@withLock emptyList()
|
||||
if (expiringByDeadline.isEmpty() || expiringByDeadline.first().expiresAt > now) return@withLock emptyList()
|
||||
val out = ArrayList<ExpiredConcordRumor>()
|
||||
val it = expiringByWrapId.values.iterator()
|
||||
while (it.hasNext()) {
|
||||
val expiring = it.next()
|
||||
if (expiring.expiresAt <= now) {
|
||||
channelWrapsById[expiring.channelIdHex]?.remove(expiring.wrapId)
|
||||
wrapIdByRumorId.remove(expiring.rumorId)
|
||||
out.add(expiring)
|
||||
it.remove()
|
||||
}
|
||||
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<ExpiredConcordRumor> = 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<ExpiredConcordRumor>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+178
-41
@@ -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<HexKey> = 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<String> 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<String> {
|
||||
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<HexKey>()
|
||||
|
||||
/** Wrap ids being opened right now, so a concurrent delivery of the same wrap is not decrypted twice. */
|
||||
private val inFlight = HashSet<HexKey>()
|
||||
|
||||
private val _pending = MutableStateFlow<Map<HexKey, OpenedDirectInvite>>(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<Set<HexKey>>(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<Set<HexKey>> = _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<HexKey>) {
|
||||
_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<HexKey>) {
|
||||
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<HexKey, OpenedDirectInvite>,
|
||||
opened: OpenedDirectInvite,
|
||||
nowSecs: Long,
|
||||
): Map<HexKey, OpenedDirectInvite> {
|
||||
val next = current + (opened.wrapId to opened)
|
||||
if (next.size <= MAX_PENDING) return next
|
||||
val rank = compareBy<OpenedDirectInvite>({ 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<HexKey>): Set<HexKey> = 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<OpenedDirectInvite>,
|
||||
joined: List<ConcordCommunityListEntry>,
|
||||
nowMs: Long = TimeUtils.nowMillis(),
|
||||
removedAt: Map<String, Long> = emptyMap(),
|
||||
isFollowed: (HexKey) -> Boolean = { false },
|
||||
isHidden: (HexKey) -> Boolean = { false },
|
||||
heldStateOf: (communityId: HexKey) -> ConcordCommunityState? = { null },
|
||||
): List<ConcordDirectInviteView> {
|
||||
val nowSecs = nowMs / 1000
|
||||
val heldById = joined.associateBy { it.id.lowercase() }
|
||||
val removedById = removedAt.mapKeys { it.key.lowercase() }
|
||||
val byKey = LinkedHashMap<String, ConcordDirectInviteView>()
|
||||
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<ConcordDirectInviteView> { it.opened.sentAt }.thenBy { it.wrapId })
|
||||
return byKey.values.sortedWith(
|
||||
compareByDescending<ConcordDirectInviteView> { it.followedSender }
|
||||
.thenByDescending { it.clampedSentAt }
|
||||
.thenBy { it.wrapId },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -79,6 +79,9 @@ class ConcordSessionManager(
|
||||
*/
|
||||
val nextExpiry: StateFlow<Long?> = _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<HexKey, Job>() // 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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
+5
@@ -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(
|
||||
|
||||
+26
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -65,6 +65,8 @@ class ConcordPinnedMediaTest {
|
||||
val rumors = mutableListOf<Event>()
|
||||
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))
|
||||
|
||||
|
||||
+56
-1
@@ -76,9 +76,10 @@ class ConcordPinningTest {
|
||||
val community: NewConcordCommunity,
|
||||
entry: ConcordCommunityListEntry,
|
||||
me: HexKey,
|
||||
drained: Boolean = true,
|
||||
) {
|
||||
val rumors = mutableListOf<Event>()
|
||||
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 {
|
||||
|
||||
+20
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+198
-5
@@ -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<SealEvent>(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<SealEvent>(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<DirectInviteAcceptPlan.CatchUp>(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 <T : Event> sign(
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
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 {
|
||||
|
||||
+100
-1
@@ -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<Event>()
|
||||
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 {
|
||||
|
||||
@@ -619,6 +619,9 @@
|
||||
<string name="concord_pinned_unavailable">This channel's pins are sealed under a key you don't hold, so they can't be shown here, and pinning is paused until they can be read.</string>
|
||||
<string name="concord_pinned_budget">%1$d of %2$d pins · %3$d% of the size budget used</string>
|
||||
<string name="concord_pinned_open_hint">Tap a pin to jump to it</string>
|
||||
<string name="concord_pin_expiring_title">Pin a disappearing message?</string>
|
||||
<string name="concord_pin_expiring_body">This message is set to disappear. Pinning it keeps its words in the channel's pin list after the timer erases the message itself.</string>
|
||||
<string name="concord_pin_expiring_confirm">Pin anyway</string>
|
||||
<string name="concord_pin_failed_title">Pins</string>
|
||||
<string name="concord_pin_failed_unavailable">The pinned list is sealed under a key you don't hold. Changing it now would drop pins you can't see.</string>
|
||||
<string name="concord_pin_failed_too_many">This channel already has 25 pins. Unpin one first.</string>
|
||||
|
||||
+52
-15
@@ -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<ConcordDirectInviteView>,
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+112
-15
@@ -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<ConcordChannelPins?> {
|
||||
val account = accountViewModel.account
|
||||
return produceState<ConcordChannelPins?>(null, account, communityId, channelId) {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return@produceState
|
||||
val evidence =
|
||||
account.cache.live.newEventBundles.filter { notes ->
|
||||
notes.any { it.event is DeletionRequestEvent || it.event is ConcordChatEditEvent }
|
||||
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<ConcordPinnedMessage> {
|
||||
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,
|
||||
|
||||
@@ -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", <session>]`, `["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
|
||||
|
||||
+12
@@ -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<String, Long> {
|
||||
val out = HashMap<String, Long>()
|
||||
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
|
||||
|
||||
+3
-1
@@ -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<HexKey>): Boolean {
|
||||
if (!isPublic) return false
|
||||
if (dissolved || !isPublic) return false
|
||||
val retiring = linkSigners.mapTo(HashSet()) { it.lowercase() }
|
||||
return liveInviteLinks.all { it in retiring }
|
||||
}
|
||||
|
||||
+21
-1
@@ -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<String, String>(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
|
||||
}
|
||||
|
||||
+13
-5
@@ -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 {
|
||||
|
||||
+85
-3
@@ -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
|
||||
|
||||
+14
-8
@@ -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<HexKey>,
|
||||
@@ -133,8 +138,9 @@ object ConcordInviteRegistry {
|
||||
): List<HexKey> {
|
||||
val dead = retired.mapTo(HashSet()) { it.lowercase() }
|
||||
val live = LinkedHashSet<HexKey>()
|
||||
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
|
||||
|
||||
+11
-2
@@ -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
|
||||
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+39
-4
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user