fix(buzz): open v2 invite links

The invite server now mints `v2.<opaque>` tokens, and the client could not
open them at all. BuzzInviteLink.parse requires `<payloadB64url>.<sigB64url>`
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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-08-19 00:07:08 -04:00
co-authored by Claude Opus 5
parent 1c8968e737
commit 41fa4538c2
2 changed files with 67 additions and 6 deletions
@@ -27,8 +27,12 @@ import kotlin.io.encoding.ExperimentalEncodingApi
/**
* A parsed Buzz workspace invite link: `https://<host>/invite/<code>`, where `<code>` is a
* relay-signed token `<payloadB64url>.<sigB64url>` (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:
*
* - `<payloadB64url>.<sigB64url>` (base64url, JWT-style but not a JWT) — the payload names the
* community, the granted role, an expiry and a nonce.
* - `v2.<opaqueB64url>` — 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.<opaque>` 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,
)
}
@@ -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"))
}
}