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..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 @@ -2037,20 +2038,34 @@ 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 + + // 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) } /** 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..6c9cc50222 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt @@ -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 +} 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..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 @@ -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)) + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6c8d8cb12b..1dd3620c16 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -309,6 +309,9 @@ 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. + 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)) + } +}