Merge pull request #3592 from vitorpamplona/claude/amethyst-invite-link-hang-w0tagl

CORD-05: Distinguish invite redemption failures for better UX
This commit is contained in:
Vitor Pamplona
2026-07-15 21:29:52 -04:00
committed by GitHub
7 changed files with 297 additions and 17 deletions
@@ -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
@@ -2037,20 +2038,34 @@ class Account(
* Redeem a Concord invite link (`…/invite/<naddr>#<fragment>`): parse it, fetch
* the kind-33301 public bundle from the link's relays (+ our outbox), unlock it
* with the fragment token, and add the resulting secret-bearing entry to the
* kind-13302 joined list. Returns the joined community id, or null if the link
* is invalid, unreadable, or no valid bundle is found.
* kind-13302 joined list.
*
* Returns a [ConcordInviteResult] that separates the failure modes so the UI can
* both explain what went wrong and decide whether a retry could ever help — a
* bundle we can't open (e.g. minted by a newer client) must not strand the user
* on a spinner that retries forever.
*/
suspend fun joinConcordViaInvite(url: String): String? {
if (!isWriteable()) return null
val parsed = ConcordActions.parseInviteLink(url) ?: return null
suspend fun joinConcordViaInvite(url: String): ConcordInviteResult {
if (!isWriteable()) return ConcordInviteResult.InvalidLink
val parsed = ConcordActions.parseInviteLink(url) ?: return ConcordInviteResult.InvalidLink
val relays =
(parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + outboxRelays.flow.value).toSet()
if (relays.isEmpty()) return null
if (relays.isEmpty()) return ConcordInviteResult.NotReachable
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) } ?: return null
// 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(
@@ -2064,7 +2079,7 @@ class Account(
addedAt = TimeUtils.now() * 1000,
)
joinConcordCommunity(entry)
return bundle.communityId
return ConcordInviteResult.Joined(bundle.communityId)
}
/**
@@ -0,0 +1,56 @@
/*
* 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.amethyst.model
/**
* The outcome of redeeming a Concord invite link (CORD-05). Separating the failure
* modes lets the UI tell the user *why* it failed and — crucially — whether
* retrying could ever help, so a link we can never open doesn't strand the user on
* an endless "redeeming…" spinner with a retry button that loops forever.
*/
sealed interface ConcordInviteResult {
/** Redeemed and joined; navigate to [communityId]. */
data class Joined(
val communityId: String,
) : ConcordInviteResult
/** The link itself is malformed, or this account can't join (read-only key). Retrying can't help. */
data object InvalidLink : ConcordInviteResult
/**
* No invite bundle was reachable on any relay — a transient miss (relays down,
* link too new to have propagated, or expired). Retrying may help.
*/
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
* bundle format this app can't read yet. Retrying can't help.
*/
data object Incompatible : ConcordInviteResult
}
@@ -38,6 +38,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.ConcordInviteResult
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -50,7 +51,15 @@ private sealed interface RedeemState {
val communityId: String,
) : RedeemState
data object Failed : RedeemState
/**
* The redeem failed. [messageRes] explains why; [canRetry] is true only for a
* transient miss — an invalid or incompatible link can never succeed, so we don't
* offer a retry that would just loop back onto the same spinner.
*/
data class Failed(
val messageRes: Int,
val canRetry: Boolean,
) : RedeemState
}
/**
@@ -69,8 +78,18 @@ fun ConcordInviteScreen(
LaunchedEffect(link, state) {
if (state is RedeemState.Working) {
val communityId = accountViewModel.account.joinConcordViaInvite(link)
state = if (communityId != null) RedeemState.Done(communityId) else RedeemState.Failed
state =
when (val result = accountViewModel.account.joinConcordViaInvite(link)) {
is ConcordInviteResult.Joined -> RedeemState.Done(result.communityId)
is ConcordInviteResult.InvalidLink ->
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)
}
}
}
@@ -96,16 +115,19 @@ fun ConcordInviteScreen(
}
is RedeemState.Failed -> {
val failed = state as RedeemState.Failed
Text(
stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_failed),
stringRes(failed.messageRes),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
Button(
onClick = { state = RedeemState.Working },
modifier = Modifier.padding(top = 16.dp),
) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.retry))
if (failed.canRetry) {
Button(
onClick = { state = RedeemState.Working },
modifier = Modifier.padding(top = 16.dp),
) {
Text(stringRes(com.vitorpamplona.amethyst.R.string.retry))
}
}
}
+3
View File
@@ -309,6 +309,9 @@
<string name="loading_account">Loading account</string>
<string name="concord_redeeming_invite">Redeeming invite…</string>
<string name="concord_invite_failed">Could not fetch this invite. The link may be expired or its relays unreachable.</string>
<string name="concord_invite_failed_invalid">This invite link is invalid or can\'t be opened with this account.</string>
<string name="concord_invite_failed_incompatible">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.</string>
<string name="concord_invite_failed_revoked">This invite link has been revoked and can no longer be used. Ask for a new one.</string>
<string name="concord_home_title">Concord Channels</string>
<string name="concord_home_empty">You haven\'t joined any Concord Channels yet. Create one, or open an invite link.</string>
<string name="concord_channels_empty">No channels yet.</string>
@@ -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<Event>,
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)
@@ -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<Event>,
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
@@ -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))
}
}