From bb39fb29fcbed9820fc9215d31cbe9c509c2bf81 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 14:56:37 +0000 Subject: [PATCH] feat(buzz): invite-link redemption + live-interop fixes (amy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated against the production relay wss://amethyst.communities.buzz.xyz by joining and running a full DM round-trip. Adds the join primitive and fixes the interop gaps that live testing surfaced. - quartz: BuzzInviteLink — parse `https:///invite/` (relay-signed base64url payload → community/role/expiry). A Buzz invite is NOT a NIP-29 code; it is redeemed over HTTP against the tenant host. Unit-tested with a real token; rejects the Concord `/invite/#…` shape (no collision). - cli: `amy buzz join ` — the real 3-step claim: GET /api/join-policy, POST /api/invites/accept-policy, then NIP-98-signed POST /api/invites/claim. Proven live (status: joined, role: member). - cli: Context.publish now authenticates-then-retries on an `auth-required` relay (warm the connection with a pendingOnAuthRequired REQ, then re-publish) — the write path had no NIP-42 handling, so every Buzz write was rejected. - cli: Buzz reads (dm list / read / console / personas) use the auth-aware drain (pendingOnAuthRequired). - cli: `dm open` surfaces the relay's synchronous OK `response:{channel_id}` — the authoritative DM channel id (the relay assigns it; it is not polled). - cli: `dm list` rewritten to the relay's actual discovery — kind-44100 member-added notifications (#p=me) filtered to the kind-40099 `dm_created` channels. The deployed relay does NOT emit kind-41001. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8 --- cli/README.md | 2 +- .../com/vitorpamplona/amethyst/cli/Context.kt | 25 ++- .../amethyst/cli/commands/BuzzCommands.kt | 209 ++++++++++++++++-- .../quartz/buzz/invite/BuzzInviteLink.kt | 137 ++++++++++++ .../quartz/buzz/invite/BuzzInviteLinkTest.kt | 71 ++++++ 5 files changed, 428 insertions(+), 16 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt diff --git a/cli/README.md b/cli/README.md index 231dc427fa..cd0c728594 100644 --- a/cli/README.md +++ b/cli/README.md @@ -606,7 +606,7 @@ and `commons` aggregator the app uses. | `amy buzz attest AGENT [--kind K] [--after UNIX] [--before UNIX]` | Sign a NIP-OA attestation authorizing AGENT (offline; needs a local key). Prints the `auth` tag to hand to the agent operator. | | `amy buzz console [--relays R,R] [--timeout SECS]` | Fetch my kind:44200 turn metrics (`#p`=me), decrypt, and aggregate fleet + per-agent cost/tokens. | | `amy buzz personas [--relays R,R] [--timeout SECS]` | List my kind:30175 persona definitions (newest per slug). | -| `amy buzz dm list [--relays R,R] [--limit N] [--timeout SECS]` | List my DMs from the relay-signed kind:41001 confirmations (`#p`=me): dm id + participants. | +| `amy buzz dm list [--relays R,R] [--limit N] [--timeout SECS]` | List my DMs: discover channels I'm in via kind:44100 member-added notifications (`#p`=me), keep the ones a kind:40099 `dm_created` marks as DMs. (The deployed relay does not emit kind:41001.) | | `amy buzz dm open RELAY PUBKEY [PUBKEY…]` | Open (or re-surface) a DM with 1-8 people (kind:41010). The relay assigns the channel id and confirms via 41001. | | `amy buzz dm hide RELAY CHANNEL` | Hide a DM from my sidebar (kind:41012); re-opening it un-hides. | | `amy buzz dm add-member RELAY CHANNEL PUBKEY` | Add a member to an existing group DM (kind:41011). | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 527db1992d..6fe7308eff 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -523,7 +523,30 @@ class Context( // event in the local cache. verifyAndStore(event) if (relayList.isEmpty()) return emptyMap() - return client.publishAndCollectResults(event, relayList, timeoutSecs) + + var results = client.publishAndCollectResults(event, relayList, timeoutSecs) + + // NIP-42 write path: an auth-required relay rejects the FIRST publish with + // `auth-required`. The publish opens a connection, races the async AUTH reply, and + // tears down before it lands — and [relayAuth] only re-auths on a REQ `CLOSED`, never + // on a rejected EVENT `OK`. The READ path, though, holds the connection open and its + // `auth-required:` CLOSED reliably drives a re-auth (`reauthenticateIfAuthRequired`), + // leaving the pooled connection authenticated. So on `auth-required` we warm auth with + // a tiny `pendingOnAuthRequired` REQ, then retry the publish on the now-authed socket. + var attempt = 0 + while (attempt < 4) { + val needAuth = + results + .filterValues { !it.accepted && it.message.contains("auth-required", ignoreCase = true) } + .keys + if (needAuth.isEmpty()) break + // A cheap REQ whose only purpose is to force the AUTH handshake to completion. + val warmFilter = listOf(Filter(kinds = listOf(event.kind), limit = 1)) + drain(needAuth.associateWith { warmFilter }, timeoutMs = 8_000, pendingOnAuthRequired = true) + results = results + client.publishAndCollectResults(event, needAuth, timeoutSecs) + attempt++ + } + return results } /** diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt index 7d9c4fea15..7e3c37b289 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt @@ -28,9 +28,10 @@ import com.vitorpamplona.amethyst.commons.model.buzz.AgentFleetAggregator import com.vitorpamplona.quartz.buzz.amTurnMetrics.AgentTurnMetricEvent import com.vitorpamplona.quartz.buzz.apPersonas.PersonaEvent import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent -import com.vitorpamplona.quartz.buzz.dm.DmCreatedEvent import com.vitorpamplona.quartz.buzz.dm.DmHideEvent import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent +import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink +import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.AttestationConditions import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event @@ -39,7 +40,20 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody /** * `amy buzz …` — first-class access to the `block/buzz` workspace protocol, driving the @@ -51,6 +65,7 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent object BuzzCommands { private val USAGE = """ + |amy buzz join [--accept-policy] redeem a Buzz invite link (HTTP claim, NIP-98) |amy buzz post RELAY GID post a kind-40002 stream message |amy buzz read RELAY GID [--limit N] read recent workspace messages (9/40002/40099) | [--timeout SECS] @@ -60,7 +75,7 @@ object BuzzCommands { | [--timeout SECS] |amy buzz personas [--relays R,R] list my kind-30175 personas | [--timeout SECS] - |amy buzz dm list [--relays R,R] list my DMs (kind-41001, #p = me) + |amy buzz dm list [--relays R,R] list my DMs (44100 member-added + 40099 dm_created) | [--limit N] [--timeout SECS] |amy buzz dm open RELAY PUBKEY [PUBKEY…] open a DM with 1-8 people (kind-41010) |amy buzz dm hide RELAY CHANNEL hide a DM from my sidebar (kind-41012) @@ -76,6 +91,7 @@ object BuzzCommands { tail, USAGE, mapOf( + "join" to { rest -> join(dataDir, rest) }, "post" to { rest -> post(dataDir, rest) }, "read" to { rest -> read(dataDir, rest) }, "attest" to { rest -> attest(dataDir, rest) }, @@ -110,7 +126,7 @@ object BuzzCommands { ) } - /** `buzz dm list` → drains the relay-signed kind-41001 confirmations addressed to me. */ + /** `buzz dm list` → discovers my DM channels via kind-44100 member-added notifications, filtered to the kind-40099 `dm_created` ones. */ private suspend fun dmList( dataDir: DataDir, rest: Array, @@ -127,20 +143,54 @@ object BuzzCommands { val relays = relaysFor(ctx, relaysFlag) if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…") - val filter = Filter(kinds = listOf(DmCreatedEvent.KIND), tags = mapOf("p" to listOf(me)), limit = limit) + // The deployed Buzz relay does NOT emit kind-41001; instead it (a) confirms a DM's + // channel id synchronously in the open OK, and (b) addresses each member a kind-44100 + // member-added notification (`#p` = me, `h` = channel). So discover the channels I'm in + // via 44100, then keep those whose kind-40099 system message marks them a DM. + val memberFilter = Filter(kinds = listOf(MemberAddedNotificationEvent.KIND), tags = mapOf("p" to listOf(me))) + val channelIds = + ctx + .drain(relays.associateWith { listOf(memberFilter) }, timeoutSecs * 1000, pendingOnAuthRequired = true) + .map { it.second } + .filterIsInstance() + .mapNotNull { it.channel() } + .distinct() + if (channelIds.isEmpty()) { + Output.emit(mapOf("count" to 0, "dms" to emptyList())) + return 0 + } + + val sysFilter = Filter(kinds = listOf(SystemMessageEvent.KIND), tags = mapOf("h" to channelIds)) val dms = ctx - .drainAllPages(relays.associateWith { listOf(filter) }, timeoutSecs * 1000) + .drain(relays.associateWith { listOf(sysFilter) }, timeoutSecs * 1000, pendingOnAuthRequired = true) .map { it.second } - .filterIsInstance() - .distinctBy { it.id } + .filterIsInstance() + .filter { it.payload()?.type == "dm_created" } + .distinctBy { it.channel() } .sortedByDescending { it.createdAt } .take(limit) - .map { + .map { sys -> + // The relay's dm_created content carries a `participants` array our + // SystemMessagePayload model drops; read it from the raw content. + val participants = + runCatching { + jsonParser + .parseToJsonElement(sys.content) + .jsonObject["participants"] + ?.let { arr -> + arr + .toString() + .trim('[', ']') + .split(",") + .map { it.trim().trim('"') } + .filter { it.isNotBlank() } + } + }.getOrNull().orEmpty() mapOf( - "dm_id" to it.dmId(), - "participants" to it.participants(), - "created_at" to it.createdAt, + "dm_id" to sys.channel(), + "participants" to participants, + "created_at" to sys.createdAt, ) } Output.emit(mapOf("count" to dms.size, "dms" to dms)) @@ -177,6 +227,23 @@ object BuzzCommands { val signed = ctx.signer.sign(DmOpenEvent.build(participants)) val ack = ctx.publish(signed, setOf(relay)) RawEventSupport.publishGuard(ack, signed.id)?.let { return it } + // The relay confirms a DM open synchronously in the OK message as + // `response:{"channel_id":"…","created":bool}` — the authoritative, relay-assigned + // channel UUID. Surface it so callers open the chat straight from the ack. + val okMessage = ack.values.firstOrNull { it.accepted }?.message + val channelId = + okMessage + ?.substringAfter("response:", "") + ?.takeIf { it.isNotBlank() } + ?.let { + runCatching { + jsonParser + .parseToJsonElement(it) + .jsonObject["channel_id"] + ?.jsonPrimitive + ?.content + }.getOrNull() + } Output.emit( mapOf( "event_id" to signed.id, @@ -184,6 +251,8 @@ object BuzzCommands { "relay" to relay.url, "participants" to participants, "published" to ack.values.any { it.accepted }, + "channel_id" to channelId, + "relay_message" to okMessage, ), ) return 0 @@ -214,6 +283,118 @@ object BuzzCommands { } } + private val jsonMedia = "application/json".toMediaType() + private val jsonParser = Json { ignoreUnknownKeys = true } + + /** + * `buzz join [--accept-policy]` → redeems a Buzz workspace invite. A Buzz + * invite is NOT a NIP-29 code; it's a relay-signed token claimed over HTTP against the + * tenant host: optionally accept the join policy, then `POST /api/invites/claim` + * NIP-98-signed by our key. On success we become a relay member and can REQ/publish. + */ + private suspend fun join( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val usage = "buzz join [--accept-policy]" + val urlArg = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) + val acceptPolicy = args.bool("accept-policy") + args.rejectUnknown("accept-policy") + + val invite = BuzzInviteLink.parse(urlArg) ?: return Output.error("bad_args", "not a Buzz invite link: $urlArg") + if (invite.isExpired(TimeUtils.now())) return Output.error("expired", "this invite has expired") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val http = ctx.okhttp + + // 1. Join policy (optional). A relay with a configured policy requires an accepted + // receipt before the claim; one without returns 404 and we skip straight to claim. + var policyReceipt: String? = null + val (policyCode, policyBody) = httpGet(http, "${invite.httpBase()}/api/join-policy") + if (policyCode == 200) { + val policy = jsonParser.parseToJsonElement(policyBody).jsonObject["policy"]?.jsonObject + val version = policy?.get("version")?.jsonPrimitive?.content + if (version != null) { + if (!acceptPolicy) { + return Output.error( + "policy_required", + "this workspace requires accepting its terms + age attestation; re-run with --accept-policy to consent", + ) + } + // 2. Accept policy → short-lived, invite-bound receipt (no NIP-98 auth). + val acceptReq = + buildJsonObject { + put("code", invite.code) + put("policy_version", version) + put("age_confirmed", true) + }.toString() + val (acceptCode, acceptBody) = httpPost(http, "${invite.httpBase()}/api/invites/accept-policy", acceptReq, null) + if (acceptCode != 200) return Output.error("policy_failed", "accept-policy failed ($acceptCode): $acceptBody") + policyReceipt = + jsonParser + .parseToJsonElement(acceptBody) + .jsonObject["receipt"] + ?.jsonPrimitive + ?.content + ?: return Output.error("policy_failed", "accept-policy returned no receipt: $acceptBody") + } + } + + // 3. Claim — NIP-98-signed POST. The `u` tag must equal the tenant-host URL. + val claimUrl = "${invite.httpBase()}/api/invites/claim" + val claimReq = + buildJsonObject { + put("code", invite.code) + policyReceipt?.let { put("policy_receipt", it) } + }.toString() + val authEvent = ctx.signer.sign(HTTPAuthorizationEvent.build(claimUrl, "POST", claimReq.encodeToByteArray())) + val (claimCode, claimBody) = httpPost(http, claimUrl, claimReq, authEvent.toAuthToken()) + if (claimCode != 200) return Output.error("claim_failed", "invite claim failed ($claimCode): $claimBody") + + val result = jsonParser.parseToJsonElement(claimBody).jsonObject + Output.emit( + mapOf( + "status" to result["status"]?.jsonPrimitive?.content, + "community_id" to (result["community_id"]?.jsonPrimitive?.content ?: invite.communityId), + "role" to (result["role"]?.jsonPrimitive?.content ?: invite.role), + "host" to invite.host, + "relay" to invite.relayUrl(), + ), + ) + return 0 + } + } + + private suspend fun httpGet( + http: OkHttpClient, + url: String, + ): Pair = + withContext(Dispatchers.IO) { + http + .newCall( + Request + .Builder() + .url(url) + .get() + .build(), + ).execute() + .use { it.code to (it.body?.string() ?: "") } + } + + private suspend fun httpPost( + http: OkHttpClient, + url: String, + body: String, + auth: String?, + ): Pair = + withContext(Dispatchers.IO) { + val builder = Request.Builder().url(url).post(body.toRequestBody(jsonMedia)) + if (auth != null) builder.header("Authorization", auth) + http.newCall(builder.build()).execute().use { it.code to (it.body?.string() ?: "") } + } + /** `buzz post RELAY GID ` → publishes a kind-40002 stream message with an `h` tag. */ private suspend fun post( dataDir: DataDir, @@ -251,7 +432,7 @@ object BuzzCommands { ) val messages = ctx - .drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000) + .drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000, pendingOnAuthRequired = true) .map { it.second } .distinctBy { it.id } .sortedByDescending { it.createdAt } @@ -336,7 +517,7 @@ object BuzzCommands { val filter = Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(me))) val decrypted = ctx - .drainAllPages(relays.associateWith { listOf(filter) }, timeoutSecs * 1000) + .drain(relays.associateWith { listOf(filter) }, timeoutSecs * 1000, pendingOnAuthRequired = true) .map { it.second } .filterIsInstance() .distinctBy { it.id } @@ -390,7 +571,7 @@ object BuzzCommands { val filter = Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(me)) val personas = ctx - .drainAllPages(relays.associateWith { listOf(filter) }, timeoutSecs * 1000) + .drain(relays.associateWith { listOf(filter) }, timeoutSecs * 1000, pendingOnAuthRequired = true) .map { it.second } .filterIsInstance() // Newest per addressable slug (replaceable): keep the latest for each d tag. 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 new file mode 100644 index 0000000000..044ca61fde --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLink.kt @@ -0,0 +1,137 @@ +/* + * 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.buzz.invite + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.io.encoding.Base64 +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. + * + * 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 + * accepting any configured join policy. The relay verifies the token's MAC (its own key), so a + * client only needs to read the payload, never validate the signature. + * + * Ground truth: `buzz-relay/src/invite_token.rs` (token shape + `verify_invite`) and + * `buzz-relay/src/api/invites.rs` (`claim_invite`, `accept_policy`). + */ +data class BuzzInvite( + /** The tenant host the invite is scoped to, e.g. `team.communities.buzz.xyz`. */ + 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`. */ + val communityId: String, + /** The role granted on claim (e.g. `member`) — the payload's `r`. */ + val role: String, + /** Unix-seconds expiry, or null when the payload omits it — the payload's `e`. */ + val expiresAt: Long?, +) { + /** The tenant's relay websocket URL. */ + fun relayUrl(): String = "wss://$host" + + /** The tenant's HTTPS base for the invite/policy REST endpoints. */ + fun httpBase(): String = "https://$host" + + /** True when [nowSecs] is at or past the invite's expiry (client-side courtesy check). */ + fun isExpired(nowSecs: Long): Boolean = expiresAt != null && nowSecs >= expiresAt +} + +object BuzzInviteLink { + private const val MARKER = "/invite/" + + private val JSON = Json { ignoreUnknownKeys = true } + + @Serializable + private data class Payload( + val c: String? = null, + val r: String? = null, + val e: Long? = null, + val n: String? = null, + ) + + /** + * Parses a Buzz invite URL. Accepts the full `https:///invite/` link (any + * scheme) and tolerates a trailing `#fragment` or `?query`. Returns null when the URL is + * not an invite link or the token payload can't be read — including the Concord invite + * shape (`/invite/#`), which carries no `.`-separated base64 payload, so + * the two link families never collide. + */ + @OptIn(ExperimentalEncodingApi::class) + fun parse(url: String): BuzzInvite? { + val trimmed = url.trim() + val marker = trimmed.indexOf(MARKER) + if (marker < 0) return null + + val host = extractHost(trimmed, marker) ?: return null + + val afterMarker = trimmed.substring(marker + MARKER.length) + val code = + afterMarker + .substringBefore('#') + .substringBefore('?') + .trim() + if (code.isEmpty()) return null + + // Token = .. No dot → not a Buzz invite (e.g. a Concord naddr). + val payloadB64 = code.substringBefore('.') + if (payloadB64 == code || payloadB64.isEmpty()) return null + + val payload = + try { + val bytes = Base64.UrlSafe.decode(padBase64(payloadB64)) + JSON.decodeFromString(bytes.decodeToString()) + } catch (_: Exception) { + return null + } + + val community = payload.c?.takeIf { it.isNotBlank() } ?: return null + return BuzzInvite( + host = host, + code = code, + communityId = community, + role = payload.r?.takeIf { it.isNotBlank() } ?: "member", + expiresAt = payload.e, + ) + } + + /** The host between the scheme's `//` and the `/invite/` marker, or null when absent. */ + private fun extractHost( + url: String, + marker: Int, + ): String? { + val schemeEnd = url.indexOf("//") + val start = if (schemeEnd in 0 until marker) schemeEnd + 2 else 0 + val host = url.substring(start, marker).trim() + return host.takeIf { it.isNotEmpty() && '/' !in it } + } + + /** Right-pads a base64url string to a multiple of 4 so the padded decoder accepts it. */ + private fun padBase64(s: String): String { + val remainder = s.length % 4 + return if (remainder == 0) s else s + "=".repeat(4 - remainder) + } +} 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 new file mode 100644 index 0000000000..2bbf4beb78 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/invite/BuzzInviteLinkTest.kt @@ -0,0 +1,71 @@ +/* + * 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.buzz.invite + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class BuzzInviteLinkTest { + // A real invite token minted by amethyst.communities.buzz.xyz (payload only is asserted; + // the signature part is opaque to clients — the relay verifies its own MAC on claim). + private val realToken = + "eyJjIjoiYzAzYWJhYTktNjVlNC00M2IxLWI5YjMtZjUwMmEyODEyZDBiIiwiciI6Im1lbWJlciIsImUiOjE3ODQ5ODk2NDksIm4iOiJFMVRMRFgxUHhWY1lFcTBIRVdpM1Z3In0" + + ".e-wUTcfoF6dYBmmeSMnKIVQ8M3zvml2dEj96tOMbVjY" + private val realUrl = "https://amethyst.communities.buzz.xyz/invite/$realToken" + + @Test + fun parsesARealBuzzInvite() { + val invite = BuzzInviteLink.parse(realUrl)!! + assertEquals("amethyst.communities.buzz.xyz", invite.host) + assertEquals(realToken, invite.code) + assertEquals("c03abaa9-65e4-43b1-b9b3-f502a2812d0b", invite.communityId) + assertEquals("member", invite.role) + assertEquals(1784989649L, invite.expiresAt) + assertEquals("wss://amethyst.communities.buzz.xyz", invite.relayUrl()) + assertEquals("https://amethyst.communities.buzz.xyz", invite.httpBase()) + } + + @Test + fun honorsTheExpiry() { + val invite = BuzzInviteLink.parse(realUrl)!! + assertTrue(invite.isExpired(1784989649L)) + assertTrue(invite.isExpired(1784989650L)) + assertTrue(!invite.isExpired(1784989648L)) + } + + @Test + fun toleratesTrailingFragmentAndQuery() { + assertEquals("c03abaa9-65e4-43b1-b9b3-f502a2812d0b", BuzzInviteLink.parse("$realUrl#x")!!.communityId) + assertEquals("c03abaa9-65e4-43b1-b9b3-f502a2812d0b", BuzzInviteLink.parse("$realUrl?ref=1")!!.communityId) + } + + @Test + fun rejectsNonInviteAndConcordShapes() { + assertNull(BuzzInviteLink.parse("https://amethyst.communities.buzz.xyz/")) + assertNull(BuzzInviteLink.parse("https://example.com/invite/")) + // A Concord invite is /invite/# — no dot-separated base64 payload. + assertNull(BuzzInviteLink.parse("https://amethyst.social/invite/naddr1abcdef#deadbeef")) + // Dotless token → not a Buzz invite. + assertNull(BuzzInviteLink.parse("https://host.example/invite/justsometext")) + } +}