mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
co-authored by
Claude Opus 5
parent
1c8968e737
commit
41fa4538c2
+30
-6
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+37
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user