diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index adfb8d50fa..a52dbc5e38 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -195,11 +195,19 @@ class AccountConcordActions( inviteCreator: HexKey? = null, inviteLabel: String? = null, fetchedWraps: List = emptyList(), + readmit: Boolean = false, ): Boolean { // False when the List could not take the membership (not loaded, or every held fragment is // full while others are missing, CORD-02 §8). Callers must say so: the community would // otherwise look joined now and be gone after a restart. - if (!persistConcordEntry(entry)) return false + // A readmission merges into the entry the List holds at write time, keeping what it knew. + val persisted = + if (readmit) { + updateConcordEntry(entry.id) { cur -> ConcordDirectInviteInbox.readmittedEntry(cur, entry) } || persistConcordEntry(entry) + } else { + persistConcordEntry(entry) + } + if (!persisted) return false // The session is built asynchronously from the Community List flow. Every wrap that reaches // the cache before it exists is kept as an unclaimed note, and the live subscription's copy // of the same wrap is then deduplicated away, so the community showed "No channels yet" @@ -917,16 +925,12 @@ class AccountConcordActions( privateChannels = ConcordActions.privateChannelKeysOf(bundle), relays = bundle.relays, name = bundle.name, - // A readmission keeps the roots it held, so the history from before the ban stays readable. - heldRoots = - readmitting - ?.let { (it.heldRoots + HeldRoot(it.rootEpoch, it.root, it.controlPk, it.controlRoot)).distinctBy { r -> r.epoch to r.key.lowercase() } } - .orEmpty(), addedAt = TimeUtils.nowMillis(), // Anchor for stranded recovery (null for a Direct Invite, which has no link). inviteRef = inviteRef, ) - if (!joinConcordCommunity(entry, creator, label, planeWraps)) return ConcordInviteResult.NotSaved + // A readmission keeps the held entry's roots, cuts and keys (ConcordDirectInviteInbox.readmittedEntry). + if (!joinConcordCommunity(entry, creator, label, planeWraps, readmit = readmitting != null)) return ConcordInviteResult.NotSaved return ConcordInviteResult.Joined(bundle.communityId) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt index 1291c03a5b..2db9ac5f60 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt @@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst.commons.model.concord import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend @@ -390,6 +392,42 @@ class ConcordDirectInviteInbox( heldState.authority.isStaff(opened.sender) && !heldState.authority.isBanned(opened.sender) + /** + * The List entry a readmission writes: [fresh]'s base (the new root, epoch, Control Plane key, + * relays, name and join time) on top of everything [held] knew. Kept from [held]: every root + * it held plus the one it is leaving (pre-ban history), its `seed` and entry extras such as + * `channel_cuts` (so a cut key never comes back), and its private channel keys, each moved to + * the key [fresh] delivers through [ConcordChannelKeyring.withChannelKey], which keeps the one + * it replaces in `priors`. Built fresh, a readmission lost all of that. + * + * Apply it to the entry the List holds inside the write, never to a snapshot from before + * the join's suspensions. + */ + fun readmittedEntry( + held: ConcordCommunityListEntry, + fresh: ConcordCommunityListEntry, + ): ConcordCommunityListEntry { + val base = + ConcordCommunityListEntry( + id = held.id, + owner = held.owner, + ownerSalt = held.ownerSalt, + root = fresh.root, + rootEpoch = fresh.rootEpoch, + controlPk = fresh.controlPk, + // A staff write key belongs to its epoch; the new one arrives with a Grant. + controlRoot = null, + heldRoots = (held.heldRoots + HeldRoot(held.rootEpoch, held.root, held.controlPk, held.controlRoot)).distinctBy { it.epoch to it.key.lowercase() }, + privateChannels = held.privateChannels, + relays = fresh.relays.ifEmpty { held.relays }, + name = fresh.name.ifBlank { held.name }, + addedAt = fresh.addedAt, + inviteRef = fresh.inviteRef ?: held.inviteRef, + residue = held.residue, + ) + return fresh.privateChannels.fold(base) { entry, key -> ConcordChannelKeyring.withChannelKey(entry, key) ?: entry } + } + /** * What a UI shows out of [pending], given the communities this account already holds * ([joined]) and the ones it left ([removedAt], community id → the Community List diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt index c48a603ec2..35a75cc54d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt @@ -25,8 +25,10 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordModeration import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.InviteChannel @@ -511,4 +513,65 @@ class ConcordDirectInviteInboxTest { assertNotEquals(DirectInviteAcceptPlan.Readmit, ConcordDirectInviteInbox.acceptPlan(forged, held, banned, me.pubKey)) assertTrue(ConcordDirectInviteInbox.visible(listOf(forged), listOf(held), heldStateOf = { banned }, me = me.pubKey).isEmpty()) } + + @Test + fun aReadmissionKeepsWhatTheHeldEntryKnew() = + runTest { + val c = community() + val chan = "a1".repeat(32) + val gone = "c3".repeat(32) + // Held while banned: a private channel key at epoch 1 with an older prior, a cut on another + // channel, and a root from before an earlier Refounding. + val withKey = assertNotNull(ConcordChannelKeyring.withChannelKey(heldEntryOf(c), PrivateChannelKey(chan, "10".repeat(32), 0, "mods"))) + val rotated = assertNotNull(ConcordChannelKeyring.withRotatedKey(withKey, chan, "11".repeat(32), 1, retiredAt = 5)) + val held = ConcordChannelKeyring.withCut(rotated, gone, 3) + val heldWithRoots = + ConcordCommunityListEntry( + id = held.id, + owner = held.owner, + ownerSalt = held.ownerSalt, + root = held.root, + rootEpoch = held.rootEpoch, + controlPk = held.controlPk, + heldRoots = listOf(HeldRoot(held.rootEpoch - 1, "55".repeat(32), null, null)), + privateChannels = held.privateChannels, + relays = held.relays, + name = held.name, + addedAt = 1, + inviteRef = held.inviteRef, + residue = held.residue, + ) + + // The owner's readmission at the next epoch re-delivers the channel at epoch 2. + val fresh = + ConcordCommunityListEntry( + id = c.communityIdHex, + owner = c.ownerPubKey, + ownerSalt = c.ownerSalt.toHexKey(), + root = "77".repeat(32), + rootEpoch = held.rootEpoch + 1, + controlPk = "88".repeat(32), + privateChannels = listOf(PrivateChannelKey(chan, "12".repeat(32), 2, "mods")), + relays = listOf("wss://new.example"), + name = "Renamed", + addedAt = 99, + ) + val merged = ConcordDirectInviteInbox.readmittedEntry(heldWithRoots, fresh) + + // The new base. + assertEquals("77".repeat(32), merged.root) + assertEquals(held.rootEpoch + 1, merged.rootEpoch) + assertEquals("88".repeat(32), merged.controlPk) + assertEquals(listOf("wss://new.example"), merged.relays) + assertEquals(99, merged.addedAt) + // Every root it held, the one it is leaving included, so pre-ban history stays readable. + assertEquals(setOf(held.rootEpoch - 1, held.rootEpoch), merged.heldRoots.map { it.epoch }.toSet()) + // The channel moved to the delivered key, and both older keys still read their eras. + assertEquals(2, ConcordChannelKeyring.heldKey(merged, chan)?.epoch) + assertEquals(listOf(1L, 0L), ConcordChannelKeyring.historicalKeys(merged, chan).map { it.epoch }) + // The cut survives: a stale key for that channel can never come back. + assertEquals(3L, ConcordChannelKeyring.cutsOf(merged)[gone]) + // The anchor for stranded recovery is kept when the invite carries none. + assertEquals("anchor", merged.inviteRef) + } }