From ff7716f6a6c6b17b584ed419aa8fc5d9fef1d28f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 29 Sep 2026 22:09:38 -0400 Subject: [PATCH] fix(concord): a banned member re-invited after an unban can rejoin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ban in a Private community Refounds, so an owner who later unbans a member and re-invites them can only do it at a newer epoch. Armada drops a banned community from the member's list, so there the re-invite is a plain one; we keep it (read-only, with the banned notice), and the inbox hid every invite for a held community except same-base key catch-ups — so the re-invite never showed and the member could never come back. A Direct Invite for a held community whose fold still bans us, at a newer epoch, is now offered as a normal invite (acceptPlan → Readmit). Accepting runs the shared join path, which re-checks the ban against the NEW epoch's roster and fails closed, and keeps the roots already held so pre-ban history reads. A non-banned member's held base still never moves on a bundle (CORD-06 §2). amy's `concord accept` follows the same plan. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/cli/commands/ConcordCommands.kt | 13 +++++++ .../commons/model/AccountConcordActions.kt | 12 +++++-- .../model/concord/ConcordDirectInviteInbox.kt | 36 +++++++++++++++++-- .../concord/ConcordDirectInviteInboxTest.kt | 33 ++++++++++++++++- 4 files changed, 88 insertions(+), 6 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 459e1b4827..ce2ae3954b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -765,6 +765,19 @@ object ConcordCommands { } // The Join is attributed to the seal-verified sender, never the bundle's claim. DirectInviteAcceptPlan.Join -> joinBundle(ctx, dataDir, opened.invite, emptySet(), inviteRef = "", inviteCreator = opened.sender, inviteLabel = opened.invite.label) + // Readmitted at a newer epoch after a ban: the join re-checks the ban there, and the + // roots we already held stay banked so the history from before the ban reads. + DirectInviteAcceptPlan.Readmit -> { + val code = joinBundle(ctx, dataDir, opened.invite, emptySet(), inviteRef = "", inviteCreator = opened.sender, inviteLabel = opened.invite.label) + val old = heldSc + if (code == 0 && old != null) { + store.find(old.communityId)?.let { now -> + val banked = (now.heldRoots + old.heldRoots + StoredHeldRoot(old.rootEpoch, old.root, old.controlPk, old.controlRoot)).distinctBy { it.epoch to it.root.lowercase() } + store.upsert(now.copy(heldRoots = banked)) + } + } + code + } } } } 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 c12aeb7dfb..b855d5a827 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 @@ -788,6 +788,7 @@ class AccountConcordActions( inviteRef: String?, inviteCreator: HexKey?, inviteLabel: String?, + readmitting: ConcordCommunityListEntry? = null, ): ConcordInviteResult { val relays = servedBy @@ -804,7 +805,7 @@ class AccountConcordActions( account.concordChannelList.liveCommunities.value .firstOrNull { it.id == bundle.communityId } var rejoined: ConcordCommunityListEntry? = null - if (held != null) { + if (held != null && readmitting == null) { val heldState = account.concordSessions .sessionFor(held.id) @@ -879,6 +880,11 @@ 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, @@ -927,6 +933,7 @@ class AccountConcordActions( ?.state ?.value }, + me = account.signer.pubKey, ) }.stateIn(account.scope, SharingStarted.Eagerly, emptyList()) @@ -1120,7 +1127,7 @@ class AccountConcordActions( val ok = writeConcordList { list -> list.update(plan.entry.id) { cur -> ConcordInviteVend.adoptCatchUp(cur, bundle, plan.channelIds) } } if (ok) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable } - DirectInviteAcceptPlan.Join -> + DirectInviteAcceptPlan.Join, DirectInviteAcceptPlan.Readmit -> joinValidatedConcordInvite( bundle = bundle, servedBy = emptySet(), @@ -1128,6 +1135,7 @@ class AccountConcordActions( // Attributed to the seal-verified sender (Armada), never the bundle's claim. inviteCreator = opened.sender, inviteLabel = bundle.label, + readmitting = held?.takeIf { plan == DirectInviteAcceptPlan.Readmit }, ) } if (result is ConcordInviteResult.Joined) directInviteInbox.resolve(opened.wrapId) 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 fd7cbc13b5..4464c2582d 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 @@ -90,6 +90,12 @@ sealed interface DirectInviteAcceptPlan { /** A community we don't hold: run the shared join path. */ data object Join : DirectInviteAcceptPlan + /** + * A held community whose fold still bans us, re-invited at a newer epoch (see [ConcordDirectInviteInbox.isReadmission]): + * run the shared join path over the held entry, which re-checks the ban at the new epoch. + */ + data object Readmit : DirectInviteAcceptPlan + /** * A held community: store [entry] — the held one plus the newly granted Private Channel keys * ([channelIds]). A writer re-applies [channelIds] to the entry it reads inside the List write @@ -343,6 +349,7 @@ class ConcordDirectInviteInbox( ): DirectInviteAcceptPlan { if (opened.isExpired(nowMs)) return DirectInviteAcceptPlan.Expired if (held == null) return DirectInviteAcceptPlan.Join + if (isReadmission(held, heldState, opened.invite, me)) return DirectInviteAcceptPlan.Readmit if (ConcordInviteVend.catchUpChannelIds(held, opened.invite).isEmpty()) return DirectInviteAcceptPlan.NothingNew if (heldState == null) return DirectInviteAcceptPlan.RosterNotLoaded // Death wins every race (CORD-02 §9): a dissolved community takes no new keys. @@ -354,6 +361,27 @@ class ConcordDirectInviteInbox( return DirectInviteAcceptPlan.CatchUp(adopted, ids) } + /** + * True when [invite] readmits a member [heldState] still bans: a ban in a Private community + * Refounds, so an owner who later unbans and re-invites us can only do it at a newer epoch. + * Armada drops a banned community from the list, so there its re-invite is a plain one; we + * keep the banned community (read-only), and without this the re-invite was hidden and + * could never be accepted. A banned member has no live membership a bundle could hijack + * (the reason a bundle may never move a held base, CORD-06 §2), and the join re-checks the + * ban against the NEW epoch's roster, failing closed. + */ + fun isReadmission( + held: ConcordCommunityListEntry, + heldState: ConcordCommunityState?, + invite: CommunityInvite, + me: HexKey, + ): Boolean = + heldState != null && + !heldState.dissolved && + heldState.authority.isBanned(me) && + invite.communityId.equals(held.id, ignoreCase = true) && + invite.rootEpoch > held.rootEpoch + /** * 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 @@ -379,6 +407,7 @@ class ConcordDirectInviteInbox( isFollowed: (HexKey) -> Boolean = { false }, isHidden: (HexKey) -> Boolean = { false }, heldStateOf: (communityId: HexKey) -> ConcordCommunityState? = { null }, + me: HexKey? = null, ): List { val nowSecs = nowMs / 1000 val heldById = joined.associateBy { it.id.lowercase() } @@ -395,15 +424,16 @@ class ConcordDirectInviteInbox( // 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 readmit = held != null && me != null && isReadmission(held, heldState, opened.invite, me) val newChannels = when { - held == null -> emptyList() + held == null || readmit -> 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 + if (held != null && !readmit && newChannels.isEmpty()) continue + val catchUp = held != null && !readmit 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)) 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 372c3e7ff7..5203f380cf 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 @@ -47,6 +47,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertSame @@ -72,12 +73,13 @@ class ConcordDirectInviteInboxTest { expiresAt: Long? = null, channels: List = emptyList(), root: String = c.communityRoot.toHexKey(), + epoch: Long = c.rootEpoch, ) = CommunityInvite( communityId = c.communityIdHex, owner = c.ownerPubKey, ownerSalt = c.ownerSalt.toHexKey(), communityRoot = root, - rootEpoch = c.rootEpoch, + rootEpoch = epoch, controlPk = c.controlPkHex, channels = channels, relays = listOf("wss://relay.example"), @@ -473,4 +475,33 @@ class ConcordDirectInviteInboxTest { val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")))), me)) assertEquals(DirectInviteAcceptPlan.Banned, ConcordDirectInviteInbox.acceptPlan(catchUp, heldEntryOf(c), banned, me.pubKey)) } + + @Test + fun aBannedMemberReInvitedAtANewerEpochIsOfferedAReadmission() = + runTest { + val c = community() + val held = heldEntryOf(c) + val editions = ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList() + editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, c.controlPlane, c.communityId, me.pubKey, editions, createdAt = 2L, owner = c.ownerPubKey)), c.controlPlane) + val banned = ConcordCommunityState.fold(editions, c.communityId, c.ownerPubKey) + + // The ban Refounded; the owner unbanned us and re-invited us at the new epoch. + val reInvite = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, root = "77".repeat(32), epoch = c.rootEpoch + 1)), me)) + assertEquals(DirectInviteAcceptPlan.Readmit, ConcordDirectInviteInbox.acceptPlan(reInvite, held, banned, me.pubKey)) + val shown = ConcordDirectInviteInbox.visible(listOf(reInvite), listOf(held), heldStateOf = { banned }, me = me.pubKey) + assertEquals(1, shown.size) + assertFalse(shown.single().catchUp) + + // Still banned at the SAME epoch: nothing to readmit into. + val sameEpoch = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c)), me)) + assertNotEquals(DirectInviteAcceptPlan.Readmit, ConcordDirectInviteInbox.acceptPlan(sameEpoch, held, banned, me.pubKey)) + assertTrue(ConcordDirectInviteInbox.visible(listOf(sameEpoch), listOf(held), heldStateOf = { banned }, me = me.pubKey).isEmpty()) + + // Not banned: a newer-epoch bundle still never moves a held base (CORD-06 §2). + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(reInvite, held, stateOf(c), me.pubKey)) + assertTrue(ConcordDirectInviteInbox.visible(listOf(reInvite), listOf(held), heldStateOf = { stateOf(c) }, me = me.pubKey).isEmpty()) + + // Dissolved: death wins. + assertNotEquals(DirectInviteAcceptPlan.Readmit, ConcordDirectInviteInbox.acceptPlan(reInvite, held, banned.withDissolved(true), me.pubKey)) + } }