feat(buzz): invite-link redemption + live-interop fixes (amy)

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://<host>/invite/<token>` (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/<naddr>#…` shape (no collision).
- cli: `amy buzz join <invite-url>` — 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
Claude
2026-07-22 14:56:37 +00:00
parent c79e7d361f
commit bb39fb29fc
5 changed files with 428 additions and 16 deletions
+1 -1
View File
@@ -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). |
@@ -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
}
/**
@@ -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 <invite-url> [--accept-policy] redeem a Buzz invite link (HTTP claim, NIP-98)
|amy buzz post RELAY GID <text> 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<String>,
@@ -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<MemberAddedNotificationEvent>()
.mapNotNull { it.channel() }
.distinct()
if (channelIds.isEmpty()) {
Output.emit(mapOf("count" to 0, "dms" to emptyList<Any>()))
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<DmCreatedEvent>()
.distinctBy { it.id }
.filterIsInstance<SystemMessageEvent>()
.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 <invite-url> [--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<String>,
): Int {
val args = Args(rest)
val usage = "buzz join <invite-url> [--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<Int, String> =
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<Int, String> =
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 <text>` → 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<AgentTurnMetricEvent>()
.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<PersonaEvent>()
// Newest per addressable slug (replaceable): keep the latest for each d tag.
@@ -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://<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.
*
* 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://<host>/invite/<code>` 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/<naddr>#<fragment>`), 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 = <payloadB64url>.<sigB64url>. 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<Payload>(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)
}
}
@@ -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/<naddr>#<fragment> — 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"))
}
}