mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 17:23:21 +00:00
fix(concord): audit fixes — memory, folding, concurrency, notifications, UI
Address the deep-audit findings across the new Concord code: - H1: stop persisting kind-21059 ephemeral typing wraps as durable notes. EphemeralGiftWrapEvent extends GiftWrapEvent, so every heartbeat was stored forever; drop it once the session has ingested it. - H2: follow()/unfollow() now read the offline backup (entriesWithBackup), so a join racing the async backup load can no longer wipe the joined list. - M1 (banlist): fold to the head (honors a chained unban) then union in authorized editions that aren't ancestors of the head — concurrent bans are healed without resurrecting an on-chain unban (CORD-06 down-only). - M2: reproject only the newly-arrived channel wrap incrementally instead of re-decrypting the whole buffer per message (was O(n^2)); refold only projects newly-folded channels. - M3: cancel a session's old state-watcher before replacing it on a Refounding rebuild (was a coroutine + session leak per rekey). - M4: publish typing/state/members/observed-authors under the lock and make revision/observedAuthors updates atomic; clamp future-dated typing. - M5: notification Concord bypass now requires the community to be one this account has currently joined (mirrors the Marmot guard). - M6: Concord chat honors the "Messages in notifications" toggle. - L1: carry NIP-30 emoji tags on minichat replies, image captions and custom-emoji reactions. - C1: make the composer VM init() idempotent so recomposition can't wipe a picked image or an open suggestion list. - C2/C3: ConcordHome channel rows and unread badges react to the channel's own notes flow instead of the global revision (no stale rows / flicker). - C4: try/finally around mint-invite / create / save so a thrown call can't strand the button disabled. - C5: gate the typing ticker on active heartbeats so an idle channel stops waking a 2s loop. Adds regression tests for concurrent-ban union-heal and unauthorized bans. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
@@ -2084,7 +2084,7 @@ class Account(
|
||||
// A minichat reply is a kind-1111 thread comment; an inline reply is a kind-9
|
||||
// message quoting the parent; a fresh post is a plain kind-9 message.
|
||||
parent != null && replyMode == ReplyMode.MINICHAT ->
|
||||
ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now())
|
||||
ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags)
|
||||
parent != null ->
|
||||
ConcordActions.buildChannelInlineReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags)
|
||||
else ->
|
||||
@@ -2111,7 +2111,9 @@ class Account(
|
||||
val session = concordSessions.sessionFor(communityId) ?: return false
|
||||
val entry = session.entry
|
||||
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
|
||||
val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now())
|
||||
// Carry NIP-30 custom-emoji tags for any `:shortcode:` in the caption, same as a plain message.
|
||||
val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray()
|
||||
val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags)
|
||||
publishConcordWrap(entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -2187,7 +2189,10 @@ class Account(
|
||||
val entry = concordSessions.sessionFor(communityId)?.entry ?: return false
|
||||
|
||||
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
|
||||
val wrap = ConcordActions.buildChannelReaction(signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now())
|
||||
// A custom-emoji reaction is a `:shortcode:` content that needs its NIP-30 `emoji` tag to
|
||||
// resolve to an image on the other side; a plain unicode/`+` reaction yields no tags.
|
||||
val emojiTags = emoji.findEmojiTags(reaction).map { it.toTagArray() }.toTypedArray()
|
||||
val wrap = ConcordActions.buildChannelReaction(signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now(), emojiTags)
|
||||
publishConcordWrap(entry, wrap)
|
||||
return true
|
||||
}
|
||||
|
||||
+12
-1
@@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
|
||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
|
||||
@@ -298,7 +299,17 @@ class GiftWrapEventHandler(
|
||||
// the payload opens with a derived plane key, not our identity — so route
|
||||
// them to the Concord read-path first. A recognized wrap is fully handled
|
||||
// there (folded / re-projected) and must not fall through to the DM path.
|
||||
if (account.concordSessions.ingest(event)) return
|
||||
if (account.concordSessions.ingest(event)) {
|
||||
// Concord typing heartbeats ride kind-21059 ephemeral wraps that arrive
|
||||
// continuously while anyone in any joined community is composing. NIP-01
|
||||
// ephemeral events (20000–29999) must never be persisted; the session has
|
||||
// already folded the state they carried, so drop the durable wrap note now
|
||||
// to keep LocalCache from growing without bound.
|
||||
if (event is EphemeralGiftWrapEvent) {
|
||||
cache.unlinkAndRemove(listOf(eventNote))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.recipientPubKey() != account.signer.pubKey) return
|
||||
|
||||
|
||||
+7
-2
@@ -181,8 +181,13 @@ fun ConcordChannelListScreen(
|
||||
onClick = {
|
||||
minting = true
|
||||
scope.launch {
|
||||
inviteLink = account.mintConcordInvite(communityId)
|
||||
minting = false
|
||||
try {
|
||||
inviteLink = account.mintConcordInvite(communityId)
|
||||
} finally {
|
||||
// Always clear the flag — a thrown mint would otherwise leave the
|
||||
// button disabled until the screen is recreated.
|
||||
minting = false
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
|
||||
+8
-2
@@ -301,10 +301,16 @@ private fun ConcordTypingIndicator(
|
||||
val typingMap by session.typing.collectAsStateWithLifecycle()
|
||||
|
||||
var nowSecs by remember { mutableLongStateOf(TimeUtils.now()) }
|
||||
LaunchedEffect(session) {
|
||||
// Only tick while this channel actually has heartbeats, and stop once they've all aged out of
|
||||
// the freshness window — an idle channel must not wake a 2s recomposition loop forever. A new
|
||||
// heartbeat re-keys this effect (the map value changes) and restarts the fade.
|
||||
LaunchedEffect(session, channelId, typingMap[channelId]) {
|
||||
val perChannel = typingMap[channelId]
|
||||
if (perChannel.isNullOrEmpty()) return@LaunchedEffect
|
||||
while (true) {
|
||||
delay(2000L)
|
||||
nowSecs = TimeUtils.now()
|
||||
if (perChannel.values.none { nowSecs - it <= ConcordCommunitySession.TYPING_STALE_SECS }) break
|
||||
delay(2000L)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-7
@@ -130,13 +130,17 @@ fun ConcordCreateScreen(
|
||||
working = true
|
||||
scope.launch {
|
||||
val communityId =
|
||||
accountViewModel.account.createConcordCommunity(
|
||||
name = name.value.trim(),
|
||||
description = about.value.trim().ifBlank { null },
|
||||
relays = relays.map { it.url },
|
||||
icon = icon.value,
|
||||
)
|
||||
working = false
|
||||
try {
|
||||
accountViewModel.account.createConcordCommunity(
|
||||
name = name.value.trim(),
|
||||
description = about.value.trim().ifBlank { null },
|
||||
relays = relays.map { it.url },
|
||||
icon = icon.value,
|
||||
)
|
||||
} finally {
|
||||
// Always re-enable — a thrown create would otherwise strand the button.
|
||||
working = false
|
||||
}
|
||||
if (communityId != null) nav.newStack(Route.ConcordServer(communityId))
|
||||
}
|
||||
},
|
||||
|
||||
+13
-9
@@ -174,15 +174,19 @@ fun ConcordEditScreen(
|
||||
working = true
|
||||
scope.launch {
|
||||
val ok =
|
||||
account.editConcordMetadata(
|
||||
communityId = communityId,
|
||||
name = name.value.trim(),
|
||||
description = about.value.trim().ifBlank { null },
|
||||
icon = icon.value,
|
||||
banner = banner.value,
|
||||
relays = relays.map { it.url },
|
||||
)
|
||||
working = false
|
||||
try {
|
||||
account.editConcordMetadata(
|
||||
communityId = communityId,
|
||||
name = name.value.trim(),
|
||||
description = about.value.trim().ifBlank { null },
|
||||
icon = icon.value,
|
||||
banner = banner.value,
|
||||
relays = relays.map { it.url },
|
||||
)
|
||||
} finally {
|
||||
// Always re-enable — a thrown save would otherwise strand the button.
|
||||
working = false
|
||||
}
|
||||
if (ok) nav.popBack()
|
||||
}
|
||||
},
|
||||
|
||||
+23
-10
@@ -78,7 +78,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
|
||||
|
||||
/**
|
||||
@@ -230,7 +229,6 @@ fun ConcordHomeScreen(
|
||||
def?.private == true -> MaterialSymbols.Lock
|
||||
else -> MaterialSymbols.Tag
|
||||
},
|
||||
revision = revision,
|
||||
hideIfRead = mode == ChannelExpand.UNREAD,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = { nav.nav(Route.Concord(entry.id, ch.key)) },
|
||||
@@ -270,15 +268,22 @@ private fun communityUnreadCount(
|
||||
account: Account,
|
||||
communityId: String,
|
||||
channelKeys: Set<String>,
|
||||
revision: Int,
|
||||
): Int {
|
||||
if (channelKeys.isEmpty()) return 0
|
||||
// Keyed only on the channel set (not the global revision): each per-channel flow reacts to both
|
||||
// its last-read marker AND the channel's own notes flow, so a folded message flips the badge
|
||||
// without tearing down and restarting every flow on every unrelated fold (which reset the badge
|
||||
// to 0 and made it flicker).
|
||||
val flow =
|
||||
remember(communityId, channelKeys, revision) {
|
||||
remember(communityId, channelKeys) {
|
||||
combine(
|
||||
channelKeys.map { key ->
|
||||
account.loadLastReadFlow(concordChannelLastReadRoute(communityId, key)).map { lastRead ->
|
||||
val last = LocalCache.getConcordChannelIfExists(ConcordChannelId(communityId, key))?.lastNote?.createdAt() ?: 0L
|
||||
val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, key))
|
||||
combine(
|
||||
account.loadLastReadFlow(concordChannelLastReadRoute(communityId, key)),
|
||||
channel.flow().notes.stateFlow,
|
||||
) { lastRead, state ->
|
||||
val last = state.channel.lastNote?.createdAt() ?: 0L
|
||||
if (last > lastRead) 1 else 0
|
||||
}
|
||||
},
|
||||
@@ -301,7 +306,7 @@ private fun CommunityHeader(
|
||||
) {
|
||||
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
|
||||
val iconModel = rememberConcordImageModel(iconPointer, accountViewModel)
|
||||
val unread = communityUnreadCount(accountViewModel.account, communityId, channelKeys, revision)
|
||||
val unread = communityUnreadCount(accountViewModel.account, communityId, channelKeys)
|
||||
// Tap cycles CLOSED → UNREAD → OPEN → CLOSED, skipping the UNREAD peek when nothing is unread
|
||||
// (so a quiet community never lands on an empty middle state).
|
||||
val next =
|
||||
@@ -412,14 +417,22 @@ private fun ConcordChannelRow(
|
||||
channelKey: String,
|
||||
channelName: String,
|
||||
icon: MaterialSymbol,
|
||||
revision: Int,
|
||||
hideIfRead: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val account = accountViewModel.account
|
||||
val channel = remember(communityId, channelKey) { LocalCache.getConcordChannelIfExists(ConcordChannelId(communityId, channelKey)) }
|
||||
val lastNote = remember(revision, channel) { channel?.lastNote }
|
||||
// getOrCreate (not getIfExists): a channel folded in the control plane may have no message-buffer
|
||||
// note yet, and caching that null for the row's lifetime would leave it perpetually blank. The
|
||||
// channel's own notes flow then makes lastNote reactive, so the preview/unread dot appears the
|
||||
// moment its first message folds in — without keying on the global revision (which flickered the
|
||||
// whole row on every unrelated fold).
|
||||
val channel = remember(communityId, channelKey) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey)) }
|
||||
val channelState by channel
|
||||
.flow()
|
||||
.notes.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
val lastNote = channelState.channel.lastNote
|
||||
val lastReadTime by account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)).collectAsStateWithLifecycle()
|
||||
val unread = (lastNote?.createdAt() ?: Long.MIN_VALUE) > lastReadTime
|
||||
|
||||
|
||||
+4
@@ -75,6 +75,10 @@ open class ConcordNewMessageViewModel : ViewModel() {
|
||||
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
|
||||
|
||||
open fun init(accountVM: AccountViewModel) {
|
||||
// Idempotent: the screen calls init() on every recomposition, and it recomposes often while
|
||||
// paging history. Rebuilding uploadState/suggestion state each time would wipe a picked image
|
||||
// mid-upload or reset an open @/emoji suggestion list, so only (re)build when the account changes.
|
||||
if (::accountViewModel.isInitialized && this.accountViewModel === accountVM) return
|
||||
this.accountViewModel = accountVM
|
||||
this.account = accountVM.account
|
||||
|
||||
|
||||
+13
-1
@@ -444,7 +444,19 @@ class NotificationFeedFilter(
|
||||
// in a community I've joined is relevant whether or not I follow that member (fellow members
|
||||
// usually aren't follows). The p-tag gate below still applies, so only genuine replies /
|
||||
// reactions / mentions notify — general channel chatter that doesn't tag me never does.
|
||||
val isConcord = it.inGatherers?.any { g -> g is ConcordChannel } == true
|
||||
//
|
||||
// A ConcordChannel gatherer alone isn't enough: notes live in the global LocalCache and keep a
|
||||
// gatherer reference from every account/community that ever touched them, so require the
|
||||
// community to be one THIS account has currently joined (mirrors the Marmot check above) —
|
||||
// otherwise a note from a prior account or a left community would leak onto Notifications.
|
||||
val isConcord =
|
||||
it.inGatherers?.any { g ->
|
||||
g is ConcordChannel && account.concordSessions.sessionFor(g.channelId.communityId) != null
|
||||
} == true
|
||||
|
||||
// Concord is a messaging feature, so honor the same "Messages in notifications" toggle that
|
||||
// silences DMs and Marmot groups above.
|
||||
if (isConcord && !showMessages) return false
|
||||
|
||||
// Global keeps every event that p-tags the user; Selected (and the
|
||||
// follow/list modes) also applies the per-kind relevance heuristics.
|
||||
|
||||
+6
-3
@@ -174,8 +174,9 @@ object ConcordActions {
|
||||
text: String,
|
||||
imetas: List<IMetaTag>,
|
||||
createdAt: Long,
|
||||
extraTags: Array<Array<String>> = emptyArray(),
|
||||
): Event {
|
||||
val rumor = ChannelChat.imageMessage(authorSigner.pubKey, channelId, epoch, text, imetas, createdAt)
|
||||
val rumor = ChannelChat.imageMessage(authorSigner.pubKey, channelId, epoch, text, imetas, createdAt, extraTags)
|
||||
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
|
||||
}
|
||||
|
||||
@@ -203,8 +204,9 @@ object ConcordActions {
|
||||
parent: Event,
|
||||
text: String,
|
||||
createdAt: Long,
|
||||
extraTags: Array<Array<String>> = emptyArray(),
|
||||
): Event {
|
||||
val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent, createdAt)
|
||||
val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent, createdAt, extraTags)
|
||||
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
|
||||
}
|
||||
|
||||
@@ -217,8 +219,9 @@ object ConcordActions {
|
||||
target: Event,
|
||||
reaction: String,
|
||||
createdAt: Long,
|
||||
extraTags: Array<Array<String>> = emptyArray(),
|
||||
): Event {
|
||||
val rumor = ChannelChat.reaction(authorSigner.pubKey, channelId, epoch, target.id, target.pubKey, target.kind, reaction, createdAt)
|
||||
val rumor = ChannelChat.reaction(authorSigner.pubKey, channelId, epoch, target.id, target.pubKey, target.kind, reaction, createdAt, extraTags)
|
||||
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -103,15 +103,19 @@ class ConcordChannelListState(
|
||||
|
||||
/** Add or replace [entry] (by community id) and return the new signed list event to publish. */
|
||||
suspend fun follow(entry: ConcordCommunityListEntry): ConcordCommunityListEvent {
|
||||
val current = getConcordList()?.decrypt(signer).orEmpty()
|
||||
// Seed from the offline backup as well as the live cache event: the saved list is
|
||||
// consumed into the cache asynchronously in `init`, so a join that races that load
|
||||
// would otherwise start from an empty `current` and wipe every prior membership.
|
||||
val current = entriesWithBackup(concordListNote)
|
||||
val next = current.filterNot { it.id == entry.id } + entry
|
||||
return ConcordCommunityListEvent.create(signer, next)
|
||||
}
|
||||
|
||||
/** Drop the community with [communityId] and return the new list event, or null if none existed. */
|
||||
suspend fun unfollow(communityId: String): ConcordCommunityListEvent? {
|
||||
val event = getConcordList() ?: return null
|
||||
val next = event.decrypt(signer).filterNot { it.id == communityId }
|
||||
val current = entriesWithBackup(concordListNote)
|
||||
if (current.none { it.id == communityId }) return null
|
||||
val next = current.filterNot { it.id == communityId }
|
||||
return ConcordCommunityListEvent.create(signer, next)
|
||||
}
|
||||
|
||||
|
||||
+62
-33
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* A validated inner chat rumor emitted by a session: its parent [communityId] and
|
||||
@@ -258,10 +259,15 @@ class ConcordCommunitySession(
|
||||
ingestTyping(wrap, channelIdHex, key)
|
||||
return ConcordIngestOutcome.NON_STRUCTURAL
|
||||
}
|
||||
lock.withLock {
|
||||
channelWrapsById.getOrPut(channelIdHex) { LinkedHashMap() }.put(wrap.id, wrap)
|
||||
}
|
||||
reprojectChannel(channelIdHex)
|
||||
val isNew =
|
||||
lock.withLock {
|
||||
channelWrapsById.getOrPut(channelIdHex) { LinkedHashMap() }.put(wrap.id, wrap) == null
|
||||
}
|
||||
// Project only the newly-arrived wrap — the buffer's earlier wraps were already
|
||||
// emitted when they landed, so re-decrypting the whole history on every message
|
||||
// would be O(history) per message (quadratic over a channel's lifetime). A duplicate
|
||||
// re-delivery (isNew == false) is a no-op.
|
||||
if (isNew) emitChannelRumors(channelIdHex, key, listOf(wrap))
|
||||
// A chat message lands in the feed via [onRumor] → LocalCache, independent of the
|
||||
// revision; it changes no plane address, so it must NOT bump (see the storm note above).
|
||||
return ConcordIngestOutcome.NON_STRUCTURAL
|
||||
@@ -279,54 +285,77 @@ class ConcordCommunitySession(
|
||||
val who = rumor.pubKey.lowercase()
|
||||
if (who == myPubKey.lowercase()) return // never show my own typing back to me
|
||||
val now = TimeUtils.now()
|
||||
val snapshot =
|
||||
lock.withLock {
|
||||
val perChannel = typingByChannel.getOrPut(channelIdHex) { HashMap() }
|
||||
val prev = perChannel[who]
|
||||
if (prev == null || rumor.createdAt > prev) perChannel[who] = rumor.createdAt
|
||||
perChannel.entries.retainAll { now - it.value <= TYPING_STALE_SECS }
|
||||
if (perChannel.isEmpty()) typingByChannel.remove(channelIdHex)
|
||||
typingByChannel.mapValues { it.value.toMap() }
|
||||
}
|
||||
_typing.value = snapshot
|
||||
// Update the map and publish inside the lock so a concurrent heartbeat on another
|
||||
// channel can't publish an older snapshot last and drop this channel's typers.
|
||||
lock.withLock {
|
||||
val perChannel = typingByChannel.getOrPut(channelIdHex) { HashMap() }
|
||||
val prev = perChannel[who]
|
||||
// Clamp a peer's heartbeat to our clock: a wildly future-dated createdAt would never
|
||||
// fall out of the freshness window below and would block later real heartbeats.
|
||||
val stamp = minOf(rumor.createdAt, now)
|
||||
if (prev == null || stamp > prev) perChannel[who] = stamp
|
||||
perChannel.entries.retainAll { now - it.value <= TYPING_STALE_SECS }
|
||||
if (perChannel.isEmpty()) typingByChannel.remove(channelIdHex)
|
||||
_typing.value = typingByChannel.mapValues { it.value.toMap() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun refold() {
|
||||
val wraps = lock.withLock { controlWraps.values.toList() }
|
||||
val folded = ConcordActions.foldCommunity(wraps, controlPlaneKey, entry.owner)
|
||||
_state.value = folded
|
||||
// Read the buffer, fold, re-derive channel keys, and publish state atomically under the
|
||||
// lock so a concurrent control wrap can't publish a smaller fold last. Control editions
|
||||
// are rare (not per-message), so serializing the fold is cheap.
|
||||
val newChannels =
|
||||
lock.withLock {
|
||||
val wraps = controlWraps.values.toList()
|
||||
val folded = ConcordActions.foldCommunity(wraps, controlPlaneKey, entry.owner)
|
||||
|
||||
// Re-derive channel plane addresses from the fresh fold.
|
||||
val next = HashMap<HexKey, Pair<HexKey, GroupKey>>()
|
||||
for (channelIdHex in folded.channels.keys) {
|
||||
val key = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch)
|
||||
next[key.publicKeyHex] = channelIdHex to key
|
||||
}
|
||||
lock.withLock { channelKeysByAddress = next }
|
||||
val prevChannels = channelKeysByAddress.values.mapTo(HashSet()) { it.first }
|
||||
val next = HashMap<HexKey, Pair<HexKey, GroupKey>>()
|
||||
for (channelIdHex in folded.channels.keys) {
|
||||
val key = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch)
|
||||
next[key.publicKeyHex] = channelIdHex to key
|
||||
}
|
||||
channelKeysByAddress = next
|
||||
_state.value = folded
|
||||
folded.channels.keys.filterNot { it in prevChannels }
|
||||
}
|
||||
|
||||
// Any channel wraps already buffered can now project.
|
||||
for (channelIdHex in folded.channels.keys) reprojectChannel(channelIdHex)
|
||||
// Project only channels appearing for the first time. Existing channels' wraps were already
|
||||
// emitted incrementally as they arrived (a channel plane is only subscribed after it folds, so
|
||||
// a channel's buffer never pre-dates its first fold) — re-projecting all channels on every
|
||||
// control edition would be O(channels × history) of redundant decryption.
|
||||
for (channelIdHex in newChannels) reprojectChannel(channelIdHex)
|
||||
}
|
||||
|
||||
private fun refoldGuestbook() {
|
||||
val wraps = lock.withLock { guestbookWraps.values.toList() }
|
||||
_members.value = ConcordActions.guestbookMembers(wraps, guestbookKey)
|
||||
lock.withLock {
|
||||
val wraps = guestbookWraps.values.toList()
|
||||
_members.value = ConcordActions.guestbookMembers(wraps, guestbookKey)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reprojectChannel(channelIdHex: HexKey) {
|
||||
val key = lock.withLock { channelKeysByAddress.values.firstOrNull { it.first == channelIdHex }?.second } ?: return
|
||||
val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return
|
||||
// Decrypt + validate every bound rumor and hand it to the sink. The sink dedups
|
||||
// by rumor id, so re-emitting the whole buffer on each fold is idempotent.
|
||||
emitChannelRumors(channelIdHex, key, wraps)
|
||||
}
|
||||
|
||||
/** Decrypt + validate the given [wraps], hand each bound rumor to the sink, and fold its author into
|
||||
* the observed roster. The sink dedups by rumor id, so re-emitting a wrap is idempotent. */
|
||||
private fun emitChannelRumors(
|
||||
channelIdHex: HexKey,
|
||||
key: GroupKey,
|
||||
wraps: List<Event>,
|
||||
) {
|
||||
val authors = HashSet<HexKey>()
|
||||
ConcordActions.channelRumors(wraps, key, channelIdHex, entry.rootEpoch).forEach { rumor ->
|
||||
authors.add(rumor.pubKey.lowercase())
|
||||
onRumor(entry.id, channelIdHex, rumor)
|
||||
}
|
||||
// Every author we just decrypted is observably present (CORD-02 §5), so fold them into the
|
||||
// roster even if they never posted a Guestbook Join. Only publish when the set actually grew.
|
||||
if (authors.isNotEmpty() && !_observedAuthors.value.containsAll(authors)) {
|
||||
_observedAuthors.value = _observedAuthors.value + authors
|
||||
// roster even if they never posted a Guestbook Join. Atomic so a concurrent add isn't lost.
|
||||
if (authors.isNotEmpty()) {
|
||||
_observedAuthors.update { if (it.containsAll(authors)) it else it + authors }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-2
@@ -31,6 +31,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -86,9 +87,14 @@ class ConcordSessionManager(
|
||||
val departed = stateWatchers.keys.filterNot { it in wantedIds }
|
||||
for (id in departed) stateWatchers.remove(id)?.cancel()
|
||||
|
||||
// Watch each newly-created session so its folds bump the revision.
|
||||
// Watch each newly-created session so its folds bump the revision. A Refounding
|
||||
// rebuilds a still-joined community's session in place (same id, new root/epoch),
|
||||
// so it comes back in `created` while its old watcher is still running — cancel
|
||||
// that stale collector before replacing the map entry, or every Refounding leaks
|
||||
// a coroutine holding a dead session and bumping the revision forever.
|
||||
for (id in created) {
|
||||
val session = registry.sessionFor(id) ?: continue
|
||||
stateWatchers.remove(id)?.cancel()
|
||||
stateWatchers[id] =
|
||||
scope.launch {
|
||||
session.state.collect { bumpRevision() }
|
||||
@@ -99,7 +105,10 @@ class ConcordSessionManager(
|
||||
}
|
||||
|
||||
private fun bumpRevision() {
|
||||
_revision.value = _revision.value + 1
|
||||
// Called from the communities collector, every per-session state watcher, and the
|
||||
// ingest path — different coroutines/dispatchers — so the increment must be atomic
|
||||
// or concurrent bumps are lost.
|
||||
_revision.update { it + 1 }
|
||||
}
|
||||
|
||||
/** The `authors` set (control + known channel planes) for the kind-1059 subscription. */
|
||||
|
||||
+6
-2
@@ -121,11 +121,13 @@ object ChannelChat {
|
||||
text: String,
|
||||
parent: Event,
|
||||
createdAt: Long,
|
||||
extraTags: Array<Array<String>> = emptyArray(),
|
||||
): Event =
|
||||
RumorAssembler.assembleRumor(
|
||||
authorPubKey,
|
||||
CommentEvent.replyBuilder(text, EventHintBundle(parent), createdAt) {
|
||||
channelBinding(channelId, epoch)
|
||||
extraTags.forEach { add(it) }
|
||||
},
|
||||
)
|
||||
|
||||
@@ -146,6 +148,7 @@ object ChannelChat {
|
||||
targetKind: Int,
|
||||
content: String,
|
||||
createdAt: Long,
|
||||
extraTags: Array<Array<String>> = emptyArray(),
|
||||
): Event =
|
||||
RumorAssembler.assembleRumor<ReactionEvent>(
|
||||
pubKey = authorPubKey,
|
||||
@@ -158,7 +161,7 @@ object ChannelChat {
|
||||
arrayOf("e", targetId),
|
||||
arrayOf("p", targetAuthor),
|
||||
arrayOf("k", targetKind.toString()),
|
||||
),
|
||||
) + extraTags,
|
||||
content = content,
|
||||
)
|
||||
|
||||
@@ -177,6 +180,7 @@ object ChannelChat {
|
||||
text: String,
|
||||
imetas: List<IMetaTag>,
|
||||
createdAt: Long,
|
||||
extraTags: Array<Array<String>> = emptyArray(),
|
||||
): Event {
|
||||
val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) }
|
||||
val finalText = (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n")
|
||||
@@ -186,7 +190,7 @@ object ChannelChat {
|
||||
epoch = epoch,
|
||||
text = finalText,
|
||||
createdAt = createdAt,
|
||||
extraTags = imetas.map { it.toTagArray() }.toTypedArray(),
|
||||
extraTags = imetas.map { it.toTagArray() }.toTypedArray() + extraTags,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+46
-9
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.concord.cord04Roles
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
|
||||
/**
|
||||
* Resolves the owner-rooted authority state of a Concord community from its
|
||||
* folded Control Plane (CORD-04).
|
||||
@@ -201,18 +203,53 @@ class AuthorityResolver private constructor(
|
||||
return acc
|
||||
}
|
||||
|
||||
// Banlist: honored only from a signer holding BAN (or the owner), then healed to the head.
|
||||
val banHead =
|
||||
EditionFold.foldEntity(
|
||||
editions.filter {
|
||||
it.entityKind == ControlEntityKind.BANLIST &&
|
||||
(it.author.lowercase() == ownerLower || effectivePermissionsOf(it.author.lowercase()).has(ConcordPermissions.BAN))
|
||||
},
|
||||
)
|
||||
// Banlist: honored only from a signer holding BAN (or the owner). The banlist is a single
|
||||
// replaced doc, so fold its chain to the head first — that honors a legitimate unban, which
|
||||
// is a *chained* edition replacing the previous set (e.g. ban→unban). Then heal concurrent
|
||||
// forks: two moderators who ban different abusers at the same chain version fork the doc, and
|
||||
// folding to one head would silently drop the other's ban. Union in every authorized edition
|
||||
// that is NOT an ancestor of the head — those are the parallel bans the chain never absorbed.
|
||||
// Ancestors (superseded by the chain, including an unban's now-cleared target) are already
|
||||
// reflected by the head and must not be resurrected. This is CORD-06's "down-only healing":
|
||||
// a concurrent ban is never lost, while an on-chain unban still takes effect.
|
||||
val authorizedBanlist =
|
||||
editions.filter {
|
||||
it.entityKind == ControlEntityKind.BANLIST &&
|
||||
(it.author.lowercase() == ownerLower || effectivePermissionsOf(it.author.lowercase()).has(ConcordPermissions.BAN))
|
||||
}
|
||||
val banned = HashSet<String>()
|
||||
banHead?.let { ConcordJson.decodeBanlist(it.content) }?.forEach { banned.add(it.lowercase()) }
|
||||
val banHead = EditionFold.foldEntity(authorizedBanlist)
|
||||
if (banHead != null) {
|
||||
ConcordJson.decodeBanlist(banHead.content)?.forEach { banned.add(it.lowercase()) }
|
||||
val ancestry = banlistAncestry(banHead, authorizedBanlist)
|
||||
for (edition in authorizedBanlist) {
|
||||
if (edition.hashHex !in ancestry) {
|
||||
ConcordJson.decodeBanlist(edition.content)?.forEach { banned.add(it.lowercase()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AuthorityResolver(ownerLower, roles, memberRoles.toMap(), banned)
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of edition hashes on [head]'s back-chain (head itself plus every edition it chains
|
||||
* from via `prevHash`), among [pool]. Used to tell a superseded ancestor (already reflected by
|
||||
* the head) from a concurrent fork (a parallel ban to heal). The `add`-guarded walk also
|
||||
* terminates on any cycle.
|
||||
*/
|
||||
private fun banlistAncestry(
|
||||
head: ControlEdition,
|
||||
pool: List<ControlEdition>,
|
||||
): Set<String> {
|
||||
val byHash = pool.associateBy { it.hashHex }
|
||||
val acc = HashSet<String>()
|
||||
var cur: ControlEdition? = head
|
||||
while (cur != null && acc.add(cur.hashHex)) {
|
||||
val prev = cur.prevHash?.toHexKey()
|
||||
cur = if (prev != null) byHash[prev] else null
|
||||
}
|
||||
return acc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+47
-12
@@ -67,18 +67,23 @@ class AuthorityResolverTest {
|
||||
0,
|
||||
)
|
||||
|
||||
private fun banlist(vararg banned: String) =
|
||||
ControlEdition(
|
||||
ControlEntityKind.BANLIST,
|
||||
"44".repeat(32).hexToByteArray(),
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
"[${banned.joinToString(",") { "\"$it\"" }}]",
|
||||
owner,
|
||||
"ban",
|
||||
0,
|
||||
)
|
||||
private fun banlist(vararg banned: String) = banlistBy(owner, "ban", *banned)
|
||||
|
||||
private fun banlistBy(
|
||||
author: String,
|
||||
rumorId: String,
|
||||
vararg banned: String,
|
||||
) = ControlEdition(
|
||||
ControlEntityKind.BANLIST,
|
||||
"44".repeat(32).hexToByteArray(),
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
"[${banned.joinToString(",") { "\"$it\"" }}]",
|
||||
author,
|
||||
rumorId,
|
||||
0,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun ranksPermissionsAndActionAuthorityAreOwnerRooted() {
|
||||
@@ -152,6 +157,36 @@ class AuthorityResolverTest {
|
||||
assertFalse(r.canActOn(alice, bob, BAN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentBansHealIntoAUnionAndAreNeverDropped() {
|
||||
// Two authorized moderators ban different abusers at the same banlist version — a
|
||||
// fork of the single banlist doc. Folding to one chain tip would silently drop the
|
||||
// loser's ban and let that abuser back in; the union keeps both (M1 / CORD-06
|
||||
// down-only healing).
|
||||
val heads =
|
||||
listOf(
|
||||
role(adminRole, adminJson),
|
||||
grant("ab".repeat(32), alice, listOf(adminRole), granter = owner), // alice gains BAN
|
||||
banlistBy(owner, "ban-owner", bob), // owner bans bob
|
||||
banlistBy(alice, "ban-alice", carol), // alice concurrently bans carol
|
||||
)
|
||||
val r = AuthorityResolver.resolve(heads, owner)
|
||||
assertTrue(r.isBanned(bob))
|
||||
assertTrue(r.isBanned(carol))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun banlistEditionsFromUnauthorizedSignersAreIgnored() {
|
||||
// carol holds no BAN permission, so her ban of dave must not take effect.
|
||||
val heads =
|
||||
listOf(
|
||||
role(adminRole, adminJson),
|
||||
banlistBy(carol, "ban-carol", dave),
|
||||
)
|
||||
val r = AuthorityResolver.resolve(heads, owner)
|
||||
assertFalse(r.isBanned(dave))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletedRolesAndPositionZeroAreDropped() {
|
||||
val heads =
|
||||
|
||||
Reference in New Issue
Block a user