refactor(marmot,cli): lift shared logic into quartz/commons

Five extractions so the Amethyst UI and the new amy CLI share one
implementation for each piece of Marmot/identity behaviour instead of
reimplementing it per platform.

quartz additions:
- MarmotGroupData.bootstrap(..) + withMergedRelays(..): factory for the
  metadata shape every inviter stamps on a fresh group and the outbox-
  merge rule every admin commit carries forward.
- KeyPackageFetcher: (a) pure fetchRelaysFor union of target kind:10051,
  target outbox, my outbox; (b) one-shot fetchKeyPackage; (c) publish-
  side resolveKeyPackagePublishRelays.
- resolveUserHexOrNull: single entry point for npub / nprofile / 64-hex
  / NIP-05 identifiers. Uses the existing Nip19Parser + Nip05Client
  infra so NIP-05 resolution is live over HTTP.

commons additions:
- MarmotManager.leafIndexOf(..): pubkey -> leaf index lookup used by
  every removeMember caller.
- MarmotManager.addMember(nostrGroupId, keyPackageEvent, relays):
  convenience overload that lifts the base64 decode into the manager.
- MarmotManager.buildTextMessage(..) returning a TextMessageBundle;
  optionally persists the outbound inner event (CLI needs persist,
  Amethyst relies on LocalCache loopback).
- MarmotIngest.ingest(event): routes kind:1059 / kind:445 through
  unwrap -> processWelcome / processGroupEvent -> persist the decrypted
  application message. Deliberately platform-agnostic.

Retrofits:
- Account.addMarmotGroupMember and fetchKeyPackageAndAddMember now take
  a KeyPackageEvent directly; Account.keyPackagePublishRelays delegates
  to KeyPackageFetcher.
- AccountViewModel.updateMarmotGroupMetadata uses bootstrap + merged.
- AccountViewModel.sendMarmotGroupMessage builds the kind:9 template via
  buildTextMessage(persistOwn = false) to avoid double-persisting.
- AccountSessionManager's NIP-05 login branch delegates to
  resolveUserHexOrNull; no more hand-rolled Nip05Id / nip05Client.get.
- CLI Context exposes nip05Client and requireUserHex; syncIncoming now
  calls MarmotManager.ingest; all command sites use KeyPackageFetcher
  + the shared identifier resolver. Npubs util deleted.

