diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt index 4f36ea409d..50ec38bd10 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt @@ -144,7 +144,7 @@ object ConcordInviteBundle { * [InviteBundleStatus] under CORD-05 §2 replaceable semantics. The newest event * wins: a `vsk=9` revocation tombstone marks the link [InviteBundleStatus.Revoked] * even when an older, still-openable bundle is also present (so a stale relay copy - * can't resurrect a retired link). Otherwise the first `vsk=6` bundle that opens + + * can't resurrect a retired link). Otherwise the **newest** `vsk=6` bundle that opens + * validates with [token] is [InviteBundleStatus.Live]; anything else present is * [InviteBundleStatus.Unreadable], and an empty set is [InviteBundleStatus.Absent]. * @@ -160,7 +160,14 @@ object ConcordInviteBundle { ): InviteBundleStatus { val newest = wraps.maxByOrNull { it.createdAt } ?: return InviteBundleStatus.Absent if (newest.tags.vsk() == ControlEntityKind.INVITE_REVOKED) return InviteBundleStatus.Revoked - val invite = wraps.firstNotNullOfOrNull { parse(it, token)?.takeIf { i -> validate(i) } } + // Newest-first, not fetch order. [wraps] arrives straight off `fetchAll`, i.e. in relay + // arrival order, so opening whichever copy decrypts first is a coin flip between editions. + // That matters because a Refounding re-mints every live link at its OWN coordinate with the + // new epoch's root: a relay still serving the pre-Refounding bundle would otherwise hand the + // joiner the root of the epoch the community just left, and they would join, see planes + // nobody reads, and get no error saying why. The revocation branch above already resolves + // newest-wins; the live branch has to agree with it. + val invite = wraps.sortedByDescending { it.createdAt }.firstNotNullOfOrNull { parse(it, token)?.takeIf { i -> validate(i) } } return when { invite == null -> InviteBundleStatus.Unreadable isExpired(invite, nowMs) -> InviteBundleStatus.Expired(invite) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt index b0ac0bf1a9..064ec219e7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt @@ -96,6 +96,28 @@ class ConcordInviteClassifyTest { assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(tombstone, minted.bundleEvent), minted.token)) } + @Test + fun remintAtTheSameCoordinateWinsOverAStaleCopy() = + runTest { + // The Refounding path (CORD-05 §1): every live link is re-minted at its own coordinate + // carrying the new epoch's root. A relay that still serves the pre-Refounding bundle + // must not be able to hand a joiner the epoch the community just left. + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val before = inviteFor(community).copy(communityRoot = "aa".repeat(32), rootEpoch = 1L) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", before, createdAt = 1000L) + + val after = before.copy(communityRoot = "bb".repeat(32), rootEpoch = 2L) + val remint = ConcordInviteBundle.build(minted.linkSignerPrivKey, minted.token, after, createdAt = 2000L) + + // Both fetch orders must resolve to the re-mint — `fetchAll` gives no ordering guarantee. + listOf(listOf(minted.bundleEvent, remint), listOf(remint, minted.bundleEvent)).forEach { wraps -> + val status = ConcordInviteBundle.classify(wraps, minted.token) + assertTrue(status is InviteBundleStatus.Live) + assertEquals("bb".repeat(32), status.invite.communityRoot) + assertEquals(2L, status.invite.rootEpoch) + } + } + @Test fun unknownSubKindIsUnreadable() = runTest {