From 389d750ccce99a70ed8948696f93d41c61280adf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 00:49:15 +0000 Subject: [PATCH 1/2] fix(concord): don't hang forever on an unopenable invite link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redeeming a Concord invite link (`…/invite/#`) that was minted by a newer/incompatible Concord client left the redeem screen on an endless "Redeeming invite…" spinner with a retry button that could never succeed. The bundle event fetches fine, but its content can't be decrypted (e.g. a bundle tagged `vsk=8` vs the `vsk=6` this app mints/reads), so `ConcordInviteBundle.parse` returns null and the old `String?` contract collapsed every failure into a single generic "could not fetch" + retry. `joinConcordViaInvite` now returns a `ConcordInviteResult` that separates the outcomes — joined, invalid link, not reachable (transient), and a bundle that was found but couldn't be opened (incompatible/newer). The invite screen shows a specific message per case and only offers a retry for the transient miss, so an invite this app can't open ends on a clear explanation instead of looping. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015KngFNwrQDLYa9QW1f5RRD --- .../vitorpamplona/amethyst/model/Account.kt | 27 +++++++--- .../amethyst/model/ConcordInviteResult.kt | 50 +++++++++++++++++++ .../concord/ConcordInviteScreen.kt | 38 ++++++++++---- amethyst/src/main/res/values/strings.xml | 2 + 4 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt 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 ab22669284..92bf6fb011 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2037,20 +2037,31 @@ class Account( * Redeem a Concord invite link (`…/invite/#`): 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 + 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 + } val entry = ConcordCommunityListEntry( @@ -2064,7 +2075,7 @@ class Account( addedAt = TimeUtils.now() * 1000, ) joinConcordCommunity(entry) - return bundle.communityId + return ConcordInviteResult.Joined(bundle.communityId) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt new file mode 100644 index 0000000000..69a39a19c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt @@ -0,0 +1,50 @@ +/* + * 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 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 +} 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 31c400395f..a9dbb85c1e 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 @@ -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,16 @@ 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.NotReachable -> + RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed, canRetry = true) + } } } @@ -96,16 +113,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)) + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6c8d8cb12b..791e78738d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -309,6 +309,8 @@ Loading account Redeeming invite… 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. Concord Channels You haven\'t joined any Concord Channels yet. Create one, or open an invite link. No channels yet. From ae9f4a0def9ea634d461d497bc1a101abbceaa52 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:24:37 +0000 Subject: [PATCH 2/2] fix(concord): resolve invite coordinate per CORD-05 (honor revocation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up making the invite redeemer match the CORD-05 §2 spec for the addressable invite coordinate (33301, link_signer, d=""): - vsk=6 → live bundle (open with the link token) - vsk=9 → revocation tombstone: the newest event wins, so a tombstone buries even a stale, still-openable copy on another relay ("a fetcher finds the grave instead of keys"). Amethyst previously never checked for this, so a revoked link failed generically. - anything else present (e.g. a mis-posted registry vsk=8, the shape of the relayop.xyz link that hung) → unreadable - nothing on any relay → absent New pure `ConcordInviteBundle.classify(wraps, token): InviteBundleStatus` in quartz (next to parse/validate), wrapped by `ConcordActions.classifyInvite`, and mapped by `Account.joinConcordViaInvite` to the `ConcordInviteResult` cases — including a new `Revoked` outcome with its own message and no futile retry. Crypto is unchanged and already matches the spec (hkdf(token,'concord/invite-key') → NIP-44 → snake_case CommunityInvite). Adds ConcordInviteClassifyTest covering live / revoked (order-independent) / unreadable / absent, plus the real relayop.xyz vsk=8 event → Unreadable. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015KngFNwrQDLYa9QW1f5RRD --- .../vitorpamplona/amethyst/model/Account.kt | 20 +-- .../amethyst/model/ConcordInviteResult.kt | 6 + .../concord/ConcordInviteScreen.kt | 2 + amethyst/src/main/res/values/strings.xml | 1 + .../commons/actions/ConcordActions.kt | 12 ++ .../cord05Invites/ConcordInviteBundle.kt | 49 +++++++ .../ConcordInviteClassifyTest.kt | 123 ++++++++++++++++++ 7 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt 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)) + } +}