https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
This commit is contained in:
Claude
2026-04-21 20:43:00 +00:00
parent 3fa45a47c6
commit 89198ab24e
17 changed files with 556 additions and 220 deletions
@@ -1995,9 +1995,6 @@ class Account(
val manager = marmotManager ?: return "Error: Marmot not initialized"
if (!isWriteable()) return "Error: Account is read-only"
// Build filter for the member's KeyPackages
val filter = manager.subscriptionManager.keyPackageFilter(memberPubKey)
// Per MIP-00, invitees advertise the relays that host their
// KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look
// there first, then fall back to the invitee's NIP-65 outbox
@@ -2019,20 +2016,18 @@ class Account(
.outboxRelays()
?.toSet()
.orEmpty()
val fetchRelays = memberKeyPackageRelays + memberOutbox + myOutbox
val fetchRelays =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchRelaysFor(memberKeyPackageRelays, memberOutbox, myOutbox)
Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " +
"(memberKeyPackageRelays=${memberKeyPackageRelays.size}, memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}"
}
// Query across the combined relay set
val filterMap = fetchRelays.associateWith { listOf(filter) }
val event =
client.fetchFirst(
filters = filterMap,
)
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchKeyPackage(client, memberPubKey, fetchRelays)
if (event == null) {
Log.w("MarmotDbg") {
@@ -2045,21 +2040,12 @@ class Account(
"fetchKeyPackageAndAddMember: got KeyPackage event id=${event.id.take(8)}… kind=${event.kind} authored=${event.pubKey.take(8)}"
}
if (event !is com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent) {
Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: unexpected kind ${event.kind}" }
return "Error: Unexpected event type received"
}
val keyPackageBase64 = event.keyPackageBase64()
if (keyPackageBase64.isBlank()) {
Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: KeyPackage event has empty content" }
return "Error: KeyPackage event has empty content"
}
val keyPackageBytes =
kotlin.io.encoding.Base64
.decode(keyPackageBase64)
val keyPackageEventId = event.id
// The relays embedded in the WelcomeEvent tell the new member
// where to subscribe for subsequent GroupEvents. Use our own
// outbox — that's where we will publish them.
@@ -2071,9 +2057,7 @@ class Account(
addMarmotGroupMember(
nostrGroupId = nostrGroupId,
memberPubKey = memberPubKey,
keyPackageBytes = keyPackageBytes,
keyPackageEventId = keyPackageEventId,
keyPackageEvent = event,
groupRelays = groupRelays,
)
@@ -2086,14 +2070,13 @@ class Account(
*/
suspend fun addMarmotGroupMember(
nostrGroupId: HexKey,
memberPubKey: HexKey,
keyPackageBytes: ByteArray,
keyPackageEventId: HexKey,
keyPackageEvent: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent,
groupRelays: List<NormalizedRelayUrl>,
) {
val memberPubKey = keyPackageEvent.pubKey
Log.d("MarmotDbg") {
"addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}" +
"keyPackageBytes=${keyPackageBytes.size}B groupRelays=${groupRelays.size}"
"groupRelays=${groupRelays.size}"
}
val manager = marmotManager ?: return
if (!isWriteable()) return
@@ -2101,9 +2084,7 @@ class Account(
val (commitEvent, welcomeDelivery) =
manager.addMember(
nostrGroupId = nostrGroupId,
memberPubKey = memberPubKey,
keyPackageBytes = keyPackageBytes,
keyPackageEventId = keyPackageEventId,
keyPackageEvent = keyPackageEvent,
relays = groupRelays,
)
@@ -2167,17 +2148,11 @@ class Account(
/**
* Relays where this account publishes kind:30443 KeyPackage events.
*
* Per MIP-00, these should match the relays advertised in the user's
* kind:10051 KeyPackage Relay List so that other clients can discover
* and fetch them. Falls back to the standard outbox set when no list
* has been configured yet, since that's also where the user's other
* write-oriented events land.
* Per MIP-00: prefer kind:10051 KeyPackage Relay List; fall back to NIP-65 outbox.
*/
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> {
val list = keyPackageRelayList.flow.value
return if (list.isNotEmpty()) list else outboxRelays.flow.value
}
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.publishRelaysFor(keyPackageRelayList.flow.value, outboxRelays.flow.value)
/**
* Publish or rotate KeyPackage events.
@@ -44,7 +44,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
@@ -223,21 +222,20 @@ class AccountSessionManager(
loginSync(newKey, transientAccount, loginWithExternalSigner, packageName, onError)
}
} else if (EMAIL_PATTERN.matcher(key).matches()) {
val nip05 = Nip05Id.parse(key)
if (nip05 == null) {
onError("Could not parse nip05 address: $nip05")
} else {
try {
val pubkeyInfo = nip05ClientBuilder().get(nip05)
if (pubkeyInfo == null) {
onError("User not found in the nip05 server: $nip05")
} else {
loginSync(Hex.decode(pubkeyInfo.pubkey).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onError("Could not load nip05 address from the server: $nip05. ${e.message}")
// Delegate to the shared quartz resolver so NIP-05 handling stays in
// lockstep with the CLI and anywhere else we accept user identifiers.
try {
val hex =
com.vitorpamplona.quartz.nip05DnsIdentifiers
.resolveUserHexOrNull(key, nip05ClientBuilder())
if (hex == null) {
onError("User not found in the nip05 server: $key")
} else {
loginSync(Hex.decode(hex).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onError("Could not load nip05 address from the server: $key. ${e.message}")
}
} else {
loginSync(key, transientAccount, loginWithExternalSigner, packageName, onError)
@@ -1453,14 +1453,12 @@ class AccountViewModel(
nostrGroupId: String,
text: String,
) {
val template =
com.vitorpamplona.quartz.nip01Core.signers.eventTemplate<com.vitorpamplona.quartz.nip01Core.core.Event>(
kind = 9,
description = text,
)
val innerEvent = account.signer.sign<com.vitorpamplona.quartz.nip01Core.core.Event>(template)
// Inner event construction lives on MarmotManager so CLI and UI don't drift.
// persistOwn=false because Account.sendMarmotGroupMessage routes the outer
// event through LocalCache which already handles own-message display.
val bundle = account.marmotManager?.buildTextMessage(nostrGroupId, text, persistOwn = false) ?: return
val relays = marmotGroupRelays(nostrGroupId)
account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays)
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays)
}
suspend fun sendMarmotGroupMediaMessage(
@@ -1558,7 +1556,6 @@ class AccountViewModel(
name: String,
description: String,
) {
val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId)
// Stamp the inviter's outbox relays into the group metadata so that
// every member ends up with a single canonical relay set for kind:445
// GroupEvents. Without this, both the inviter and the invitee fall
@@ -1569,29 +1566,19 @@ class AccountViewModel(
val outboxRelayStrings =
account.outboxRelays.flow.value
.map { it.url }
val mergedRelays =
(currentMetadata?.relays.orEmpty() + outboxRelayStrings)
.distinct()
val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId)
val updatedMetadata =
if (currentMetadata != null) {
currentMetadata.copy(
name = name,
description = description,
relays = mergedRelays,
)
} else {
// No MarmotGroupData extension exists yet — this happens for groups
// created before initial metadata was persisted, or right after a
// fresh `createMarmotGroup`. Build a brand-new extension with the
// creator as the sole admin so the GCE proposal carries valid data.
com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData(
nostrGroupId = nostrGroupId,
name = name,
description = description,
adminPubkeys = listOf(account.signer.pubKey),
relays = mergedRelays,
)
}
currentMetadata
?.copy(name = name, description = description)
?.withMergedRelays(outboxRelayStrings)
?: com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
.bootstrap(
nostrGroupId = nostrGroupId,
creatorPubKey = account.signer.pubKey,
outboxRelays = outboxRelayStrings,
name = name,
description = description,
)
val relays = marmotGroupRelays(nostrGroupId)
account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
}
@@ -24,8 +24,8 @@ import com.vitorpamplona.amethyst.cli.stores.FileKeyPackageBundleStore
import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore
import com.vitorpamplona.amethyst.cli.stores.FileMlsGroupStateStore
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.marmot.ingest
import com.vitorpamplona.quartz.marmot.MarmotFilters
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -72,6 +72,18 @@ class Context(
websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp },
)
/**
* NIP-05 resolver for turning `alice@damus.io`-style identifiers into pubkeys.
* Uses the same OkHttp instance as the WebSocket client so we share connection
* pools and TLS sessions.
*/
val nip05Client: com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client =
com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client(
fetcher =
com.vitorpamplona.quartz.nip05DnsIdentifiers
.OkHttpNip05Fetcher { _ -> okhttp },
)
private val mlsStore = FileMlsGroupStateStore(dataDir.groupsDir)
private val keyPackageStore = FileKeyPackageBundleStore(dataDir.keyPackageBundleFile)
private val messageStore = FileMarmotMessageStore(dataDir.groupsDir)
@@ -93,6 +105,18 @@ class Context(
prepared = true
}
/**
* Resolve `npub…` / `nprofile…` / 64-hex / `name@domain.tld` to a pubkey hex.
* Delegates to the shared [resolveUserHexOrNull] in quartz so the UI and CLI
* accept the exact same identifier formats. Throws on unrecognised input —
* command handlers catch [IllegalArgumentException] at the top level and
* translate to `{"error": "bad_args"}`.
*/
suspend fun requireUserHex(input: String): com.vitorpamplona.quartz.nip01Core.core.HexKey =
com.vitorpamplona.quartz.nip05DnsIdentifiers
.resolveUserHexOrNull(input, nip05Client)
?: throw IllegalArgumentException("Could not resolve user: '$input' (accepts npub, nprofile, 64-hex, or name@domain.tld)")
fun outboxRelays(): Set<NormalizedRelayUrl> = relays.normalized("nip65")
fun inboxRelays(): Set<NormalizedRelayUrl> = relays.normalized("inbox")
@@ -237,39 +261,18 @@ class Context(
val maxGroupSeen = perGroupFilters.keys.associateWith { state.groupSince[it] ?: 0L }.toMutableMap()
for ((relay, event) in events) {
// All the MLS/NIP-59 decryption + persistence lives in MarmotIngest —
// we only care about bookkeeping (since-cursors, logging) here.
val result = marmot.ingest(event)
System.err.println("[cli] ingest ${event.kind}/${event.id.take(8)} via $relay${result::class.simpleName}")
when (event.kind) {
GiftWrapEvent.KIND -> {
// kind:1059 — try to unwrap. If it's a Welcome, let the
// inbound processor apply it; anything else is ignored.
val gw = event as? GiftWrapEvent ?: continue
try {
val inner = gw.unwrapOrNull(signer) ?: continue
if (inner.kind == WelcomeEvent.KIND && inner is WelcomeEvent) {
val hint = inner.nostrGroupId()
val res = marmot.processWelcome(inner, hint)
System.err.println("[cli] Welcome via $relay$res")
}
} catch (e: Exception) {
System.err.println("[cli] failed to unwrap giftwrap ${event.id.take(8)}: ${e.message}")
}
if (event.createdAt > maxGwSeen) maxGwSeen = event.createdAt
}
GroupEvent.KIND -> {
val ge = event as? GroupEvent ?: continue
val gid = ge.groupId() ?: continue
try {
val res = marmot.processGroupEvent(ge)
// Mirror DecryptAndIndexProcessor on Amethyst: application
// messages get persisted at decrypt-time, because MLS ratchet
// advancement makes the ciphertext un-re-decryptable later.
if (res is com.vitorpamplona.quartz.marmot.GroupEventResult.ApplicationMessage) {
marmot.persistDecryptedMessage(res.groupId, res.innerEventJson)
}
System.err.println("[cli] GroupEvent ${event.id.take(8)}${res::class.simpleName}")
} catch (e: Exception) {
System.err.println("[cli] processGroupEvent failed: ${e.message}")
}
val gid = (event as? GroupEvent)?.groupId() ?: continue
val prev = maxGroupSeen[gid] ?: 0L
if (event.createdAt > prev) maxGroupSeen[gid] = event.createdAt
}
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.cli.AwaitTimeout
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.amethyst.cli.util.Npubs
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import kotlinx.coroutines.delay
@@ -59,12 +58,12 @@ object AwaitCommands {
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "await key-package <npub>")
val target = Npubs.resolveToHex(rest[0])
val args = Args(rest.drop(1).toTypedArray())
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val target = ctx.requireUserHex(rest[0])
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(target)
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
@@ -129,7 +128,7 @@ object AwaitCommands {
): Int =
pollCondition(dataDir, rest, "await member <gid> <npub>", targetIdx = 1) { ctx, rawArgs ->
val gid = rawArgs[0]
val target = Npubs.resolveToHex(rawArgs[1])
val target = ctx.requireUserHex(rawArgs[1])
if (!ctx.marmot.isMember(gid)) {
null
} else if (ctx.marmot.memberPubkeys(gid).any { it.pubkey == target }) {
@@ -145,7 +144,7 @@ object AwaitCommands {
): Int =
pollCondition(dataDir, rest, "await admin <gid> <npub>", targetIdx = 1) { ctx, rawArgs ->
val gid = rawArgs[0]
val target = Npubs.resolveToHex(rawArgs[1])
val target = ctx.requireUserHex(rawArgs[1])
if (!ctx.marmot.isMember(gid)) {
null
} else if (ctx.marmot
@@ -23,11 +23,6 @@ package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.amethyst.cli.util.Npubs
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* `group add <group_id> <npub> [<npub> ...]` — fetch each invitee's
@@ -37,40 +32,42 @@ import kotlin.io.encoding.ExperimentalEncodingApi
* gift wrap.
*/
object GroupAddMemberCommand {
@OptIn(ExperimentalEncodingApi::class)
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "group add <group_id> <npub> [<npub> ...]")
val gid = rest[0]
val invitees = rest.drop(1).map { Npubs.resolveToHex(it) }
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
// Accept any identifier the UI would: npub1…, nprofile1…, 64-hex,
// NIP-05 (name@domain). Resolution fires NIP-05 HTTP fetches in parallel
// where applicable; bech32/hex stays fully offline.
val invitees = rest.drop(1).map { ctx.requireUserHex(it) }
val groupRelays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val report = mutableListOf<Map<String, Any?>>()
for (pub in invitees) {
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(pub)
val relays = ctx.anyRelays()
val filters = relays.associateWith { listOf(filter) }
val kpEvent = ctx.client.fetchFirst(filters = filters, timeoutMs = 10_000)
if (kpEvent == null || kpEvent !is KeyPackageEvent) {
val relays =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchRelaysFor(emptySet(), emptySet(), ctx.anyRelays())
val kpEvent =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchKeyPackage(ctx.client, pub, relays, timeoutMs = 10_000)
if (kpEvent == null) {
report.add(mapOf("pubkey" to pub, "status" to "no_key_package"))
continue
}
val kpBytes = Base64.decode(kpEvent.keyPackageBase64())
val (commitEvent, welcomeDelivery) =
ctx.marmot.addMember(
nostrGroupId = gid,
memberPubKey = pub,
keyPackageBytes = kpBytes,
keyPackageEventId = kpEvent.id,
keyPackageEvent = kpEvent,
relays = groupRelays.toList(),
)
@@ -42,16 +42,14 @@ object GroupCreateCommand {
ctx.marmot.createGroup(gid)
// Stamp initial metadata: name + our outbox relays + self as admin.
// Mirrors CreateGroupScreen.proceedWithCreate() on the Amethyst side.
// Stamp initial metadata via the shared factory so UI + CLI stay byte-identical.
val outboxUrls = ctx.outboxRelays().map { it.url }
val metadata =
MarmotGroupData(
MarmotGroupData.bootstrap(
nostrGroupId = gid,
creatorPubKey = ctx.identity.pubKeyHex,
outboxRelays = outboxUrls,
name = name,
description = "",
adminPubkeys = listOf(ctx.identity.pubKeyHex),
relays = outboxUrls,
)
val commit = ctx.marmot.updateGroupMetadata(gid, metadata)
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.amethyst.cli.util.Npubs
object GroupMembershipCommands {
suspend fun remove(
@@ -32,26 +31,25 @@ object GroupMembershipCommands {
): Int {
if (rest.size < 2) return Json.error("bad_args", "group remove <gid> <npub>")
val gid = rest[0]
val target = Npubs.resolveToHex(rest[1])
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val target = ctx.requireUserHex(rest[1])
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val member =
ctx.marmot.memberPubkeys(gid).firstOrNull { it.pubkey == target }
val leafIndex =
ctx.marmot.leafIndexOf(gid, target)
?: return Json.error("not_in_group", target)
val outbound =
ctx.marmot.removeMember(nostrGroupId = gid, targetLeafIndex = member.leafIndex)
val outbound = ctx.marmot.removeMember(nostrGroupId = gid, targetLeafIndex = leafIndex)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(outbound.signedEvent, targets)
Json.writeLine(
mapOf(
"group_id" to gid,
"removed" to target,
"leaf_index" to member.leafIndex,
"leaf_index" to leafIndex,
"epoch" to ctx.marmot.groupEpoch(gid),
"commit_event_id" to outbound.signedEvent.id,
"published_to" to ack.filterValues { it }.keys.map { it.url },
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.amethyst.cli.util.Npubs
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -37,7 +36,7 @@ object GroupMetadataCommands {
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "group rename <gid> <name>")
return edit(dataDir, rest[0]) { it.copy(name = rest[1]) }
return edit(dataDir, rest[0]) { _, cur -> cur.copy(name = rest[1]) }
}
suspend fun promote(
@@ -45,8 +44,8 @@ object GroupMetadataCommands {
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "group promote <gid> <npub>")
val newAdmin = Npubs.resolveToHex(rest[1])
return edit(dataDir, rest[0]) { cur ->
return edit(dataDir, rest[0]) { ctx, cur ->
val newAdmin = ctx.requireUserHex(rest[1])
val admins = cur.adminPubkeys.toMutableList()
if (newAdmin !in admins) admins.add(newAdmin)
cur.copy(adminPubkeys = admins)
@@ -58,8 +57,8 @@ object GroupMetadataCommands {
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "group demote <gid> <npub>")
val target = Npubs.resolveToHex(rest[1])
return edit(dataDir, rest[0]) { cur ->
return edit(dataDir, rest[0]) { ctx, cur ->
val target = ctx.requireUserHex(rest[1])
val admins = cur.adminPubkeys.filter { it != target }
cur.copy(adminPubkeys = admins)
}
@@ -68,23 +67,22 @@ object GroupMetadataCommands {
private suspend fun edit(
dataDir: DataDir,
gid: HexKey,
mutate: (MarmotGroupData) -> MarmotGroupData,
mutate: suspend (Context, MarmotGroupData) -> MarmotGroupData,
): Int {
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val outboxUrls = ctx.outboxRelays().map { it.url }
val cur =
ctx.marmot.groupMetadata(gid)
?: MarmotGroupData(
?: MarmotGroupData.bootstrap(
nostrGroupId = gid,
name = "",
description = "",
adminPubkeys = listOf(ctx.identity.pubKeyHex),
relays = ctx.outboxRelays().map { it.url },
creatorPubKey = ctx.identity.pubKeyHex,
outboxRelays = outboxUrls,
)
val updated = mutate(cur)
val updated = mutate(ctx, cur).withMergedRelays(outboxUrls)
val commit = ctx.marmot.updateGroupMetadata(gid, updated)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
@@ -23,9 +23,6 @@ package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.amethyst.cli.util.Npubs
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
object KeyPackageCommands {
suspend fun dispatch(
@@ -68,16 +65,20 @@ object KeyPackageCommands {
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "key-package check <npub>")
val targetHex = Npubs.resolveToHex(rest[0])
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(targetHex)
val relays = ctx.anyRelays()
val targetHex = ctx.requireUserHex(rest[0])
// CLI doesn't (yet) cache target's kind:10051/10002 — just ask every
// configured relay. Amethyst, which does cache those, passes them in.
val relays =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchRelaysFor(emptySet(), emptySet(), ctx.anyRelays())
if (relays.isEmpty()) return Json.error("no_relays", "configure relays first")
val filtersByRelay = relays.associateWith { listOf(filter) }
val event = ctx.client.fetchFirst(filters = filtersByRelay, timeoutMs = 10_000)
if (event == null || event !is KeyPackageEvent) {
val event =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchKeyPackage(ctx.client, targetHex, relays, timeoutMs = 10_000)
if (event == null) {
return Json.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)")
}
Json.writeLine(
@@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
object MessageCommands {
suspend fun dispatch(
@@ -54,24 +53,16 @@ object MessageCommands {
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val template =
eventTemplate<com.vitorpamplona.quartz.nip01Core.core.Event>(kind = 9, description = text)
val innerEvent = ctx.signer.sign<com.vitorpamplona.quartz.nip01Core.core.Event>(template)
val outbound = ctx.marmot.buildGroupMessage(gid, innerEvent)
// Persist our own outbound inner event so `message list` includes it
// alongside remote messages.
ctx.marmot.persistDecryptedMessage(gid, innerEvent.toJson())
val bundle = ctx.marmot.buildTextMessage(gid, text)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(outbound.signedEvent, targets)
val ack = ctx.publish(bundle.outbound.signedEvent, targets)
Json.writeLine(
mapOf(
"group_id" to gid,
"inner_event_id" to innerEvent.id,
"outer_event_id" to outbound.signedEvent.id,
"kind" to innerEvent.kind,
"inner_event_id" to bundle.innerEvent.id,
"outer_event_id" to bundle.outbound.signedEvent.id,
"kind" to bundle.innerEvent.kind,
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
@@ -1,39 +0,0 @@
/*
* 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.amethyst.cli.util
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
/** Accept either an `npub1…` or a raw 64-hex pubkey; always return hex. */
object Npubs {
fun resolveToHex(input: String): String {
val trimmed = input.trim()
// Already hex?
if (trimmed.length == 64 && trimmed.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) {
return trimmed.lowercase()
}
if (trimmed.startsWith("npub1") || trimmed.startsWith("npub")) {
return trimmed.bechToBytes().toHexKey()
}
throw IllegalArgumentException("expected npub or 64-hex pubkey, got: $input")
}
}
@@ -0,0 +1,148 @@
/*
* 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.amethyst.commons.marmot
import com.vitorpamplona.quartz.marmot.GroupEventResult
import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor
import com.vitorpamplona.quartz.marmot.WelcomeResult
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
/**
* Outcome of routing a single inbound Nostr event through the Marmot pipeline.
*
* Drives both Amethyst's `DecryptAndIndexProcessor.GroupEventHandler` and the
* CLI's `Context.syncIncoming`. Platform-specific side effects (UI notifications,
* "mark as known", cache hints) happen at the call site based on which variant
* came back; the ingest method handles only the MLS/crypto parts that are the
* same everywhere.
*/
sealed class MarmotIngestResult {
/** A Welcome arrived and we successfully joined a new group. */
data class JoinedGroup(
val nostrGroupId: HexKey,
val needsKeyPackageRotation: Boolean,
) : MarmotIngestResult()
/** A Welcome for a group we're already in — benign replay. */
data class AlreadyInGroup(
val nostrGroupId: HexKey,
) : MarmotIngestResult()
/** A kind:445 carried an application message we decrypted. Already persisted. */
data class Message(
val inner: GroupEventResult.ApplicationMessage,
) : MarmotIngestResult()
/** A kind:445 carried a commit that advanced the group epoch. */
data class Commit(
val inner: GroupEventResult.CommitProcessed,
) : MarmotIngestResult()
/** A kind:445 whose outer layer we couldn't decrypt (pre-join epoch, etc). Debug-only. */
data class UndecryptableOuter(
val groupId: HexKey,
val retainedEpochCount: Int,
) : MarmotIngestResult()
/** Deduplicate / out-of-order commits / unsupported content. Not an error. */
data object Ignored : MarmotIngestResult()
/** Something blew up. Callers log. */
data class Failure(
val message: String,
val cause: Throwable? = null,
) : MarmotIngestResult()
}
/**
* Route a single event through the Marmot inbound pipeline.
*
* Kind 1059 (gift wraps): unwrap → if inner kind:444, process Welcome.
* Kind 445 (group events): decrypt + process; on [GroupEventResult.ApplicationMessage],
* persist the decrypted inner JSON so [MarmotManager.loadStoredMessages] sees it.
*
* Other kinds are returned as [MarmotIngestResult.Ignored] — callers decide
* what to do with them (most route through their own feed ingestion).
*/
suspend fun MarmotManager.ingest(event: Event): MarmotIngestResult =
when (event) {
is GiftWrapEvent -> ingestGiftWrap(event)
is GroupEvent -> ingestGroupEvent(event)
else -> MarmotIngestResult.Ignored
}
private suspend fun MarmotManager.ingestGiftWrap(wrap: GiftWrapEvent): MarmotIngestResult =
try {
val inner = wrap.unwrapOrNull(signer) ?: return MarmotIngestResult.Ignored
if (!MarmotInboundProcessor.isWelcomeEvent(inner) || inner !is WelcomeEvent) {
return MarmotIngestResult.Ignored
}
when (val result = processWelcome(inner, inner.nostrGroupId())) {
is WelcomeResult.Joined -> {
MarmotIngestResult.JoinedGroup(
nostrGroupId = result.nostrGroupId,
needsKeyPackageRotation = result.needsKeyPackageRotation,
)
}
is WelcomeResult.AlreadyJoined -> {
MarmotIngestResult.AlreadyInGroup(result.nostrGroupId)
}
is WelcomeResult.Error -> {
MarmotIngestResult.Failure(result.message, result.cause)
}
}
} catch (e: Exception) {
MarmotIngestResult.Failure("giftwrap unwrap failed: ${e.message}", e)
}
private suspend fun MarmotManager.ingestGroupEvent(ge: GroupEvent): MarmotIngestResult =
when (val result = processGroupEvent(ge)) {
is GroupEventResult.ApplicationMessage -> {
// MLS ratchets once we decrypt; future reads of the same ciphertext
// would fail — persist the plaintext now so restarts/replays see it.
persistDecryptedMessage(result.groupId, result.innerEventJson)
MarmotIngestResult.Message(result)
}
is GroupEventResult.CommitProcessed -> {
MarmotIngestResult.Commit(result)
}
is GroupEventResult.Duplicate,
is GroupEventResult.CommitPending,
-> {
MarmotIngestResult.Ignored
}
is GroupEventResult.UndecryptableOuterLayer -> {
MarmotIngestResult.UndecryptableOuter(result.groupId, result.retainedEpochCount)
}
is GroupEventResult.Error -> {
MarmotIngestResult.Failure(result.message, result.cause)
}
}
@@ -154,6 +154,56 @@ class MarmotManager(
innerEvent: Event,
): OutboundGroupEvent = outboundProcessor.buildGroupEvent(nostrGroupId, innerEvent)
/**
* Build a kind:9 chat-message GroupEvent from plain text. The inner event is
* signed with this manager's signer and optionally persisted to the local
* decrypted-message log so `loadStoredMessages` reflects our own outbound
* immediately (without waiting for relay loopback).
*
* Platform callers that already maintain their own "own event" cache (i.e.
* Amethyst's `LocalCache.justConsumeMyOwnEvent`) should pass `persistOwn = false`.
* Headless callers (CLI) should leave it at the default.
*
* @return the signed kind:445 outer event together with the inner kind:9
* event id, so the caller can reference it for replies/reactions.
*/
suspend fun buildTextMessage(
nostrGroupId: HexKey,
text: String,
persistOwn: Boolean = true,
): TextMessageBundle {
val template =
com.vitorpamplona.quartz.nip01Core.signers
.eventTemplate<Event>(kind = 9, description = text)
val innerEvent = signer.sign<Event>(template)
val outbound = buildGroupMessage(nostrGroupId, innerEvent)
if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson())
return TextMessageBundle(outbound = outbound, innerEvent = innerEvent)
}
/**
* Add a member to a group by consuming their published [KeyPackageEvent].
*
* Convenience over [addMember] that handles base64 decoding and lifts the
* event id into the WelcomeDelivery. Prefer this overload — both the UI's
* `Account.addMarmotGroupMember` and the CLI's `group add` command call it.
*/
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
suspend fun addMember(
nostrGroupId: HexKey,
keyPackageEvent: KeyPackageEvent,
relays: List<NormalizedRelayUrl>,
): Pair<OutboundGroupEvent, WelcomeDelivery?> =
addMember(
nostrGroupId = nostrGroupId,
memberPubKey = keyPackageEvent.pubKey,
keyPackageBytes =
kotlin.io.encoding.Base64
.decode(keyPackageEvent.keyPackageBase64()),
keyPackageEventId = keyPackageEvent.id,
relays = relays,
)
/**
* Add a member to a group.
* Returns the commit GroupEvent to publish, and the WelcomeDelivery for the new member.
@@ -430,6 +480,19 @@ class MarmotManager(
*/
fun groupEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch
/**
* Resolve the MLS leaf index for a member by Nostr pubkey, or null if that
* pubkey isn't currently in the group.
*
* `removeMember` needs a leaf index; every caller (UI remove-member dialog
* and CLI `group remove`) previously did its own pubkey→leaf lookup through
* [memberPubkeys]. Centralised here so the scan lives in one place.
*/
fun leafIndexOf(
nostrGroupId: HexKey,
pubKey: HexKey,
): Int? = memberPubkeys(nostrGroupId).firstOrNull { it.pubkey == pubKey }?.leafIndex
/**
* Get the MIP-04 media exporter secret for a group.
* MLS-Exporter("marmot", "encrypted-media", 32)
@@ -477,3 +540,13 @@ data class GroupMemberInfo(
val leafIndex: Int,
val pubkey: HexKey,
)
/**
* Result of [MarmotManager.buildTextMessage]: the signed outer kind:445 event
* to publish on group relays, plus the inner kind:9 event (for callers that
* need the inner id to reference it in replies or reactions).
*/
data class TextMessageBundle(
val outbound: OutboundGroupEvent,
val innerEvent: Event,
)
@@ -0,0 +1,91 @@
/*
* 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.marmot.mip00KeyPackages
import com.vitorpamplona.quartz.marmot.MarmotFilters
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Discovery helpers for MIP-00 KeyPackages.
*
* Pulled out of Amethyst's `Account.fetchKeyPackageAndAddMember` so the CLI
* (and any future non-Android caller) does not re-implement the same union-
* of-relays logic when inviting a user to a Marmot group.
*
* This is the lowest-useful layer — it knows *nothing* about how callers
* discover the target user's kind:10051 (KeyPackage Relay List) or kind:10002
* (NIP-65 outbox) — those live in platform-specific caches. Callers collect
* those sets themselves and pass them in.
*/
object KeyPackageFetcher {
/**
* Union of the three relay sets we'd ever want to query for a given user's
* KeyPackage, in priority order of specificity:
*
* 1. target's kind:10051 KeyPackage Relay List (most authoritative)
* 2. target's kind:10002 NIP-65 outbox (where the user publishes in general)
* 3. our own outbox (shared-relay fallback — someone publishing and us
* reading often overlap here)
*/
fun fetchRelaysFor(
targetKeyPackageRelays: Collection<NormalizedRelayUrl>,
targetOutbox: Collection<NormalizedRelayUrl>,
myOutbox: Collection<NormalizedRelayUrl>,
): Set<NormalizedRelayUrl> =
buildSet {
addAll(targetKeyPackageRelays)
addAll(targetOutbox)
addAll(myOutbox)
}
/**
* One-shot fetch of a user's KeyPackage across [relays], returning the first
* matching event or `null` if none of the relays yielded one before timeout.
*/
suspend fun fetchKeyPackage(
client: INostrClient,
targetPubKey: HexKey,
relays: Set<NormalizedRelayUrl>,
timeoutMs: Long = 30_000,
): KeyPackageEvent? {
if (relays.isEmpty()) return null
val filter = MarmotFilters.keyPackagesByAuthor(targetPubKey)
val event = client.fetchFirst(filters = relays.associateWith { listOf(filter) }, timeoutMs = timeoutMs)
return event as? KeyPackageEvent
}
/**
* Resolve which relays this account should publish its OWN KeyPackage to.
*
* Per MIP-00, a user's KeyPackages SHOULD live on the relays listed in their
* kind:10051 KeyPackageRelayListEvent. If they haven't published one yet,
* fall back to their NIP-65 outbox — that's where their other write-oriented
* events land and it keeps discovery working in the common "just starting out"
* case.
*/
fun publishRelaysFor(
keyPackageRelayList: Collection<NormalizedRelayUrl>,
myOutbox: Collection<NormalizedRelayUrl>,
): Set<NormalizedRelayUrl> = if (keyPackageRelayList.isNotEmpty()) keyPackageRelayList.toSet() else myOutbox.toSet()
}
@@ -111,6 +111,21 @@ data class MarmotGroupData(
/** Whether this group has an encrypted image set */
fun hasImage(): Boolean = imageHash != null && imageKey != null && imageNonce != null
/**
* Return a copy with [newRelays] unioned into [relays], de-duplicated and order-preserving.
*
* Every metadata-updating commit produced by the group creator or an admin SHOULD fold
* its author's own outbox into [relays] so new invitees learn a single canonical relay
* set for kind:445 from the Welcome, even when the inviter's outbox has drifted since
* the last commit. Both the UI and the CLI need this same merge rule — hence living
* on the data class.
*/
fun withMergedRelays(newRelays: Collection<String>): MarmotGroupData {
if (newRelays.isEmpty()) return this
val merged = (relays + newRelays).distinct()
return if (merged == relays) this else copy(relays = merged)
}
/**
* Encode this MarmotGroupData to TLS wire format bytes.
* Mirrors the [decodeTls] format.
@@ -185,6 +200,30 @@ data class MarmotGroupData(
const val EXTENSION_ID: UShort = 0xF2EEu
const val EXTENSION_ID_INT: Int = 0xF2EE
/**
* Build a freshly-minted [MarmotGroupData] for a group with no prior metadata —
* i.e. right after `MlsGroupManager.createGroup`. Creator becomes the sole admin
* and their outbox relays are stamped as the group's relay set.
*
* Both the Android UI (`AccountViewModel.updateMarmotGroupMetadata`) and the
* headless CLI need this same initial shape; keeping the factory here avoids
* subtle drift between them.
*/
fun bootstrap(
nostrGroupId: HexKey,
creatorPubKey: HexKey,
outboxRelays: Collection<String>,
name: String = "",
description: String = "",
): MarmotGroupData =
MarmotGroupData(
nostrGroupId = nostrGroupId,
name = name,
description = description,
adminPubkeys = listOf(creatorPubKey),
relays = outboxRelays.distinct(),
)
/**
* Find and decode the MarmotGroupData extension from a list of MLS extensions.
* Returns null if no extension with type 0xF2EE is present or if decoding fails.
@@ -0,0 +1,79 @@
/*
* 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.nip05DnsIdentifiers
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.coroutines.CancellationException
/**
* Resolve any user identifier the UI or a CLI might throw at us to a 64-hex
* Nostr pubkey. Accepts:
*
* - raw 64-hex pubkey
* - bech32 `npub1…`
* - bech32 `nprofile1…` (extracts the pubkey component)
* - bech32 `nsec1…` (derives the public key from the secret)
* - NIP-05 internet identifier (`name@domain.tld` or bare domain)
*
* Returns null when the input doesn't match any of the above or when a NIP-05
* lookup fails (network error, no match on server). Throws only on
* [CancellationException] so callers can still use structured concurrency.
*
* Pass a non-null [nip05Client] to enable NIP-05 resolution. When it's null,
* NIP-05-shaped inputs fall through and this returns null — that's the right
* behaviour for call sites that don't have HTTP access (e.g. a pure-offline
* context).
*/
suspend fun resolveUserHexOrNull(
input: String,
nip05Client: INip05Client? = null,
): HexKey? {
val trimmed = input.trim()
if (trimmed.isEmpty()) return null
// First try the bech32/hex path — it's pure and synchronous.
decodePublicKeyAsHexOrNull(trimmed)?.let { return it }
// NIP-05: name@domain.tld (bare `@domain` = root "_" account).
if (looksLikeNip05(trimmed) && nip05Client != null) {
try {
val id = Nip05Id.parse(trimmed) ?: return null
return nip05Client.get(id)?.pubkey
} catch (e: Exception) {
if (e is CancellationException) throw e
return null
}
}
return null
}
/**
* Cheap precheck — a plausible NIP-05 identifier has an `@` followed by at
* least one dot. Keeps us from issuing HTTP fetches on obvious non-matches
* like hex strings or single bech32 tokens.
*/
internal fun looksLikeNip05(value: String): Boolean {
val at = value.indexOf('@')
if (at <= 0 || at == value.length - 1) return false
val afterAt = value.substring(at + 1)
return afterAt.contains('.') && !afterAt.contains('@')
}