diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e6617f5ade..d0c2a94ba8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -362,6 +362,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.math.BigDecimal +import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.cancellation.CancellationException import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash @@ -371,6 +372,14 @@ private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not /** Name of the default Concord community Admin role minted by "Make admin". */ private const val CONCORD_ADMIN_ROLE = "Admin" +/** + * How often a joined Concord community's stored invite link is re-resolved to check whether + * we were left out of a Refounding (see `recoverStrandedConcordCommunities`). Stranding is + * rare and silent, so this trades detection latency for not turning the revision tick into a + * relay-fetch loop. + */ +private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L + @OptIn(DelicateCoroutinesApi::class) @Stable class Account( @@ -2146,6 +2155,10 @@ class Account( relays = bundle.relays, name = bundle.name, addedAt = TimeUtils.now() * 1000, + // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a + // Refounding that leaves us out of the recipient set is recoverable later. See + // recoverStrandedConcordCommunities(). + inviteRef = ConcordActions.bareInviteRef(url), ) joinConcordCommunity(entry) return ConcordInviteResult.Joined(bundle.communityId) @@ -2603,6 +2616,10 @@ class Account( relays = entry.relays, name = entry.name, addedAt = entry.addedAt, + // The invite_ref anchor must survive a rotation, or the *next* Refounding we're left + // out of would be unrecoverable. + inviteRef = entry.inviteRef, + excludedAtEpoch = entry.excludedAtEpoch, ) sendMyPublicAndPrivateOutbox(concordChannelList.follow(next)) announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null) @@ -2623,12 +2640,11 @@ class Account( * which forks a community across clients. Self-escalation to BAN is prevented * upstream by the role rank gate in AuthorityResolver. * - * KNOWN LIMIT — a rotation carries only (newRoot, newEpoch, rotator); there is no - * recipient list, so a receiver cannot tell who was left out, and a BAN-holder can - * evict anyone (the owner included) by omission. Armada does not prevent this - * either; it *recovers* from it, re-resolving the invite link the member joined - * through and merging forward to the higher epoch ("stranded recovery"). Amethyst - * has no equivalent yet, so a stranded member stays stranded. Tracked for CORD-06. + * A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list, + * so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the + * owner included) by omission — nothing on this receive path can prevent it. The + * cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves + * the invite link the membership was joined through and merges forward. */ private suspend fun drainConcordRekeys() { if (!isWriteable()) return @@ -2655,6 +2671,67 @@ class Account( } } + // Last time we re-resolved each community's invite_ref, so the recovery sweep rides the + // Concord revision tick (which fires on every structural change) without turning it into a + // relay-fetch loop. + private val lastConcordRecoveryCheck = ConcurrentHashMap() + + /** + * Stranded recovery (CORD-05/06 receive path). A Refounding carries only + * `(newRoot, newEpoch, rotator)` — **no recipient list** — so a member simply left + * out of the rekey recipient set receives nothing and sits on the dead epoch + * forever while everyone else moves on. This happens to any member, the owner + * included, and [drainConcordRekeys] cannot prevent it: there is no message to + * miss detecting. + * + * The way back is the invite link the membership was joined through + * ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and + * carried through every rotation by [adoptConcordRoot]). The community keeps + * re-minting its bundle at that same addressable coordinate, so a bundle there at + * a **strictly higher** epoch than ours proves we were left behind — and carries + * the new root. Same or lower epoch is a no-op. Memberships with no link (direct + * invites, legacy entries) are inert here; that is expected, not an error. + * + * The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps + * both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the + * entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays + * derivable). We then re-announce the Guestbook at the new epoch, exactly as an + * ordinary rotation does, so the recovered member is visible to whoever refounds + * next instead of being silently dropped again. + * + * Called on the Concord revision tick, but rate-limited per community + * ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup. + */ + private suspend fun recoverStrandedConcordCommunities() { + if (!isWriteable()) return + val now = TimeUtils.nowMillis() + for (entry in concordChannelList.liveCommunities.value) { + val inviteRef = entry.inviteRef ?: continue + val last = lastConcordRecoveryCheck[entry.id] + if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue + lastConcordRecoveryCheck[entry.id] = now + + val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue + val relays = + ( + parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + + entry.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + ).toSet() + if (relays.isEmpty()) continue + + val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } + val wraps = client.fetchAll(filters = filters) + // Only a live bundle recovers: an expired/revoked link is not a rotation we missed. + val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue + + val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue + if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue + Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}") + sendMyPublicAndPrivateOutbox(concordChannelList.follow(merged)) + announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null) + } + } + /** * Replace the community metadata (name / icon / description / relays) with a new * Control-Plane edition. Honored on fold only when this account holds @@ -5410,6 +5487,9 @@ class Account( refreshConcordChannelIndex() // A revision also bumps when a base-rotation rekey lands; adopt ours if present. runCatching { drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) } + // A rotation we were *excluded* from produces no rekey to drain, so it can only be + // found by re-resolving the invite link we joined through. Rate-limited internally. + runCatching { recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index ebbe1ecbbb..4847685fa0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.actions 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.Guestbook import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction @@ -35,6 +36,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordStrandedRecovery import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink @@ -351,6 +353,23 @@ object ConcordActions { /** Parses a shareable invite URL into its pointer + private fragment. */ fun parseInviteLink(url: String): ParsedInviteLink? = ConcordInviteLink.parseUrl(url) + /** + * Reduces an invite URL to the domain-agnostic bare `#` form + * stored as an entry's `invite_ref` (the stranded-recovery anchor). Null if the + * link is unparseable. + */ + fun bareInviteRef(url: String): String? = ConcordInviteLink.bareForm(url) + + /** + * Merges a stranded membership forward onto a higher-epoch [bundle] resolved at + * its own stored invite link, or null when there is nothing to recover. See + * [ConcordStrandedRecovery]. + */ + fun recoverStranded( + entry: ConcordCommunityListEntry, + bundle: CommunityInvite, + ): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle) + /** Decrypts + validates a fetched bundle event with the link token; null if invalid. */ fun openBundle( bundleEvent: Event, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index c59e604dc5..5f2609d746 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -49,6 +49,15 @@ class PrivateChannelKey( * [heldRoots], any [privateChannels] keys, bootstrap [relays], and a cached * display [name]. [addedAt] is the wire join timestamp (ms) that tiebreaks * liveness against tombstones. + * + * [inviteRef] is the invite link this membership was joined through, kept in the + * domain-agnostic bare `#` form (Armada's `invite_ref`, CORD-05 + * §2/§3). It is the anchor for stranded recovery: a Refounding carries no + * recipient list, so a member left out of the rekey set never hears about the new + * epoch — re-resolving this link is the only way back. Entries joined without a + * link (direct invites, legacy entries) simply have none and are inert for + * recovery. [excludedAtEpoch] records the epoch at which we observed ourselves + * excluded, if ever. */ @Serializable class ConcordCommunityListEntry( @@ -62,6 +71,8 @@ class ConcordCommunityListEntry( val relays: List = emptyList(), val name: String = "", val addedAt: Long = 0, + val inviteRef: String? = null, + val excludedAtEpoch: Long? = null, ) /** @@ -118,6 +129,8 @@ object ConcordCommunityList { val seed: JoinMaterialWire? = null, val current: JoinMaterialWire? = null, @SerialName("added_at") val addedAt: Long = 0, + @SerialName("invite_ref") val inviteRef: String? = null, + @SerialName("excluded_at_epoch") val excludedAtEpoch: Long? = null, ) @Serializable @@ -145,19 +158,24 @@ object ConcordCommunityList { heldRoots = heldRoots.map { WireHeldRoot(it.epoch, it.key) }, ) - private fun JoinMaterialWire.toEntry(addedAt: Long) = - ConcordCommunityListEntry( - id = communityId, - owner = owner, - ownerSalt = ownerSalt, - root = communityRoot, - rootEpoch = rootEpoch, - heldRoots = heldRoots.map { HeldRoot(it.epoch, it.key) }, - privateChannels = channels.map { PrivateChannelKey(it.id, it.key, it.epoch, it.name) }, - relays = relays, - name = name, - addedAt = addedAt, - ) + private fun JoinMaterialWire.toEntry( + addedAt: Long, + inviteRef: String? = null, + excludedAtEpoch: Long? = null, + ) = ConcordCommunityListEntry( + id = communityId, + owner = owner, + ownerSalt = ownerSalt, + root = communityRoot, + rootEpoch = rootEpoch, + heldRoots = heldRoots.map { HeldRoot(it.epoch, it.key) }, + privateChannels = channels.map { PrivateChannelKey(it.id, it.key, it.epoch, it.name) }, + relays = relays, + name = name, + addedAt = addedAt, + inviteRef = inviteRef, + excludedAtEpoch = excludedAtEpoch, + ) // ---- build / codec -------------------------------------------------------- @@ -183,6 +201,8 @@ object ConcordCommunityList { seed = jm, current = jm, addedAt = e.addedAt, + inviteRef = e.inviteRef, + excludedAtEpoch = e.excludedAtEpoch, ) }, tombstones = emptyList(), @@ -206,7 +226,7 @@ object ConcordCommunityList { doc.entries.mapNotNull { e -> val removedAt = latestRemoval[e.communityId] if (removedAt != null && e.addedAt <= removedAt) return@mapNotNull null - (e.current ?: e.seed)?.toEntry(e.addedAt) + (e.current ?: e.seed)?.toEntry(e.addedAt, e.inviteRef, e.excludedAtEpoch) } } catch (_: Exception) { emptyList() @@ -238,8 +258,33 @@ object ConcordCommunityList { val byId = LinkedHashMap() for (e in a + b) { val existing = byId[e.id] - if (existing == null || e.rootEpoch > existing.rootEpoch) byId[e.id] = e + if (existing == null) { + byId[e.id] = e + } else if (e.rootEpoch > existing.rootEpoch) { + // A winner without an invite_ref inherits the loser's: that link is the only anchor + // stranded recovery has, and dropping it on a merge would disarm recovery forever. + byId[e.id] = if (e.inviteRef == null) e.withInviteRef(existing.inviteRef) else e + } else if (existing.inviteRef == null && e.inviteRef != null) { + byId[e.id] = existing.withInviteRef(e.inviteRef) + } } return byId.values.toList() } + + /** Copy of this entry carrying [inviteRef]; every other field untouched. */ + fun ConcordCommunityListEntry.withInviteRef(inviteRef: String?) = + ConcordCommunityListEntry( + id = id, + owner = owner, + ownerSalt = ownerSalt, + root = root, + rootEpoch = rootEpoch, + heldRoots = heldRoots, + privateChannels = privateChannels, + relays = relays, + name = name, + addedAt = addedAt, + inviteRef = inviteRef, + excludedAtEpoch = excludedAtEpoch, + ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt index dff968e91f..ef85bd6852 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt @@ -169,7 +169,12 @@ object ConcordInviteLink { return "$trimmed/invite/$naddr#${encodeFragment(token, relays)}" } - /** Parses a full invite URL back into its pointer + fragment, or null if malformed. */ + /** + * Parses an invite link back into its pointer + fragment, or null if malformed. + * Accepts both the full `{base}/invite/{naddr}#{fragment}` URL and the + * domain-agnostic bare `{naddr}#{fragment}` form produced by [bareForm], so a + * link stored by one front end still resolves when shared through another. + */ fun parseUrl(url: String): ParsedInviteLink? { val hash = url.indexOf('#') if (hash < 0) return null @@ -180,10 +185,26 @@ object ConcordInviteLink { return null } val marker = url.indexOf("/invite/") - if (marker < 0) return null - val naddr = url.substring(marker + "/invite/".length, hash) + val naddr = + if (marker >= 0) { + url.substring(marker + "/invite/".length, hash) + } else { + // Bare `#` — no host, no path. + url.substring(0, hash) + } val parsed = NAddress.parse(naddr) ?: return null if (parsed.kind != ConcordInviteBundleEvent.KIND) return null return ParsedInviteLink(naddr, parsed.author, parsed.kind, fragment) } + + /** + * Reduces any invite link to the domain-agnostic bare `{naddr}#{fragment}` form + * (CORD-05 §2/§3 `invite_ref`), dropping any `https://host/invite/` prefix so a + * membership anchored through one front end still matches a link shared via + * another. Returns null if [url] is not a parseable invite link. + */ + fun bareForm(url: String): String? { + val parsed = parseUrl(url) ?: return null + return parsed.naddr + "#" + url.substring(url.indexOf('#') + 1) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt new file mode 100644 index 0000000000..f3ab0bd286 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot + +/** + * Stranded recovery (CORD-05/06). + * + * A Refounding carries only `(newRoot, newEpoch, rotator)` — there is **no + * recipient list** — so a member who is simply left out of the rekey recipient + * set receives nothing and is silently stranded on the dead epoch forever, while + * everyone else moves on. This is true of any member, the owner included, and + * cannot be prevented on the receive side. + * + * The way out is the invite link the membership was joined through + * ([ConcordCommunityListEntry.inviteRef]): the community keeps publishing its + * bundle at that same addressable coordinate, re-minted at the current epoch. So + * a member who re-resolves their own join link and finds a **higher** epoch than + * the one they hold knows they were left behind, and can merge forward. + * + * This object holds only the pure decision + merge; fetching and unlocking the + * bundle at the link is the caller's job. + */ +object ConcordStrandedRecovery { + /** + * True when [bundle], resolved at [entry]'s stored invite link, proves we were + * left behind: it must describe the same community and sit at a strictly higher + * epoch. Same or lower is a no-op (we are current, or the bundle is stale). + */ + fun isStranded( + entry: ConcordCommunityListEntry, + bundle: CommunityInvite, + ): Boolean = + entry.inviteRef != null && + bundle.communityId.equals(entry.id, ignoreCase = true) && + bundle.rootEpoch > entry.rootEpoch + + /** + * Merges [entry] forward onto the higher-epoch [bundle], or returns null when + * there is nothing to do ([isStranded] is false) — so the caller can treat null + * as "stay put" without a second check. + * + * The merge is epoch-monotonic (it never moves backwards, by construction of + * [isStranded]) and preserves two things the naive "adopt the bundle" would + * destroy: + * + * - the [ConcordCommunityListEntry.inviteRef] anchor, so the next Refounding we + * are left out of is recoverable too; and + * - the existing [ConcordCommunityListEntry.heldRoots], plus the root we are + * leaving, so prior-epoch history the member legitimately holds stays + * derivable instead of going dark on catch-up. + */ + fun mergeForward( + entry: ConcordCommunityListEntry, + bundle: CommunityInvite, + ): ConcordCommunityListEntry? { + if (!isStranded(entry, bundle)) return null + + val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch } + + return ConcordCommunityListEntry( + id = entry.id, + owner = entry.owner, + ownerSalt = entry.ownerSalt, + root = bundle.communityRoot, + rootEpoch = bundle.rootEpoch, + heldRoots = held, + privateChannels = entry.privateChannels, + relays = if (bundle.relays.isNotEmpty()) bundle.relays else entry.relays, + name = entry.name.ifEmpty { bundle.name }, + addedAt = entry.addedAt, + inviteRef = entry.inviteRef, + // We were excluded from the epoch we were sitting on when we found the gap. + excludedAtEpoch = entry.rootEpoch, + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt new file mode 100644 index 0000000000..6c0f9aa59c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Stranded recovery: a member left out of a Refounding's recipient set gets no rekey at + * all, so the only way they learn about the new epoch is by re-resolving the invite link + * they joined through and finding a higher-epoch bundle there. + */ +class ConcordStrandedRecoveryTest { + private val communityId = "11".repeat(32) + private val token = ByteArray(16) { it.toByte() } + private val linkSigner = KeyPair().pubKey.toHexKey() + + private val inviteRef = ConcordInviteLink.buildUrl("https://amethyst.social", linkSigner, token).substringAfter("/invite/") + + private fun entry( + epoch: Long, + ref: String? = inviteRef, + heldRoots: List = emptyList(), + ) = ConcordCommunityListEntry( + id = communityId, + owner = "0f".repeat(32), + ownerSalt = "aa".repeat(32), + root = "bb".repeat(32), + rootEpoch = epoch, + heldRoots = heldRoots, + relays = listOf("wss://relay.example"), + name = "Gamers", + addedAt = 1_700_000_000_000L, + inviteRef = ref, + ) + + private fun bundle( + epoch: Long, + root: String = "cc".repeat(32), + id: String = communityId, + ) = CommunityInvite( + communityId = id, + owner = "0f".repeat(32), + ownerSalt = "aa".repeat(32), + communityRoot = root, + rootEpoch = epoch, + relays = listOf("wss://relay.example"), + name = "Gamers", + ) + + // ---- merge forward -------------------------------------------------------- + + @Test + fun higherEpochBundleMergesForwardKeepingAnchorAndHistory() { + val prior = HeldRoot(0L, "aa".repeat(32)) + val stranded = entry(epoch = 1, heldRoots = listOf(prior)) + + val merged = ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5)) + assertNotNull(merged, "a higher-epoch bundle at our own invite link means we were left behind") + + // adopted the new epoch's access root + assertEquals(5L, merged.rootEpoch) + assertEquals("cc".repeat(32), merged.root) + + // the anchor survives, or the *next* exclusion would be unrecoverable + assertEquals(inviteRef, merged.inviteRef) + + // prior-epoch history we legitimately hold is not lost, and the root we just left + // is added so epoch-1 channels stay derivable + assertEquals(setOf(0L, 1L), merged.heldRoots.map { it.epoch }.toSet()) + assertTrue(merged.heldRoots.any { it.epoch == 0L && it.key == prior.key }) + assertTrue(merged.heldRoots.any { it.epoch == 1L && it.key == "bb".repeat(32) }) + + // identity is untouched and we record where we were dropped + assertEquals(communityId, merged.id) + assertEquals(stranded.addedAt, merged.addedAt) + assertEquals(1L, merged.excludedAtEpoch) + } + + @Test + fun sameEpochBundleIsANoOp() { + assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 5), bundle(epoch = 5))) + assertFalse(ConcordStrandedRecovery.isStranded(entry(epoch = 5), bundle(epoch = 5))) + } + + @Test + fun lowerEpochBundleIsANoOp() { + // Epoch-monotonic: a stale bundle must never walk the membership backwards. + assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 7), bundle(epoch = 3))) + } + + @Test + fun entryWithoutInviteRefIsInert() { + // Direct invites and legacy entries have no anchor — expected, not an error. + val noAnchor = entry(epoch = 1, ref = null) + assertFalse(ConcordStrandedRecovery.isStranded(noAnchor, bundle(epoch = 9))) + assertNull(ConcordStrandedRecovery.mergeForward(noAnchor, bundle(epoch = 9))) + } + + @Test + fun bundleForAnotherCommunityIsIgnored() { + assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32)))) + } + + // ---- the bare `#` anchor form ---------------------------- + + @Test + fun bareFormStripsTheHostSoAnyFrontEndsLinkMatches() { + val amethyst = ConcordInviteLink.buildUrl("https://amethyst.social", linkSigner, token) + val armada = ConcordInviteLink.buildUrl("https://someother.example", linkSigner, token) + + val bare = ConcordInviteLink.bareForm(amethyst) + assertNotNull(bare) + assertFalse(bare.contains("amethyst.social")) + assertFalse(bare.contains("/invite/")) + assertTrue(bare.startsWith("naddr")) + + // domain-agnostic: the same invite shared through a different front end reduces + // to the identical anchor, which is the whole point of storing the bare form + assertEquals(bare, ConcordInviteLink.bareForm(armada)) + } + + @Test + fun bareFormReParsesBackToTheSamePointerAndToken() { + val bare = ConcordInviteLink.bareForm(ConcordInviteLink.buildUrl("https://amethyst.social", linkSigner, token))!! + val parsed = ConcordInviteLink.parseUrl(bare) + assertNotNull(parsed, "the stored bare anchor must be re-resolvable, or recovery can never fire") + assertEquals(linkSigner, parsed.linkSignerPubKey) + assertEquals(token.toList(), parsed.fragment.token.toList()) + } + + @Test + fun bareFormOfGarbageIsNull() { + assertNull(ConcordInviteLink.bareForm("https://amethyst.social/invite/nope")) + assertNull(ConcordInviteLink.bareForm("not a link")) + } + + // ---- wire round-trip (Armada interop) ------------------------------------ + + @Test + fun inviteRefAndExcludedAtEpochRoundTripOnTheWire() { + val original = + ConcordCommunityListEntry( + id = communityId, + owner = "0f".repeat(32), + ownerSalt = "aa".repeat(32), + root = "cc".repeat(32), + rootEpoch = 5, + heldRoots = listOf(HeldRoot(0L, "aa".repeat(32)), HeldRoot(1L, "bb".repeat(32))), + relays = listOf("wss://relay.example"), + name = "Gamers", + addedAt = 1_700_000_000_000L, + inviteRef = inviteRef, + excludedAtEpoch = 1L, + ) + + val json = ConcordCommunityList.encode(listOf(original)) + + // Armada's field names, verbatim — a mismatch silently breaks interop both ways. + assertTrue(json.contains("\"invite_ref\""), "must serialize as invite_ref: $json") + assertTrue(json.contains("\"excluded_at_epoch\""), "must serialize as excluded_at_epoch: $json") + + val back = ConcordCommunityList.decode(json).single() + assertEquals(inviteRef, back.inviteRef) + assertEquals(1L, back.excludedAtEpoch) + assertEquals(5L, back.rootEpoch) + assertEquals("cc".repeat(32), back.root) + assertEquals(listOf(0L, 1L), back.heldRoots.map { it.epoch }) + } + + @Test + fun decodesArmadaEntryCarryingInviteRef() { + // Shape as Armada's communityList.ts writes it, with invite_ref / excluded_at_epoch + // at the ENTRY level (not inside the JoinMaterial). + val json = + """ + { + "entries": [ + { + "community_id": "$communityId", + "current": { + "community_id": "$communityId", + "owner": "${"0f".repeat(32)}", + "owner_salt": "${"aa".repeat(32)}", + "community_root": "${"cc".repeat(32)}", + "root_epoch": 4 + }, + "added_at": 1700000000000, + "invite_ref": "$inviteRef", + "excluded_at_epoch": 2 + } + ], + "tombstones": [] + } + """.trimIndent() + + val entry = ConcordCommunityList.decode(json).single() + assertEquals(inviteRef, entry.inviteRef) + assertEquals(2L, entry.excludedAtEpoch) + assertEquals(4L, entry.rootEpoch) + } + + @Test + fun anEntryWithoutInviteRefStillDecodes() { + // Legacy lists (and Armada entries joined by direct invite) carry no invite_ref. + val json = ConcordCommunityList.encode(listOf(entry(epoch = 1, ref = null))) + val back = ConcordCommunityList.decode(json).single() + assertNull(back.inviteRef) + assertNull(back.excludedAtEpoch) + } + + @Test + fun mergeKeepsTheAnchorWhenTheHigherEpochCopyLacksIt() { + // Two devices: one holds the anchor at the old epoch, the other rotated forward + // without it. Dropping the anchor here would disarm recovery permanently. + val anchored = entry(epoch = 1, ref = inviteRef) + val rotatedNoAnchor = entry(epoch = 4, ref = null) + + val merged = ConcordCommunityList.merge(listOf(anchored), listOf(rotatedNoAnchor)).single() + assertEquals(4L, merged.rootEpoch) + assertEquals(inviteRef, merged.inviteRef) + + // and the same regardless of argument order + val other = ConcordCommunityList.merge(listOf(rotatedNoAnchor), listOf(anchored)).single() + assertEquals(4L, other.rootEpoch) + assertEquals(inviteRef, other.inviteRef) + } +}