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 92bf6fb011..d506daf54a 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
@@ -157,6 +157,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
+import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
@@ -2054,14 +2055,17 @@ class Account(
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
val wraps = client.fetchAll(filters = filters)
- val bundle = wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) }
- if (bundle == null) {
- // The filter matches only kind-33301 bundles authored by this link signer, so any wrap
- // that came back IS a bundle we simply couldn't open — a wrong token or, as the reference
- // client evolves, a bundle format newer than we support. Distinguish that from "nothing on
- // any relay" so the UI skips the pointless retry on an incompatible link.
- return if (wraps.isNotEmpty()) ConcordInviteResult.Incompatible else ConcordInviteResult.NotReachable
- }
+
+ // Resolve the coordinate per CORD-05 §2 (newest wins; a vsk=9 tombstone revokes even over a
+ // stale openable copy) so we honour revocation and can tell the user *why* a link won't open
+ // instead of stranding them on a spinner that retries a link we can never redeem.
+ val bundle =
+ when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) {
+ is InviteBundleStatus.Live -> status.invite
+ InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked
+ InviteBundleStatus.Unreadable -> return ConcordInviteResult.Incompatible
+ InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable
+ }
val entry =
ConcordCommunityListEntry(
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt
index 69a39a19c1..6c9cc50222 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt
@@ -41,6 +41,12 @@ sealed interface ConcordInviteResult {
*/
data object NotReachable : ConcordInviteResult
+ /**
+ * The link was revoked: the newest event at its coordinate is a `vsk=9` revocation
+ * tombstone (CORD-05 §2). Retrying can't help — the owner retired this link.
+ */
+ data object Revoked : ConcordInviteResult
+
/**
* The bundle event was found but could not be opened with the link's token —
* typically because it was minted by a newer/incompatible Concord client whose
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt
index a9dbb85c1e..8c592fb7e7 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt
@@ -85,6 +85,8 @@ fun ConcordInviteScreen(
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_invalid, canRetry = false)
is ConcordInviteResult.Incompatible ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_incompatible, canRetry = false)
+ is ConcordInviteResult.Revoked ->
+ RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_revoked, canRetry = false)
is ConcordInviteResult.NotReachable ->
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed, canRetry = true)
}
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 791e78738d..1dd3620c16 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -311,6 +311,7 @@
Could not fetch this invite. The link may be expired or its relays unreachable.
This invite link is invalid or can\'t be opened with this account.
This invite was created with a newer version of the app and can\'t be opened here yet. Ask for an updated link or try again after updating.
+ This invite link has been revoked and can no longer be used. Ask for a new one.
Concord Channels
You haven\'t joined any Concord Channels yet. Create one, or open an invite link.
No channels yet.
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 c975e232e0..8b6bbb6528 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
@@ -34,6 +34,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.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink
import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
@@ -314,6 +315,17 @@ object ConcordActions {
token: ByteArray,
): CommunityInvite? = ConcordInviteBundle.parse(bundleEvent, token)?.takeIf { ConcordInviteBundle.validate(it) }
+ /**
+ * Resolves every event fetched at an invite's addressable coordinate into one
+ * [InviteBundleStatus] (live / revoked / unreadable / absent) per CORD-05 §2, so a
+ * redeeming client honours a `vsk=9` revocation tombstone and reports why a link
+ * can't be opened instead of retrying blindly.
+ */
+ fun classifyInvite(
+ wraps: List,
+ token: ByteArray,
+ ): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token)
+
/** Derives the control plane described by a redeemed [invite] so the joiner can read it. */
fun controlPlaneFor(invite: CommunityInvite): GroupKey = controlPlane(invite.communityRoot.hexToByteArray(), invite.communityId.hexToByteArray(), invite.rootEpoch)
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 88fc9e9702..6feab287b2 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
@@ -21,6 +21,8 @@
package com.vitorpamplona.quartz.concord.cord05Invites
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson
+import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
+import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -31,6 +33,34 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip44Encryption.Nip44
import com.vitorpamplona.quartz.utils.RandomInstance
+/**
+ * What the events fetched at an invite's addressable coordinate `(33301,
+ * link_signer, d="")` actually resolve to under CORD-05 §2. The coordinate is
+ * replaceable, so its live state is the **newest** event: a revocation tombstone
+ * (`vsk=9`) supersedes and buries the bundle even if an older openable copy still
+ * lingers on some relay, which is exactly the "fetcher finds the grave instead of
+ * keys" behaviour the spec mandates.
+ */
+sealed interface InviteBundleStatus {
+ /** A live `vsk=6` bundle that opened with the link token. */
+ data class Live(
+ val invite: CommunityInvite,
+ ) : InviteBundleStatus
+
+ /** The newest event at the coordinate is a `vsk=9` revocation tombstone — the link was retired. */
+ data object Revoked : InviteBundleStatus
+
+ /**
+ * Something is at the coordinate, but it isn't a `vsk=6` bundle this client can open —
+ * a wrong/expired token, or a sub-kind (e.g. a mis-posted registry `vsk=8`) or format
+ * newer than we support.
+ */
+ data object Unreadable : InviteBundleStatus
+
+ /** Nothing was found at the coordinate on any queried relay (unreachable, expired, or not yet propagated). */
+ data object Absent : InviteBundleStatus
+}
+
/** A freshly minted public invite link: the shareable URL, the link keys, and the bundle to publish. */
class MintedInviteLink(
val url: String,
@@ -82,6 +112,25 @@ object ConcordInviteBundle {
}
}
+ /**
+ * Resolves the events fetched at an invite's addressable coordinate into a single
+ * [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 +
+ * validates with [token] is [InviteBundleStatus.Live]; anything else present is
+ * [InviteBundleStatus.Unreadable], and an empty set is [InviteBundleStatus.Absent].
+ */
+ fun classify(
+ wraps: List,
+ token: ByteArray,
+ ): 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) } }
+ return if (invite != null) InviteBundleStatus.Live(invite) else InviteBundleStatus.Unreadable
+ }
+
/**
* Validates that an [invite]'s owner + salt actually reproduce its
* community_id (CORD-02 self-certification), so a bundle can't smuggle a false
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
new file mode 100644
index 0000000000..00d941ad25
--- /dev/null
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt
@@ -0,0 +1,123 @@
+/*
+ * 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.ConcordCommunityFactory
+import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity
+import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
+import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag
+import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
+import com.vitorpamplona.quartz.nip01Core.core.toHexKey
+import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
+import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * Resolving an invite's addressable coordinate per CORD-05 §2: a live `vsk=6` bundle
+ * opens, a `vsk=9` revocation tombstone wins even over a still-openable stale copy,
+ * an unknown/mis-posted sub-kind is unreadable, and an empty fetch is absent.
+ */
+class ConcordInviteClassifyTest {
+ private val owner = NostrSignerInternal(KeyPair())
+
+ private fun inviteFor(community: NewConcordCommunity) =
+ CommunityInvite(
+ communityId = community.communityIdHex,
+ owner = community.ownerPubKey,
+ ownerSalt = community.ownerSalt.toHexKey(),
+ communityRoot = community.communityRoot.toHexKey(),
+ rootEpoch = community.rootEpoch,
+ relays = listOf("wss://relay.example"),
+ name = "Nostrichs",
+ )
+
+ /** A raw kind-33301 event at the link-signer coordinate carrying an arbitrary [vsk] wire value. */
+ private fun coordinateEvent(
+ linkSignerPubKey: String,
+ vsk: String,
+ createdAt: Long,
+ content: String = "",
+ ) = Event(
+ id = "00".repeat(32),
+ pubKey = linkSignerPubKey,
+ createdAt = createdAt,
+ kind = ConcordInviteBundleEvent.KIND,
+ tags = arrayOf(arrayOf("d", ""), VskTag.TAG_NAME.let { arrayOf(it, vsk) }),
+ content = content,
+ sig = "00".repeat(64),
+ )
+
+ @Test
+ fun liveBundleOpens() =
+ runTest {
+ val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
+ val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
+
+ val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token)
+ assertTrue(status is InviteBundleStatus.Live)
+ assertEquals(community.communityIdHex, status.invite.communityId)
+ }
+
+ @Test
+ fun revocationTombstoneWinsOverStaleBundle() =
+ runTest {
+ val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example"))
+ val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example"))
+
+ // A newer vsk=9 tombstone at the same coordinate buries the still-openable bundle.
+ val tombstone = coordinateEvent(minted.linkSignerPubKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L)
+
+ assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, tombstone), minted.token))
+ // Order of the fetched list must not matter — newest createdAt wins regardless.
+ assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(tombstone, minted.bundleEvent), minted.token))
+ }
+
+ @Test
+ fun unknownSubKindIsUnreadable() =
+ runTest {
+ // A mis-posted registry (vsk=8) at the bundle coordinate — the exact shape of the
+ // relayop.xyz link that hung — is present but not a vsk=6 bundle we can open.
+ val registry = coordinateEvent("aa".repeat(32), ControlEntityKind.INVITE_REGISTRY.wire, createdAt = 1L, content = "unopenable")
+ assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), ByteArray(16)))
+ }
+
+ @Test
+ fun emptyFetchIsAbsent() {
+ assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), ByteArray(16)))
+ }
+
+ @Test
+ fun realRelayopBundleIsUnreadable() {
+ // The actual kind-33301 event behind the reported relayop.xyz/invite link (vsk=8), plus the
+ // 16-byte token from its #fragment. It fetches fine but can't be opened, so a compliant
+ // redeemer reports Unreadable instead of spinning forever.
+ val json =
+ """{"content":"ApoDjyzcHUg2imEiqw6Gsfpc2O86r+CMtMor+jc8ZlgrYwlI6CCmX7qGGEQvEJ5537nINE9H09Ro8RtEghpYgwkhdPHS274RpklFmuyLMdcoC5u1EVhppu8BrlHZ0YBfw3GX1Ui0uwy3V/J+rvrYiLhdREmwlK39JAX8sZfzCUhVtDMCgLVy03dwdpTC1Kj/ZeZJTYhJ8qmaN2273jgBTno/bFLzJlYvbANss69Tg53mljcmdSyhMlZ8z1kuenm1zkrPO5yHvi//r25tXkXb580OCkWxTmEwFzo20ntMgFnVSwVRvLZelOZt++tMevqi2Z5asvDgG7RytHP/0vLxxPzmjH0No+nITsxcmDbEweoKvSSzoc/7DYzENmfmrLXgP2KU/eE6CpTcSNaedLVKbAu9XptdtV8ruZxHjVBh1wpOwXkETEdqqvbCiR4TCNWzqbmwRKJ+acvZLBxhXcpfqmRsolaATU4sZKLs4iu92YpMIuUDh2Pquu0Daiz/IGnVe7BPb7E/gSd9NBFIxds6Nk1DbP8XKMRtYmWdTforUPWZqdM4EOtt8AcNpALRmsbEF26Gyd6t4/81bQPh+7WhI97lR/KkdWtKxNjjJ4CoJLgceyHuwbxXnFR23IWhzvQpBY12MBeYOw9oizvEzEGhEqpUns6LkH2sUNRRXbneNNvVgCEk6BK7j6Dxi95mcGJDEtOW+coE1SjhnfrwjIsdJL7cUEyC5DHFKuvxUi0iw/1I6b3AfZV5+A1tssEE2dhDv8uw6B3/a5EfMURFDqSfmGw1btdPPJ3+yjo1yYu2BtbYa4U++GtaAJfmNPrsB9lm4YgXuwCCRSpI2+TR9H2ntWM2j3HVdXqOpg3kfX82o9KFndo2g+7vGrOAyfL1jcybluq7AxPEV6D5yBky82MjoMeS0vSM6ytYu+0jheWPwDVs/3iPTELHPeDXAZOaw76ISBvNsXcxHvFsSiZBguBr+ucZOUnazVRAYIsmm/WNcIJu+6tfbyupqFCo5wkus6lKN2RNYIH1SRIi163cdBDhTBOdZoI2WcDr+SSW2fHtZutk7fW5IkJvSuy5xlke+YW/u3uzvriAIRmVDtk/fKISKEnMj2G47JdGn6EiHf+2+XfUSuDiliJb62pPXWBupinbb9HEW0tuyPHYGACH0/GA/egr6KMgI6YSh+BWS8vniMRTkmouKCzL5Csvc+2txC9LrfodrMF2R3jFZ1nig0mYzTQ9HvhqA2Uc+YG06iZtRaU7KqH6fMZYzPbjrxVOliyXR2G6","created_at":1784122846,"id":"112701bc1541c10b92f5a105e2e1f1813e591936e20075ec6a53c8bb8d235d81","kind":33301,"pubkey":"7177ccb8e8786c152e4960765f03fbceb7419d36a26e693a6399319760e7fd30","sig":"80eb4b49d70d73d35c1026b9c06d0fab280787950b5df412ebe4cdd05fcacadb4d20419c4e732749ed2698186a321069b4329ebd93fe21d8594d7392bf6445e0","tags":[["d",""],["vsk","8"]]}"""
+ val event = Event.fromJson(json)
+ val token = "c0277c415fe2ecc901a22b2f23dca5bf".hexToByteArray()
+ assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(event), token))
+ }
+}