mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
fix(concord): a banned member re-invited after an unban can rejoin
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2966487f64
commit
ff7716f6a6
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -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)
|
||||
|
||||
+33
-3
@@ -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<ConcordDirectInviteView> {
|
||||
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))
|
||||
|
||||
+32
-1
@@ -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<InviteChannel> = 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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user