From 41fa4538c20eb959ba58c7774b6924e47ef87eed Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Aug 2026 23:37:27 -0400 Subject: [PATCH] fix(buzz): open v2 invite links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invite server now mints `v2.` tokens, and the client could not open them at all. BuzzInviteLink.parse requires `.` and reads the community out of the payload; for a v2 token the payload segment is the literal `v2`, which decodes to one byte and fails the JSON parse, so parse returned null. Every entry point is gated on that one call, so the failure was total and silent: the deep link fell through to the external browser (where nothing can sign the claim with the user's key — the reason the in-app flow exists), a pasted link in search did nothing at all, and a link inside a note rendered as a plain url instead of an invite. `amy buzz join` refused it too. Nothing is lost by admitting the shape. The join needs the host and the code, both carried by the url itself: relayUrl() is `wss://$host`, httpBase() is `https://$host`, and the claim response returns community_id and role — which is why the screen never reads communityId. Expiry is the relay's call for a token it alone can interpret. Matched on the literal `v2` prefix rather than by relaxing the decode, so `…/invite/anything.else` still fails to parse and a Concord naddr invite (no dot) is still rejected. Tests cover the real v2 token end to end plus both guards; all three fail against the unpatched parser. Verified on device against a live workspace: the link now opens the join screen, hands off to the window.nostr browser, and the claim enrolls the key. Co-Authored-By: Claude Opus 5 (1M context) --- .../quartz/buzz/invite/BuzzInviteLink.kt | 36 +++++++++++++++--- .../quartz/buzz/invite/BuzzInviteLinkTest.kt | 37 +++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt index 044ca61fde..1f1b0fbc37 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt @@ -27,8 +27,12 @@ import kotlin.io.encoding.ExperimentalEncodingApi /** * A parsed Buzz workspace invite link: `https:///invite/`, where `` is a - * relay-signed token `.` (base64url, JWT-style but not a JWT). The - * payload names the community, the granted role, an expiry and a nonce. + * relay-signed token in one of two shapes: + * + * - `.` (base64url, JWT-style but not a JWT) — the payload names the + * community, the granted role, an expiry and a nonce. + * - `v2.` — a bare server-side handle. Nothing about the invite is readable here; + * the relay resolves it on claim. * * A Buzz invite is **not** a NIP-29 invite code (kind 9009) — it is redeemed over HTTP against * the relay's tenant host: `POST /api/invites/claim`, NIP-98-signed by the joining key, after @@ -43,11 +47,15 @@ data class BuzzInvite( val host: String, /** The full opaque token (`payload.sig`) to hand back to the relay's claim endpoint. */ val code: String, - /** The community (workspace/tenant) UUID the invite admits into — the payload's `c`. */ + /** + * The community (workspace/tenant) UUID the invite admits into — the payload's `c`. Empty for + * a `v2.` token, whose code is opaque: the relay resolves the community on claim and returns + * it, so nothing client-side needs it (the join hands off to the tenant host, not the id). + */ val communityId: String, - /** The role granted on claim (e.g. `member`) — the payload's `r`. */ + /** The role granted on claim (e.g. `member`) — the payload's `r`, or [DEFAULT_ROLE] for `v2.`. */ val role: String, - /** Unix-seconds expiry, or null when the payload omits it — the payload's `e`. */ + /** Unix-seconds expiry, or null when the payload omits it (always for `v2.`) — the payload's `e`. */ val expiresAt: Long?, ) { /** The tenant's relay websocket URL. */ @@ -63,6 +71,12 @@ data class BuzzInvite( object BuzzInviteLink { private const val MARKER = "/invite/" + /** The payload segment of an opaque, server-resolved token. */ + private const val V2_PREFIX = "v2" + + /** What the relay grants when the token doesn't say — and it never says for `v2.`. */ + private const val DEFAULT_ROLE = "member" + private val JSON = Json { ignoreUnknownKeys = true } @Serializable @@ -100,6 +114,16 @@ object BuzzInviteLink { val payloadB64 = code.substringBefore('.') if (payloadB64 == code || payloadB64.isEmpty()) return null + // `v2.` carries no client-readable payload — the community, role and expiry live + // only on the relay, which resolves the code on claim and returns them. There is nothing + // to decode and nothing to lose by admitting it: the join flow needs the host (for the + // relay url and the REST base) and the code, both of which the url itself carries, and the + // claim response supplies the rest. Matched on the literal prefix rather than by relaxing + // the decode below, so `…/invite/anything.else` still fails to parse. + if (payloadB64 == V2_PREFIX) { + return BuzzInvite(host = host, code = code, communityId = "", role = DEFAULT_ROLE, expiresAt = null) + } + val payload = try { val bytes = Base64.UrlSafe.decode(padBase64(payloadB64)) @@ -113,7 +137,7 @@ object BuzzInviteLink { host = host, code = code, communityId = community, - role = payload.r?.takeIf { it.isNotBlank() } ?: "member", + role = payload.r?.takeIf { it.isNotBlank() } ?: DEFAULT_ROLE, expiresAt = payload.e, ) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt index 2bbf4beb78..4583b09b99 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt @@ -59,6 +59,37 @@ class BuzzInviteLinkTest { assertEquals("c03abaa9-65e4-43b1-b9b3-f502a2812d0b", BuzzInviteLink.parse("$realUrl?ref=1")!!.communityId) } + // A real v2 token minted by amethyst.communities.buzz.xyz: `v2.` plus an opaque handle. Nothing + // about the invite is encoded in it — the relay resolves the code when the claim arrives. + private val v2Token = "v2.WWsMv33mYH8o04ZGdcoZKmIImGOEMW7auc5cZ0UdH24" + private val v2Url = "https://amethyst.communities.buzz.xyz/invite/$v2Token" + + @Test + fun parsesAnOpaqueV2Invite() { + val invite = BuzzInviteLink.parse(v2Url)!! + assertEquals("amethyst.communities.buzz.xyz", invite.host) + assertEquals(v2Token, invite.code) + // Unknowable client-side: the claim response carries the community and the granted role. + assertEquals("", invite.communityId) + assertEquals("member", invite.role) + assertNull(invite.expiresAt) + // What the join actually needs, both derived from the host. + assertEquals("wss://amethyst.communities.buzz.xyz", invite.relayUrl()) + assertEquals("https://amethyst.communities.buzz.xyz", invite.httpBase()) + } + + @Test + fun aV2InviteNeverExpiresClientSide() { + // No expiry to check, so the courtesy check must not block the claim — the relay decides. + assertTrue(!BuzzInviteLink.parse(v2Url)!!.isExpired(Long.MAX_VALUE)) + } + + @Test + fun toleratesTrailingFragmentAndQueryOnV2() { + assertEquals(v2Token, BuzzInviteLink.parse("$v2Url#x")!!.code) + assertEquals(v2Token, BuzzInviteLink.parse("$v2Url?ref=1")!!.code) + } + @Test fun rejectsNonInviteAndConcordShapes() { assertNull(BuzzInviteLink.parse("https://amethyst.communities.buzz.xyz/")) @@ -67,5 +98,11 @@ class BuzzInviteLinkTest { assertNull(BuzzInviteLink.parse("https://amethyst.social/invite/naddr1abcdef#deadbeef")) // Dotless token → not a Buzz invite. assertNull(BuzzInviteLink.parse("https://host.example/invite/justsometext")) + // The v2 exemption is the literal prefix, not "give up on decoding": an undecodable + // payload with any other prefix is still not an invite. + assertNull(BuzzInviteLink.parse("https://host.example/invite/v3.WWsMv33mYH8o04ZGdcoZKmI")) + assertNull(BuzzInviteLink.parse("https://host.example/invite/notbase64json.sig")) + // `v2` without the dot is a dotless token like any other. + assertNull(BuzzInviteLink.parse("https://host.example/invite/v2")) } }