Merge pull request #2488 from vitorpamplona/claude/debug-marmot-whitenoise-oO26C

Add CLI interface (amy) for Marmot/MLS group operations
This commit is contained in:
Vitor Pamplona
2026-04-21 18:47:56 -04:00
committed by GitHub
63 changed files with 4286 additions and 179 deletions
@@ -0,0 +1,114 @@
/*
* 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.account
import com.vitorpamplona.amethyst.commons.defaults.DefaultChannels
import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultGlobalRelays
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65List
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
/**
* Full set of "new Amethyst account" bootstrap events: the nine signed
* events that [com.vitorpamplona.amethyst.ui.screen.AccountSessionManager.createNewAccount]
* produces for a brand-new user, in the exact same order and with the same
* relay defaults. Used by:
*
* - Amethyst's in-app "Create Account" flow (builds an `AccountSettings`
* from these).
* - `amy create` (writes the keypair to disk, publishes these to the
* default NIP-65 relay set).
*
* Each event is optional in the sense that the builder lets you null out
* individual ones via the args — but the sensible default is "all of them".
*/
data class AccountBootstrapEvents(
val userMetadata: MetadataEvent,
val contactList: ContactListEvent,
val nip65RelayList: AdvertisedRelayListEvent,
val dmRelayList: ChatMessageRelayListEvent,
val keyPackageRelayList: KeyPackageRelayListEvent,
val searchRelayList: SearchRelayListEvent,
val indexerRelayList: IndexerRelayListEvent,
val channelList: ChannelListEvent,
val relayFeedsList: RelayFeedsListEvent,
) {
/** All nine signed events in publication order. */
fun all(): List<com.vitorpamplona.quartz.nip01Core.core.Event> =
listOf(
userMetadata,
contactList,
nip65RelayList,
dmRelayList,
keyPackageRelayList,
searchRelayList,
indexerRelayList,
channelList,
relayFeedsList,
)
}
/**
* Build and sign the nine events that comprise a freshly-created Amethyst
* account. The caller is responsible for publishing them — typically to
* [DefaultNIP65RelaySet] — after at least a short delay so relay
* connections are established.
*
* [name] is the optional display name; null or blank produces a kind:0
* event with no `name` field.
*/
fun bootstrapAccountEvents(
signer: NostrSignerSync,
name: String? = null,
): AccountBootstrapEvents =
AccountBootstrapEvents(
userMetadata = signer.sign(MetadataEvent.newUser(name)),
contactList =
ContactListEvent.createFromScratch(
followUsers = listOf(ContactTag(signer.keyPair.pubKey.toHexKey(), null, null)),
relayUse = emptyMap(),
signer = signer,
),
nip65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, signer),
dmRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, signer),
// MIP-00: advertise the default outbox relays as KeyPackage hosts
// so other users can discover and fetch this account's KeyPackage
// events without having to guess where they were published.
keyPackageRelayList = KeyPackageRelayListEvent.create(DefaultNIP65RelaySet.toList(), signer),
searchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), signer),
indexerRelayList = IndexerRelayListEvent.create(DefaultIndexerRelayList.toList(), signer),
channelList = ChannelListEvent.create(emptyList(), DefaultChannels, signer),
relayFeedsList = RelayFeedsListEvent.create(DefaultGlobalRelays, signer),
)
@@ -0,0 +1,63 @@
/*
* 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.defaults
import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
/**
* Default relay/channel bundles applied to every freshly-created Amethyst
* account. Both the Android UI (`AccountSessionManager.createNewAccount`)
* and the `amy` CLI (`amy create`) seed new accounts from these so users
* land in the same connected state regardless of which client they start
* with.
*
* Pure data — no platform deps, no runtime config, no i18n. Change here,
* both clients follow.
*/
val DefaultChannels =
listOf(
// Anigma's Nostr
ChannelTag("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", Constants.nos),
// Amethyst's Group
ChannelTag("42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", Constants.nos),
)
val DefaultNIP65RelaySet = setOf(Constants.mom, Constants.nos, Constants.bitcoiner)
val DefaultNIP65List =
listOf(
AdvertisedRelayInfo(Constants.mom, AdvertisedRelayType.BOTH),
AdvertisedRelayInfo(Constants.nos, AdvertisedRelayType.BOTH),
AdvertisedRelayInfo(Constants.bitcoiner, AdvertisedRelayType.BOTH),
)
val DefaultGlobalRelays = listOf(Constants.wine, Constants.news)
val DefaultDMRelayList = listOf(Constants.auth, Constants.oxchat, Constants.nos)
val DefaultSearchRelayList =
setOf(Constants.wine, Constants.where, Constants.nostoday, Constants.antiprimal, Constants.ditto)
val DefaultIndexerRelayList =
setOf(Constants.purplepages, Constants.coracle, Constants.userkinds, Constants.yabu, Constants.nostr1)
@@ -0,0 +1,60 @@
/*
* 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.defaults
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
/**
* Relay URLs that Amethyst (and `amy`) seed new accounts with and use as
* sensible defaults across the app. Pure data — lives in commons so JVM /
* iOS / desktop builds get the same URLs without duplicating the list.
*/
object Constants {
val nos = RelayUrlNormalizer.normalize("wss://nos.lol")
val mom = RelayUrlNormalizer.normalize("wss://nostr.mom")
val primal = RelayUrlNormalizer.normalize("wss://relay.primal.net")
val damus = RelayUrlNormalizer.normalize("wss://relay.damus.io")
val wine = RelayUrlNormalizer.normalize("wss://nostr.wine")
val where = RelayUrlNormalizer.normalize("wss://relay.noswhere.com")
val elites = RelayUrlNormalizer.normalize("wss://nostrelites.org")
val bitcoiner = RelayUrlNormalizer.normalize("wss://nostr.bitcoiner.social")
val oxtr = RelayUrlNormalizer.normalize("wss://nostr.oxtr.dev")
val nostoday = RelayUrlNormalizer.normalize("wss://search.nos.today")
val antiprimal = RelayUrlNormalizer.normalize("wss://antiprimal.net")
val ditto = RelayUrlNormalizer.normalize("wss://relay.ditto.pub")
val auth = RelayUrlNormalizer.normalize("wss://auth.nostr1.com")
val oxchat = RelayUrlNormalizer.normalize("wss://relay.0xchat.com")
val news = RelayUrlNormalizer.normalize("wss://news.utxo.one")
val purplepages = RelayUrlNormalizer.normalize("wss://purplepag.es")
val coracle = RelayUrlNormalizer.normalize("wss://indexer.coracle.social")
val userkinds = RelayUrlNormalizer.normalize("wss://user.kindpag.es")
val yabu = RelayUrlNormalizer.normalize("wss://directory.yabu.me")
val nostr1 = RelayUrlNormalizer.normalize("wss://profiles.nostr1.com")
val bootstrapInbox = setOf(damus, primal, mom, nos, bitcoiner, oxtr, yabu)
val eventFinderRelays = setOf(wine, damus, primal, mom, nos, bitcoiner, oxtr)
}
@@ -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,
)