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
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
@@ -2001,9 +2001,6 @@ class Account(
val manager = marmotManager ?: return "Error: Marmot not initialized" val manager = marmotManager ?: return "Error: Marmot not initialized"
if (!isWriteable()) return "Error: Account is read-only" 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 // Per MIP-00, invitees advertise the relays that host their
// KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look // KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look
// there first, then fall back to the invitee's NIP-65 outbox // there first, then fall back to the invitee's NIP-65 outbox
@@ -2025,20 +2022,18 @@ class Account(
.outboxRelays() .outboxRelays()
?.toSet() ?.toSet()
.orEmpty() .orEmpty()
val fetchRelays = memberKeyPackageRelays + memberOutbox + myOutbox val fetchRelays =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchRelaysFor(memberKeyPackageRelays, memberOutbox, myOutbox)
Log.d("MarmotDbg") { Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " + "fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " +
"(memberKeyPackageRelays=${memberKeyPackageRelays.size}, memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}" "(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 = val event =
client.fetchFirst( com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
filters = filterMap, .fetchKeyPackage(client, memberPubKey, fetchRelays)
)
if (event == null) { if (event == null) {
Log.w("MarmotDbg") { Log.w("MarmotDbg") {
@@ -2051,21 +2046,12 @@ class Account(
"fetchKeyPackageAndAddMember: got KeyPackage event id=${event.id.take(8)}… kind=${event.kind} authored=${event.pubKey.take(8)}" "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() val keyPackageBase64 = event.keyPackageBase64()
if (keyPackageBase64.isBlank()) { if (keyPackageBase64.isBlank()) {
Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: KeyPackage event has empty content" } Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: KeyPackage event has empty content" }
return "Error: 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 // The relays embedded in the WelcomeEvent tell the new member
// where to subscribe for subsequent GroupEvents. Use our own // where to subscribe for subsequent GroupEvents. Use our own
// outbox — that's where we will publish them. // outbox — that's where we will publish them.
@@ -2077,9 +2063,7 @@ class Account(
addMarmotGroupMember( addMarmotGroupMember(
nostrGroupId = nostrGroupId, nostrGroupId = nostrGroupId,
memberPubKey = memberPubKey, keyPackageEvent = event,
keyPackageBytes = keyPackageBytes,
keyPackageEventId = keyPackageEventId,
groupRelays = groupRelays, groupRelays = groupRelays,
) )
@@ -2092,14 +2076,13 @@ class Account(
*/ */
suspend fun addMarmotGroupMember( suspend fun addMarmotGroupMember(
nostrGroupId: HexKey, nostrGroupId: HexKey,
memberPubKey: HexKey, keyPackageEvent: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent,
keyPackageBytes: ByteArray,
keyPackageEventId: HexKey,
groupRelays: List<NormalizedRelayUrl>, groupRelays: List<NormalizedRelayUrl>,
) { ) {
val memberPubKey = keyPackageEvent.pubKey
Log.d("MarmotDbg") { Log.d("MarmotDbg") {
"addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}" + "addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}" +
"keyPackageBytes=${keyPackageBytes.size}B groupRelays=${groupRelays.size}" "groupRelays=${groupRelays.size}"
} }
val manager = marmotManager ?: return val manager = marmotManager ?: return
if (!isWriteable()) return if (!isWriteable()) return
@@ -2107,9 +2090,7 @@ class Account(
val (commitEvent, welcomeDelivery) = val (commitEvent, welcomeDelivery) =
manager.addMember( manager.addMember(
nostrGroupId = nostrGroupId, nostrGroupId = nostrGroupId,
memberPubKey = memberPubKey, keyPackageEvent = keyPackageEvent,
keyPackageBytes = keyPackageBytes,
keyPackageEventId = keyPackageEventId,
relays = groupRelays, relays = groupRelays,
) )
@@ -2173,17 +2154,11 @@ class Account(
/** /**
* Relays where this account publishes kind:30443 KeyPackage events. * Relays where this account publishes kind:30443 KeyPackage events.
* * Per MIP-00: prefer kind:10051 KeyPackage Relay List; fall back to NIP-65 outbox.
* 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.
*/ */
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> { fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> =
val list = keyPackageRelayList.flow.value com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
return if (list.isNotEmpty()) list else outboxRelays.flow.value .publishRelaysFor(keyPackageRelayList.flow.value, outboxRelays.flow.value)
}
/** /**
* Publish or rotate KeyPackage events. * Publish or rotate KeyPackage events.
@@ -37,7 +37,6 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
@@ -55,8 +54,6 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
@@ -68,31 +65,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
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)
val DefaultSignerPermissions = val DefaultSignerPermissions =
listOf( listOf(
Permission(CommandType.SIGN_EVENT, RelayAuthEvent.KIND), Permission(CommandType.SIGN_EVENT, RelayAuthEvent.KIND),
@@ -20,8 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays package com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.NoteState
@@ -20,8 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.model.nip51Lists.searchRelays package com.vitorpamplona.amethyst.model.nip51Lists.searchRelays
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.NoteState
@@ -20,8 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.model.nip65RelayList package com.vitorpamplona.amethyst.model.nip65RelayList
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.NoteState
@@ -20,8 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.model.topNavFeeds package com.vitorpamplona.amethyst.model.topNavFeeds
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -20,10 +20,10 @@
*/ */
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
@@ -20,8 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
@@ -20,7 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
@@ -58,7 +58,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
import com.vitorpamplona.amethyst.service.broadcast.RelayResult import com.vitorpamplona.amethyst.service.broadcast.RelayResult
@@ -77,7 +77,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent import com.vitorpamplona.amethyst.service.broadcast.BroadcastEvent
import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus import com.vitorpamplona.amethyst.service.broadcast.BroadcastStatus
import com.vitorpamplona.amethyst.service.broadcast.RelayResult import com.vitorpamplona.amethyst.service.broadcast.RelayResult
@@ -23,30 +23,18 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.DefaultDMRelayList
import com.vitorpamplona.amethyst.model.DefaultGlobalRelays
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.model.DefaultNIP65List
import com.vitorpamplona.amethyst.model.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync 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.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
@@ -58,12 +46,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
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
import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -223,21 +206,20 @@ class AccountSessionManager(
loginSync(newKey, transientAccount, loginWithExternalSigner, packageName, onError) loginSync(newKey, transientAccount, loginWithExternalSigner, packageName, onError)
} }
} else if (EMAIL_PATTERN.matcher(key).matches()) { } else if (EMAIL_PATTERN.matcher(key).matches()) {
val nip05 = Nip05Id.parse(key) // Delegate to the shared quartz resolver so NIP-05 handling stays in
if (nip05 == null) { // lockstep with the CLI and anywhere else we accept user identifiers.
onError("Could not parse nip05 address: $nip05")
} else {
try { try {
val pubkeyInfo = nip05ClientBuilder().get(nip05) val hex =
if (pubkeyInfo == null) { com.vitorpamplona.quartz.nip05DnsIdentifiers
onError("User not found in the nip05 server: $nip05") .resolveUserHexOrNull(key, nip05ClientBuilder())
if (hex == null) {
onError("User not found in the nip05 server: $key")
} else { } else {
loginSync(Hex.decode(pubkeyInfo.pubkey).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError) loginSync(Hex.decode(hex).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError)
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
onError("Could not load nip05 address from the server: $nip05. ${e.message}") onError("Could not load nip05 address from the server: $key. ${e.message}")
}
} }
} else { } else {
loginSync(key, transientAccount, loginWithExternalSigner, packageName, onError) loginSync(key, transientAccount, loginWithExternalSigner, packageName, onError)
@@ -305,28 +287,23 @@ class AccountSessionManager(
fun createNewAccount(name: String? = null): AccountSettings { fun createNewAccount(name: String? = null): AccountSettings {
val keyPair = KeyPair() val keyPair = KeyPair()
val tempSigner = NostrSignerSync(keyPair) val bootstrap =
com.vitorpamplona.amethyst.commons.account.bootstrapAccountEvents(
signer = NostrSignerSync(keyPair),
name = name,
)
return AccountSettings( return AccountSettings(
keyPair = keyPair, keyPair = keyPair,
transientAccount = false, transientAccount = false,
backupUserMetadata = tempSigner.sign(MetadataEvent.newUser(name)), backupUserMetadata = bootstrap.userMetadata,
backupContactList = backupContactList = bootstrap.contactList,
ContactListEvent.createFromScratch( backupNIP65RelayList = bootstrap.nip65RelayList,
followUsers = listOf(ContactTag(keyPair.pubKey.toHexKey(), null, null)), backupDMRelayList = bootstrap.dmRelayList,
relayUse = emptyMap(), backupKeyPackageRelayList = bootstrap.keyPackageRelayList,
signer = tempSigner, backupSearchRelayList = bootstrap.searchRelayList,
), backupIndexRelayList = bootstrap.indexerRelayList,
backupNIP65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, tempSigner), backupChannelList = bootstrap.channelList,
backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner), backupRelayFeedsList = bootstrap.relayFeedsList,
// 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.
backupKeyPackageRelayList = KeyPackageRelayListEvent.create(DefaultNIP65RelaySet.toList(), tempSigner),
backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), tempSigner),
backupIndexRelayList = IndexerRelayListEvent.create(DefaultIndexerRelayList.toList(), tempSigner),
backupChannelList = ChannelListEvent.create(emptyList(), DefaultChannels, tempSigner),
backupRelayFeedsList = RelayFeedsListEvent.create(DefaultGlobalRelays, tempSigner),
) )
} }
@@ -1453,14 +1453,12 @@ class AccountViewModel(
nostrGroupId: String, nostrGroupId: String,
text: String, text: String,
) { ) {
val template = // Inner event construction lives on MarmotManager so CLI and UI don't drift.
com.vitorpamplona.quartz.nip01Core.signers.eventTemplate<com.vitorpamplona.quartz.nip01Core.core.Event>( // persistOwn=false because Account.sendMarmotGroupMessage routes the outer
kind = 9, // event through LocalCache which already handles own-message display.
description = text, val bundle = account.marmotManager?.buildTextMessage(nostrGroupId, text, persistOwn = false) ?: return
)
val innerEvent = account.signer.sign<com.vitorpamplona.quartz.nip01Core.core.Event>(template)
val relays = marmotGroupRelays(nostrGroupId) val relays = marmotGroupRelays(nostrGroupId)
account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays) account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays)
} }
suspend fun sendMarmotGroupMediaMessage( suspend fun sendMarmotGroupMediaMessage(
@@ -1558,7 +1556,6 @@ class AccountViewModel(
name: String, name: String,
description: String, description: String,
) { ) {
val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId)
// Stamp the inviter's outbox relays into the group metadata so that // 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 // every member ends up with a single canonical relay set for kind:445
// GroupEvents. Without this, both the inviter and the invitee fall // GroupEvents. Without this, both the inviter and the invitee fall
@@ -1569,29 +1566,19 @@ class AccountViewModel(
val outboxRelayStrings = val outboxRelayStrings =
account.outboxRelays.flow.value account.outboxRelays.flow.value
.map { it.url } .map { it.url }
val mergedRelays = val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId)
(currentMetadata?.relays.orEmpty() + outboxRelayStrings)
.distinct()
val updatedMetadata = val updatedMetadata =
if (currentMetadata != null) { currentMetadata
currentMetadata.copy( ?.copy(name = name, description = description)
name = name, ?.withMergedRelays(outboxRelayStrings)
description = description, ?: com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
relays = mergedRelays, .bootstrap(
)
} 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, nostrGroupId = nostrGroupId,
creatorPubKey = account.signer.pubKey,
outboxRelays = outboxRelayStrings,
name = name, name = name,
description = description, description = description,
adminPubkeys = listOf(account.signer.pubKey),
relays = mergedRelays,
) )
}
val relays = marmotGroupRelays(nostrGroupId) val relays = marmotGroupRelays(nostrGroupId)
account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays) account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
} }
@@ -48,10 +48,10 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
@@ -20,7 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource
import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@@ -20,7 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource
import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -20,7 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource
import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -49,9 +49,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.DefaultDMRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.components.M3ActionSection
@@ -38,7 +38,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.DefaultDMRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
@@ -38,7 +38,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
+36
View File
@@ -0,0 +1,36 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.jetbrainsKotlinJvm)
application
}
kotlin {
jvmToolchain(21)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
sourceSets {
main {
kotlin.srcDir("src/main/kotlin")
resources.srcDir("src/main/resources")
}
}
dependencies {
implementation(project(":quartz"))
implementation(project(":commons"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.okhttp)
implementation(libs.okhttpCoroutines)
implementation(libs.jackson.module.kotlin)
implementation(libs.slf4j.nop)
}
application {
mainClass.set("com.vitorpamplona.amethyst.cli.MainKt")
applicationName = "amy"
}
@@ -0,0 +1,103 @@
/*
* 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
/**
* Minimal argv parser. Splits flags (--key value or --key=value) from positional args.
* Boolean flags are those whose next token starts with "--" or is absent.
*
* The parser is intentionally tiny this CLI is driven by shell scripts, not humans,
* so we don't need subcommand groups, short flags, or help text generation.
*/
class Args(
argv: Array<String>,
) {
val flags: Map<String, String>
val booleans: Set<String>
val positional: List<String>
init {
val f = mutableMapOf<String, String>()
val b = mutableSetOf<String>()
val p = mutableListOf<String>()
var i = 0
while (i < argv.size) {
val a = argv[i]
if (a.startsWith("--")) {
val eq = a.indexOf('=')
if (eq >= 0) {
f[a.substring(2, eq)] = a.substring(eq + 1)
i++
} else {
val key = a.substring(2)
val next = argv.getOrNull(i + 1)
if (next == null || next.startsWith("--")) {
b.add(key)
i++
} else {
f[key] = next
i += 2
}
}
} else {
p.add(a)
i++
}
}
flags = f
booleans = b
positional = p
}
fun flag(
name: String,
default: String? = null,
): String? = flags[name] ?: default
fun intFlag(
name: String,
default: Int,
): Int = flags[name]?.toIntOrNull() ?: default
fun longFlag(
name: String,
default: Long,
): Long = flags[name]?.toLongOrNull() ?: default
fun requireFlag(name: String): String =
flags[name] ?: run {
System.err.println("missing required flag: --$name")
throw IllegalArgumentException("missing flag $name")
}
fun bool(name: String): Boolean = name in booleans
fun positional(
index: Int,
name: String,
): String =
positional.getOrNull(index) ?: run {
System.err.println("missing positional arg: $name (index $index)")
throw IllegalArgumentException("missing positional $name")
}
fun positionalOrNull(index: Int): String? = positional.getOrNull(index)
}
@@ -0,0 +1,176 @@
/*
* 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
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip19Bech32.toNsec
import java.io.File
/**
* Persisted identity.
*
* [privKeyHex] may be null for read-only accounts imported from an `npub`,
* `nprofile` or NIP-05 in that case [nsec] is also null and any CLI verb
* that needs to sign will fail with a clear error. `keyPair()` materialises
* a [KeyPair] with only `pubKey` set when no private key is available.
*/
data class Identity(
val privKeyHex: String?,
val pubKeyHex: String,
val nsec: String?,
val npub: String,
) {
@get:com.fasterxml.jackson.annotation.JsonIgnore
val hasPrivateKey: Boolean get() = privKeyHex != null
fun keyPair(): KeyPair =
if (privKeyHex != null) {
KeyPair(privKey = privKeyHex.hexToByteArray(), pubKey = pubKeyHex.hexToByteArray())
} else {
KeyPair(pubKey = pubKeyHex.hexToByteArray())
}
companion object {
fun create(): Identity = fromPrivateKey(KeyPair().privKey!!)
fun fromNsec(nsec: String): Identity = fromPrivateKey(nsec.bechToBytes())
fun fromPrivateKey(priv: ByteArray): Identity {
val pub = KeyPair(privKey = priv).pubKey
return Identity(
privKeyHex = priv.toHexKey(),
pubKeyHex = pub.toHexKey(),
nsec = priv.toNsec(),
npub = pub.toNpub(),
)
}
/** Read-only identity (no private key). */
fun fromPublicKeyHex(pubHex: String): Identity =
Identity(
privKeyHex = null,
pubKeyHex = pubHex.lowercase(),
nsec = null,
npub = pubHex.hexToByteArray().toNpub(),
)
}
}
/**
* On-disk relay configuration, bucketed by purpose (mirrors `wn relays add --type`).
*
* - `nip65`: advertised read/write (kind:10002)
* - `inbox`: DM inbox / gift-wrap delivery (kind:10050)
* - `keyPackage`: where this account's KeyPackages (kind:30443) live
*/
data class RelayConfig(
val nip65: MutableList<String> = mutableListOf(),
val inbox: MutableList<String> = mutableListOf(),
val keyPackage: MutableList<String> = mutableListOf(),
) {
fun all(): Set<String> = (nip65 + inbox + keyPackage).toSet()
fun add(
type: String,
url: String,
): Boolean {
val list =
when (type) {
"nip65" -> nip65
"inbox" -> inbox
"key_package", "keyPackage" -> keyPackage
else -> throw IllegalArgumentException("unknown relay type: $type")
}
if (list.contains(url)) return false
list.add(url)
return true
}
fun normalized(kind: String): Set<NormalizedRelayUrl> {
val src =
when (kind) {
"nip65" -> nip65
"inbox" -> inbox
"key_package" -> keyPackage
"all" -> all().toList()
else -> throw IllegalArgumentException("unknown relay selector: $kind")
}
return src.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
}
}
/** Opaque per-run state (subscription cursors, etc). Stored alongside identity. */
data class RunState(
var giftWrapSince: Long? = null,
val groupSince: MutableMap<String, Long> = mutableMapOf(),
)
/**
* Root of the on-disk layout. Any absolute path chosen by `--data-dir` (or
* `$AMETHYST_CLI_DATA`) defaults to `./amethyst-cli-data`.
*/
class DataDir(
val root: File,
) {
val identityFile = File(root, "identity.json")
val relaysFile = File(root, "relays.json")
val stateFile = File(root, "state.json")
val groupsDir = File(root, "groups")
val keyPackageBundleFile = File(root, "keypackages.bundle")
init {
root.mkdirs()
groupsDir.mkdirs()
}
fun loadIdentityOrNull(): Identity? = if (identityFile.exists()) Json.mapper.readValue<Identity>(identityFile.readText()) else null
fun saveIdentity(id: Identity) {
identityFile.writeText(Json.mapper.writeValueAsString(id))
}
fun loadRelays(): RelayConfig = if (relaysFile.exists()) Json.mapper.readValue(relaysFile.readText()) else RelayConfig()
fun saveRelays(r: RelayConfig) {
relaysFile.writeText(Json.mapper.writeValueAsString(r))
}
fun loadRunState(): RunState = if (stateFile.exists()) Json.mapper.readValue(stateFile.readText()) else RunState()
fun saveRunState(s: RunState) {
stateFile.writeText(Json.mapper.writeValueAsString(s))
}
companion object {
fun resolve(flag: String?): DataDir {
val envPath = System.getenv("AMETHYST_CLI_DATA")
val path = flag ?: envPath ?: "./amethyst-cli-data"
return DataDir(File(path).absoluteFile)
}
}
}
@@ -0,0 +1,322 @@
/*
* 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
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.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
/**
* Per-invocation wiring. Each CLI run constructs a Context, does its work,
* and then closes it no daemon.
*
* Responsibilities:
* - load identity + relay + run-state from the data-dir,
* - wire up a [NostrClient] pointing at those relays,
* - wire up the [MarmotManager] pipeline with file-backed stores,
* - expose helpers that every command needs (sync, publish-and-confirm,
* process-incoming, etc).
*
* Closing flushes run-state to disk and disconnects the client.
*/
class Context(
val dataDir: DataDir,
val identity: Identity,
val relays: RelayConfig,
val state: RunState,
) : AutoCloseable {
val signer = NostrSignerInternal(identity.keyPair())
private val okhttp = OkHttpClient.Builder().build()
val client: NostrClient =
NostrClient(
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)
/** Fully-wired manager. Call [prepare] once before use to load persisted state. */
val marmot: MarmotManager = MarmotManager(signer, mlsStore, messageStore, keyPackageStore)
private var prepared = false
/**
* Hydrate MarmotManager from disk (groups + KeyPackage bundles) and
* connect to relays. Safe to call multiple times subsequent calls are
* no-ops.
*/
suspend fun prepare() {
if (prepared) return
marmot.restoreAll()
client.connect()
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")
fun keyPackageRelays(): Set<NormalizedRelayUrl> = relays.normalized("key_package")
fun anyRelays(): Set<NormalizedRelayUrl> = relays.normalized("all")
/**
* Publish an event to the given relays and wait for OK confirmations.
*
* Returns the set of relays that ACK'd `true`. Does not throw on rejection
* callers inspect the map and decide.
*/
suspend fun publish(
event: Event,
relayList: Set<NormalizedRelayUrl>,
timeoutSecs: Long = 15,
): Map<NormalizedRelayUrl, Boolean> {
if (relayList.isEmpty()) return emptyMap()
return client.publishAndConfirmDetailed(event, relayList, timeoutSecs)
}
/**
* Subscribe to the given filters across the given relays, drain all events
* until either every relay has sent EOSE or the timeout elapses, and
* return them. Used for one-shot catch-up queries not live subscriptions.
*/
suspend fun drain(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 8_000,
): List<Pair<NormalizedRelayUrl, Event>> {
if (filters.isEmpty()) return emptyList()
val eventChannel = Channel<Pair<NormalizedRelayUrl, Event>>(UNLIMITED)
val doneChannel = Channel<NormalizedRelayUrl>(UNLIMITED)
val remaining = filters.keys.toMutableSet()
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eventChannel.trySend(relay to event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
doneChannel.trySend(relay)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
doneChannel.trySend(relay)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
doneChannel.trySend(relay)
}
}
val collected = mutableListOf<Pair<NormalizedRelayUrl, Event>>()
try {
client.subscribe(subId, filters, listener)
withTimeoutOrNull(timeoutMs) {
while (remaining.isNotEmpty()) {
select {
eventChannel.onReceive { collected.add(it) }
doneChannel.onReceive { r -> remaining.remove(r) }
}
}
// Drain any events that landed after EOSE but before cancel
while (true) {
val r = eventChannel.tryReceive()
if (!r.isSuccess) break
collected.add(r.getOrThrow())
}
}
} finally {
client.unsubscribe(subId)
eventChannel.close()
doneChannel.close()
}
return collected
}
/**
* Pull down everything needed to bring local Marmot state current:
* - kind:1059 gift wraps on inbox relays try to unwrap Welcomes
* - kind:445 group events per active group feed into inbound processor
*
* Incrementally advances the `since` cursors in [state] so the next run
* only asks relays for newer events.
*/
suspend fun syncIncoming(timeoutMs: Long = 8_000) {
val inbox = inboxRelays().ifEmpty { anyRelays() }
val gwSince = state.giftWrapSince
val gwFilter =
if (gwSince != null) {
MarmotFilters.giftWrapsForUserSince(identity.pubKeyHex, gwSince)
} else {
MarmotFilters.giftWrapsForUser(identity.pubKeyHex)
}
val activeGroupIds = marmot.subscriptionManager.activeGroupIdsSnapshot().toList()
val perGroupFilters: Map<HexKey, Filter> =
activeGroupIds.associateWith { gid ->
val since = state.groupSince[gid]
if (since != null) {
MarmotFilters.groupEventsByGroupIdSince(gid, since)
} else {
MarmotFilters.groupEventsByGroupId(gid)
}
}
// Group filters go to each group's configured relays, not the user's
// inbox — kind:445 is delivered to the group's relay set advertised in
// its MIP-01 metadata (falls back to our outbox if the group never
// stamped any).
val filterMap = mutableMapOf<NormalizedRelayUrl, MutableList<Filter>>()
for (r in inbox) filterMap.getOrPut(r) { mutableListOf() }.add(gwFilter)
for ((gid, filter) in perGroupFilters) {
val groupRelays = marmotGroupRelays(gid).ifEmpty { outboxRelays() }
for (r in groupRelays) filterMap.getOrPut(r) { mutableListOf() }.add(filter)
}
if (filterMap.isEmpty()) return
val events = drain(filterMap, timeoutMs)
val now = System.currentTimeMillis() / 1000
var maxGwSeen = gwSince ?: 0L
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 -> {
if (event.createdAt > maxGwSeen) maxGwSeen = event.createdAt
}
GroupEvent.KIND -> {
val gid = (event as? GroupEvent)?.groupId() ?: continue
val prev = maxGroupSeen[gid] ?: 0L
if (event.createdAt > prev) maxGroupSeen[gid] = event.createdAt
}
}
}
state.giftWrapSince = if (maxGwSeen > 0) maxGwSeen else now
for ((gid, seen) in maxGroupSeen) {
state.groupSince[gid] = if (seen > 0) seen else now
}
}
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
val m = marmot.groupMetadata(nostrGroupId) ?: return emptySet()
return m.relays
.mapNotNull {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
.normalizeOrNull(it)
}.toSet()
}
override fun close() {
dataDir.saveRunState(state)
try {
client.close()
} catch (_: Exception) {
}
}
companion object {
/** Build a Context but require an identity to already exist — most commands can't run without one. */
fun open(dataDir: DataDir): Context {
val identity =
dataDir.loadIdentityOrNull()
?: run {
System.err.println("No identity found at ${dataDir.identityFile}. Run `amethyst-cli init` first.")
throw IllegalStateException("no identity")
}
return Context(
dataDir = dataDir,
identity = identity,
relays = dataDir.loadRelays(),
state = dataDir.loadRunState(),
)
}
}
}
@@ -0,0 +1,42 @@
/*
* 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
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
object Json {
val mapper: ObjectMapper = jacksonObjectMapper()
fun writeLine(obj: Any) {
println(mapper.writeValueAsString(obj))
}
fun error(
code: String,
detail: String? = null,
): Int {
val payload = mutableMapOf<String, Any>("error" to code)
if (detail != null) payload["detail"] = detail
System.err.println(mapper.writeValueAsString(payload))
return 1
}
}
@@ -0,0 +1,220 @@
/*
* 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
import com.vitorpamplona.amethyst.cli.commands.Commands
import kotlinx.coroutines.runBlocking
import kotlin.system.exitProcess
/**
* amy non-interactive command-line interface to Amethyst.
*
* Today this covers the Marmot/MLS surface (`amy marmot `) plus identity
* and relay configuration at the root level. The layout is intentionally
* extensible future verbs (`amy dm`, `amy feed`, `amy profile`) slot in
* as new top-level subcommands.
*
* Usage: amy --data-dir PATH SUBCOMMAND ARGS
*
* Exit codes:
* 0 success
* 1 runtime error (printed as JSON on stderr: {"error": "...", "detail": "..."})
* 2 invalid arguments
* 124 await timeout
*
* Every command that succeeds prints exactly one JSON object to stdout.
* Diagnostic logs go to stderr and are safe to discard.
*/
fun main(argv: Array<String>) {
val code =
try {
runBlocking { dispatch(argv) }
} catch (e: IllegalArgumentException) {
Json.error("bad_args", e.message)
2
} catch (e: AwaitTimeout) {
Json.error("timeout", e.message)
124
} catch (e: Exception) {
Json.error("runtime", "${e::class.simpleName}: ${e.message}")
1
}
exitProcess(code)
}
class AwaitTimeout(
message: String,
) : RuntimeException(message)
private suspend fun dispatch(argv: Array<String>): Int {
if (argv.isEmpty() || argv[0] == "--help" || argv[0] == "-h") {
printUsage()
return 0
}
// Pull --data-dir out of argv before subcommand parsing so subcommands see
// only their own args.
val filteredArgs = mutableListOf<String>()
var dataDirFlag: String? = null
var i = 0
while (i < argv.size) {
when (val a = argv[i]) {
"--data-dir" -> {
dataDirFlag = argv.getOrNull(i + 1)
i += 2
}
else -> {
if (a.startsWith("--data-dir=")) {
dataDirFlag = a.removePrefix("--data-dir=")
i++
} else {
filteredArgs.add(a)
i++
}
}
}
}
if (filteredArgs.isEmpty()) {
printUsage()
return 2
}
val dataDir = DataDir.resolve(dataDirFlag)
val head = filteredArgs[0]
val tail = filteredArgs.drop(1).toTypedArray()
return when (head) {
"init" -> {
Commands.init(dataDir, Args(tail))
}
"create" -> {
Commands.create(dataDir, tail)
}
"login" -> {
Commands.login(dataDir, tail)
}
"whoami" -> {
Commands.whoami(dataDir)
}
"relay" -> {
Commands.relay(dataDir, tail)
}
"marmot" -> {
marmotDispatch(dataDir, tail)
}
else -> {
System.err.println("unknown subcommand: $head")
printUsage()
2
}
}
}
private suspend fun marmotDispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) {
printUsage()
return 2
}
val head = tail[0]
val rest = tail.drop(1).toTypedArray()
return when (head) {
"key-package" -> {
Commands.keyPackage(dataDir, rest)
}
"group" -> {
Commands.group(dataDir, rest)
}
"message" -> {
Commands.message(dataDir, rest)
}
"await" -> {
Commands.await(dataDir, rest)
}
else -> {
System.err.println("unknown marmot subcommand: $head")
printUsage()
2
}
}
}
private fun printUsage() {
System.err.println(
"""
|amy Amethyst command-line interface
|
|Usage:
| amy [--data-dir PATH] <cmd> [args...]
|
|Identity:
| init [--nsec NSEC] create or import a bare identity (no defaults published)
| create [--name NAME] provision a full Amethyst-style account + publish bootstrap events
| login KEY [--password X] import (nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05)
| whoami print current identity
|
|Relays:
| relay add URL [--type T] T=nip65|inbox|key_package|all (default all)
| relay list print configured relays
| relay publish-lists publish kind:10002 + kind:10050
|
|Marmot (MLS group messaging):
| marmot key-package publish publish a fresh KeyPackage
| marmot key-package check NPUB fetch NPUB's KeyPackage from relays
|
| marmot group create [--name NAME] create an empty group (self-only)
| marmot group list list joined groups
| marmot group show GID print full group details
| marmot group members GID print members
| marmot group admins GID print admins
| marmot group add GID NPUB [NPUB...] fetch KPs and invite
| marmot group rename GID NAME commit a rename
| marmot group promote GID NPUB add admin
| marmot group demote GID NPUB remove admin
| marmot group remove GID NPUB remove member
| marmot group leave GID self-remove
|
| marmot message send GID TEXT publish kind:9 inner event into the group
| marmot message list GID [--limit N] dump decrypted inner events
|
| marmot await key-package NPUB (all await verbs take --timeout SECS, default 30;
| marmot await group --name NAME exit 124 on timeout)
| marmot await member GID NPUB
| marmot await admin GID NPUB
| marmot await message GID --match TEXT
| marmot await rename GID --name NAME
| marmot await epoch GID --min N
""".trimMargin(),
)
}
@@ -0,0 +1,294 @@
/*
* 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.commands
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.cli.Args
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.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import kotlinx.coroutines.delay
/**
* `await` subcommands. Each polls until a condition is met or the timeout elapses;
* on timeout we throw [AwaitTimeout] which maps to exit code 124.
*/
object AwaitCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Json.error("bad_args", "await <key-package|group|member|admin|message|rename|epoch>")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"key-package" -> awaitKeyPackage(dataDir, rest)
"group" -> awaitGroup(dataDir, rest)
"member" -> awaitMember(dataDir, rest)
"admin" -> awaitAdmin(dataDir, rest)
"message" -> awaitMessage(dataDir, rest)
"rename" -> awaitRename(dataDir, rest)
"epoch" -> awaitEpoch(dataDir, rest)
else -> Json.error("bad_args", "await ${tail[0]}")
}
}
private suspend fun awaitKeyPackage(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "await key-package <npub>")
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) {
val relays = ctx.anyRelays()
if (relays.isNotEmpty()) {
val event =
ctx.client.fetchFirst(
filters = relays.associateWith { listOf(filter) },
timeoutMs = 3_000,
)
if (event is KeyPackageEvent) {
Json.writeLine(mapOf("event_id" to event.id, "author" to event.pubKey))
return 0
}
}
delay(2_000)
}
throw AwaitTimeout("no KeyPackage for $target within ${timeoutSecs}s")
} finally {
ctx.close()
}
}
private suspend fun awaitGroup(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val wantedName = args.flag("name")
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
ctx.syncIncoming(timeoutMs = 3_000)
val match =
ctx.marmot.activeGroupIds().firstOrNull { gid ->
wantedName == null || ctx.marmot.groupMetadata(gid)?.name == wantedName
}
if (match != null) {
Json.writeLine(
mapOf(
"group_id" to match,
"name" to (ctx.marmot.groupMetadata(match)?.name ?: ""),
"epoch" to ctx.marmot.groupEpoch(match),
),
)
return 0
}
delay(1_500)
}
throw AwaitTimeout("no group with name=$wantedName within ${timeoutSecs}s")
} finally {
ctx.close()
}
}
private suspend fun awaitMember(
dataDir: DataDir,
rest: Array<String>,
): Int =
pollCondition(dataDir, rest, "await member <gid> <npub>", targetIdx = 1) { ctx, rawArgs ->
val gid = rawArgs[0]
val target = ctx.requireUserHex(rawArgs[1])
if (!ctx.marmot.isMember(gid)) {
null
} else if (ctx.marmot.memberPubkeys(gid).any { it.pubkey == target }) {
mapOf("group_id" to gid, "pubkey" to target, "epoch" to ctx.marmot.groupEpoch(gid))
} else {
null
}
}
private suspend fun awaitAdmin(
dataDir: DataDir,
rest: Array<String>,
): Int =
pollCondition(dataDir, rest, "await admin <gid> <npub>", targetIdx = 1) { ctx, rawArgs ->
val gid = rawArgs[0]
val target = ctx.requireUserHex(rawArgs[1])
if (!ctx.marmot.isMember(gid)) {
null
} else if (ctx.marmot
.groupMetadata(gid)
?.adminPubkeys
?.contains(target) == true
) {
mapOf("group_id" to gid, "pubkey" to target, "epoch" to ctx.marmot.groupEpoch(gid))
} else {
null
}
}
private suspend fun awaitRename(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "await rename <gid> --name <name>")
val gid = rest[0]
val args = Args(rest.drop(1).toTypedArray())
val wantedName = args.requireFlag("name")
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
ctx.syncIncoming(timeoutMs = 3_000)
val name = ctx.marmot.groupMetadata(gid)?.name
if (name == wantedName) {
Json.writeLine(mapOf("group_id" to gid, "name" to name, "epoch" to ctx.marmot.groupEpoch(gid)))
return 0
}
delay(1_500)
}
throw AwaitTimeout("group $gid never renamed to $wantedName within ${timeoutSecs}s")
} finally {
ctx.close()
}
}
private suspend fun awaitEpoch(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "await epoch <gid> --min N")
val gid = rest[0]
val args = Args(rest.drop(1).toTypedArray())
val min = args.longFlag("min", 1)
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
ctx.syncIncoming(timeoutMs = 3_000)
val epoch = ctx.marmot.groupEpoch(gid)
if (epoch != null && epoch >= min) {
Json.writeLine(mapOf("group_id" to gid, "epoch" to epoch))
return 0
}
delay(1_500)
}
throw AwaitTimeout("group $gid epoch never reached $min within ${timeoutSecs}s")
} finally {
ctx.close()
}
}
private suspend fun awaitMessage(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "await message <gid> --match STRING")
val gid = rest[0]
val args = Args(rest.drop(1).toTypedArray())
val needle = args.requireFlag("match")
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
ctx.syncIncoming(timeoutMs = 3_000)
val msgs = ctx.marmot.loadStoredMessages(gid)
for (line in msgs.asReversed()) {
val obj =
try {
Json.mapper.readValue<Map<String, Any?>>(line)
} catch (_: Exception) {
null
} ?: continue
val content = obj["content"]?.toString() ?: continue
if (content.contains(needle)) {
Json.writeLine(
mapOf(
"group_id" to gid,
"id" to obj["id"],
"author" to obj["pubkey"],
"content" to content,
"kind" to obj["kind"],
),
)
return 0
}
}
delay(1_500)
}
throw AwaitTimeout("no message matching '$needle' in $gid within ${timeoutSecs}s")
} finally {
ctx.close()
}
}
/**
* Generic poll loop used by [awaitMember] / [awaitAdmin] both take the
* same `<gid> <npub>` positional shape and differ only in the predicate.
*/
private suspend fun pollCondition(
dataDir: DataDir,
rest: Array<String>,
usage: String,
targetIdx: Int,
check: suspend (Context, Array<String>) -> Map<String, Any?>?,
): Int {
if (rest.size <= targetIdx) return Json.error("bad_args", usage)
val args = Args(rest.drop(targetIdx + 1).toTypedArray())
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
ctx.syncIncoming(timeoutMs = 3_000)
val hit = check(ctx, rest)
if (hit != null) {
Json.writeLine(hit)
return 0
}
delay(1_500)
}
throw AwaitTimeout("condition never satisfied within ${timeoutSecs}s ($usage)")
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,73 @@
/*
* 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.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.DataDir
/**
* Tiny dispatcher over the per-verb command groups. Each top-level subcommand
* (`init`, `relay`, `group`, `message`, ) gets its own file so no single
* file is too big to edit safely.
*/
object Commands {
suspend fun init(
dataDir: DataDir,
args: Args,
): Int = InitCommands.init(dataDir, args)
suspend fun create(
dataDir: DataDir,
tail: Array<String>,
): Int = CreateCommand.run(dataDir, tail)
suspend fun login(
dataDir: DataDir,
tail: Array<String>,
): Int = LoginCommand.run(dataDir, tail)
suspend fun whoami(dataDir: DataDir): Int = InitCommands.whoami(dataDir)
suspend fun relay(
dataDir: DataDir,
tail: Array<String>,
): Int = RelayCommands.dispatch(dataDir, tail)
suspend fun keyPackage(
dataDir: DataDir,
tail: Array<String>,
): Int = KeyPackageCommands.dispatch(dataDir, tail)
suspend fun group(
dataDir: DataDir,
tail: Array<String>,
): Int = GroupCommands.dispatch(dataDir, tail)
suspend fun message(
dataDir: DataDir,
tail: Array<String>,
): Int = MessageCommands.dispatch(dataDir, tail)
suspend fun await(
dataDir: DataDir,
tail: Array<String>,
): Int = AwaitCommands.dispatch(dataDir, tail)
}
@@ -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.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Identity
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.amethyst.cli.RelayConfig
import com.vitorpamplona.amethyst.commons.account.bootstrapAccountEvents
import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65List
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
/**
* `amy create [--name NAME]` provision a brand-new Nostr account with the
* same defaults Amethyst uses, publish the nine bootstrap events to the
* default NIP-65 relay set, and seed this data-dir's relay config so
* subsequent `amy marmot ` commands immediately target the right relays.
*
* The heavy lifting (which events to sign, with which defaults) lives in
* `commons/.../AccountBootstrap.kt` so this command stays assembly-thin
* and the on-relay shape matches the in-app flow byte-for-byte.
*/
object CreateCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (dataDir.loadIdentityOrNull() != null) {
return Json.error("exists", "identity already exists at ${dataDir.identityFile}")
}
val args = Args(rest)
val name = args.flag("name")
// 1. Mint identity + seed relay config.
val identity = Identity.create()
dataDir.saveIdentity(identity)
dataDir.saveRelays(defaultRelayConfig())
// 2. Build the nine signed bootstrap events via the shared helper.
val signer = NostrSignerSync(identity.keyPair())
val bootstrap = bootstrapAccountEvents(signer, name)
// 3. Open a Context so we reuse publishAndConfirmDetailed + the
// NostrClient plumbing instead of reinventing a second client.
val ctx = Context.open(dataDir)
val accepted = mutableMapOf<String, List<String>>()
try {
ctx.prepare()
for (event in bootstrap.all()) {
val ack = ctx.publish(event, DefaultNIP65RelaySet)
accepted[event.kind.toString()] =
ack.filterValues { it }.keys.map { it.url }
}
} finally {
ctx.close()
}
Json.writeLine(
mapOf(
"npub" to identity.npub,
"hex" to identity.pubKeyHex,
"name" to (name ?: ""),
"data_dir" to dataDir.root.absolutePath,
"published_kinds" to bootstrap.all().map { it.kind },
"accepted_by" to accepted,
"relays" to
mapOf(
"nip65" to DefaultNIP65List.map { it.relayUrl.url },
"inbox" to DefaultDMRelayList.map { it.url },
"key_package" to DefaultNIP65RelaySet.map { it.url },
),
),
)
return 0
}
/**
* Mirror of what Amethyst's in-app defaults would write on disk NIP-65
* outbox, DM inbox (kind:10050), and KeyPackage host relays (kind:10051).
* Keeping these in sync with the signed events above is load-bearing:
* `amy marmot key-package publish` later reads `relays.json` to decide
* where to publish, and if those diverge from the advertised kind:10051
* nobody will find the KPs.
*/
private fun defaultRelayConfig(): RelayConfig {
val cfg = RelayConfig()
DefaultNIP65List.forEach { cfg.add("nip65", it.relayUrl.url) }
DefaultDMRelayList.forEach { cfg.add("inbox", it.url) }
DefaultNIP65RelaySet.forEach { cfg.add("key_package", it.url) }
return cfg
}
}
@@ -0,0 +1,110 @@
/*
* 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.commands
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
/**
* `group add <group_id> <npub> [<npub> ...]` fetch each invitee's
* KeyPackage from the union of (our relays + any known KeyPackage relays
* for them) and run the full add-member flow for each one: build commit,
* publish commit to the group's relays, then wrap + publish the Welcome
* gift wrap.
*/
object GroupAddMemberCommand {
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 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 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 (commitEvent, welcomeDelivery) =
ctx.marmot.addMember(
nostrGroupId = gid,
keyPackageEvent = kpEvent,
relays = groupRelays.toList(),
)
// Order matters: commit first (so invitee doesn't join at a future epoch),
// then welcome.
val commitAck = ctx.publish(commitEvent.signedEvent, groupRelays)
val welcomeAck =
if (welcomeDelivery != null) {
val inbox = ctx.inboxRelays().ifEmpty { ctx.outboxRelays() }
ctx.publish(welcomeDelivery.giftWrapEvent, inbox)
} else {
emptyMap()
}
report.add(
mapOf(
"pubkey" to pub,
"status" to "invited",
"key_package_event_id" to kpEvent.id,
"commit_event_id" to commitEvent.signedEvent.id,
"welcome_event_id" to welcomeDelivery?.giftWrapEvent?.id,
"commit_accepted_by" to commitAck.filterValues { it }.keys.map { it.url },
"welcome_accepted_by" to welcomeAck.filterValues { it }.keys.map { it.url },
),
)
}
Json.writeLine(
mapOf(
"group_id" to gid,
"epoch" to ctx.marmot.groupEpoch(gid),
"results" to report,
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,48 @@
/*
* 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.commands
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
object GroupCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Json.error("bad_args", "group <create|list|show|…>")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"create" -> GroupCreateCommand.run(dataDir, rest)
"list" -> GroupReadCommands.list(dataDir)
"show" -> GroupReadCommands.show(dataDir, rest)
"members" -> GroupReadCommands.members(dataDir, rest)
"admins" -> GroupReadCommands.admins(dataDir, rest)
"add" -> GroupAddMemberCommand.run(dataDir, rest)
"rename" -> GroupMetadataCommands.rename(dataDir, rest)
"promote" -> GroupMetadataCommands.promote(dataDir, rest)
"demote" -> GroupMetadataCommands.demote(dataDir, rest)
"remove" -> GroupMembershipCommands.remove(dataDir, rest)
"leave" -> GroupMembershipCommands.leave(dataDir, rest)
else -> Json.error("bad_args", "group ${tail[0]}")
}
}
}
@@ -0,0 +1,74 @@
/*
* 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.commands
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.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.RandomInstance
object GroupCreateCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val name = args.flag("name", "")!!
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val gid = RandomInstance.bytes(32).toHexKey()
ctx.marmot.createGroup(gid)
// Stamp initial metadata via the shared factory so UI + CLI stay byte-identical.
val outboxUrls = ctx.outboxRelays().map { it.url }
val metadata =
MarmotGroupData.bootstrap(
nostrGroupId = gid,
creatorPubKey = ctx.identity.pubKeyHex,
outboxRelays = outboxUrls,
name = name,
)
val commit = ctx.marmot.updateGroupMetadata(gid, metadata)
// Group relays == what the metadata carries, which on first commit is our outbox.
val targets = ctx.outboxRelays()
val ack = ctx.publish(commit.signedEvent, targets)
Json.writeLine(
mapOf(
"group_id" to gid,
"name" to name,
"epoch" to ctx.marmot.groupEpoch(gid),
"commit_event_id" to commit.signedEvent.id,
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,90 @@
/*
* 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.commands
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
object GroupMembershipCommands {
suspend fun remove(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "group remove <gid> <npub>")
val gid = rest[0]
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 leafIndex =
ctx.marmot.leafIndexOf(gid, target)
?: return Json.error("not_in_group", target)
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 leafIndex,
"epoch" to ctx.marmot.groupEpoch(gid),
"commit_event_id" to outbound.signedEvent.id,
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
suspend fun leave(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "group leave <gid>")
val gid = rest[0]
val ctx = Context.open(dataDir)
try {
ctx.prepare()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val outbound = ctx.marmot.leaveGroup(gid)
val ack = ctx.publish(outbound.signedEvent, targets)
Json.writeLine(
mapOf(
"group_id" to gid,
"proposal_event_id" to outbound.signedEvent.id,
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,106 @@
/*
* 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.commands
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* Metadata-only commits: rename, promote/demote. Each loads current metadata,
* edits the right field, publishes a GCE commit to the group relays.
*/
object GroupMetadataCommands {
suspend fun rename(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "group rename <gid> <name>")
return edit(dataDir, rest[0]) { _, cur -> cur.copy(name = rest[1]) }
}
suspend fun promote(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "group promote <gid> <npub>")
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)
}
}
suspend fun demote(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "group demote <gid> <npub>")
return edit(dataDir, rest[0]) { ctx, cur ->
val target = ctx.requireUserHex(rest[1])
val admins = cur.adminPubkeys.filter { it != target }
cur.copy(adminPubkeys = admins)
}
}
private suspend fun edit(
dataDir: DataDir,
gid: HexKey,
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.bootstrap(
nostrGroupId = gid,
creatorPubKey = ctx.identity.pubKeyHex,
outboxRelays = outboxUrls,
)
val updated = mutate(ctx, cur).withMergedRelays(outboxUrls)
val commit = ctx.marmot.updateGroupMetadata(gid, updated)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(commit.signedEvent, targets)
Json.writeLine(
mapOf(
"group_id" to gid,
"name" to updated.name,
"admins" to updated.adminPubkeys,
"epoch" to ctx.marmot.groupEpoch(gid),
"commit_event_id" to commit.signedEvent.id,
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,128 @@
/*
* 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.commands
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
/**
* Read-only queries. None of these publish; they all sync-then-report.
*/
object GroupReadCommands {
suspend fun list(dataDir: DataDir): Int {
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
val ids = ctx.marmot.activeGroupIds()
val items =
ids.map { id ->
val m = ctx.marmot.groupMetadata(id)
mapOf(
"group_id" to id,
"name" to (m?.name ?: ""),
"members" to ctx.marmot.memberCount(id),
"epoch" to ctx.marmot.groupEpoch(id),
)
}
Json.writeLine(mapOf("groups" to items))
return 0
} finally {
ctx.close()
}
}
suspend fun show(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "group show <group_id>")
val gid = rest[0]
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val meta = ctx.marmot.groupMetadata(gid)
val members =
ctx.marmot.memberPubkeys(gid).map {
mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex)
}
Json.writeLine(
mapOf(
"group_id" to gid,
"name" to (meta?.name ?: ""),
"description" to (meta?.description ?: ""),
"epoch" to ctx.marmot.groupEpoch(gid),
"admins" to (meta?.adminPubkeys ?: emptyList()),
"relays" to (meta?.relays ?: emptyList()),
"members" to members,
"is_admin" to (meta?.adminPubkeys?.contains(ctx.identity.pubKeyHex) == true),
),
)
return 0
} finally {
ctx.close()
}
}
suspend fun members(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "group members <group_id>")
val gid = rest[0]
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val members =
ctx.marmot.memberPubkeys(gid).map {
mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex)
}
Json.writeLine(mapOf("group_id" to gid, "members" to members))
return 0
} finally {
ctx.close()
}
}
suspend fun admins(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "group admins <group_id>")
val gid = rest[0]
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val m = ctx.marmot.groupMetadata(gid)
Json.writeLine(mapOf("group_id" to gid, "admins" to (m?.adminPubkeys ?: emptyList())))
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,67 @@
/*
* 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.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Identity
import com.vitorpamplona.amethyst.cli.Json
object InitCommands {
suspend fun init(
dataDir: DataDir,
args: Args,
): Int {
val existing = dataDir.loadIdentityOrNull()
val id =
existing ?: run {
val nsec = args.flag("nsec")
val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create()
dataDir.saveIdentity(created)
created
}
Json.writeLine(
mapOf(
"npub" to id.npub,
"hex" to id.pubKeyHex,
"nsec" to id.nsec,
"existing" to (existing != null),
"data_dir" to dataDir.root.absolutePath,
),
)
return 0
}
suspend fun whoami(dataDir: DataDir): Int {
val id = dataDir.loadIdentityOrNull()
if (id == null) {
return Json.error("no_identity", "No identity at ${dataDir.identityFile}. Run `init` first.")
}
Json.writeLine(
mapOf(
"npub" to id.npub,
"hex" to id.pubKeyHex,
"data_dir" to dataDir.root.absolutePath,
),
)
return 0
}
}
@@ -0,0 +1,98 @@
/*
* 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.commands
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
object KeyPackageCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Json.error("bad_args", "key-package <publish|check> …")
return when (tail[0]) {
"publish" -> publish(dataDir)
"check" -> check(dataDir, tail.drop(1).toTypedArray())
else -> Json.error("bad_args", "key-package ${tail[0]}")
}
}
private suspend fun publish(dataDir: DataDir): Int {
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val relays = ctx.keyPackageRelays().ifEmpty { ctx.outboxRelays() }.ifEmpty { ctx.anyRelays() }
if (relays.isEmpty()) return Json.error("no_relays", "configure relays first")
val event = ctx.marmot.generateKeyPackageEvent(relays.toList())
val ack = ctx.publish(event, relays)
Json.writeLine(
mapOf(
"event_id" to event.id,
"kind" to event.kind,
"accepted_by" to ack.filterValues { it }.keys.map { it.url },
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
private suspend fun check(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "key-package check <npub>")
val ctx = Context.open(dataDir)
try {
ctx.prepare()
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 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(
mapOf(
"event_id" to event.id,
"author" to event.pubKey,
"kind" to event.kind,
"created_at" to event.createdAt,
"has_content" to event.content.isNotBlank(),
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,125 @@
/*
* 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.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Identity
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
import okhttp3.OkHttpClient
/**
* `amy login KEY [--password X]` import any of the identifier forms
* Amethyst's login screen accepts and persist the identity to the
* data-dir. Mirrors [AccountSessionManager.loginSync] but on the JVM.
*
* Accepted forms (tried in this order):
* - nsec1 full account
* - ncryptsec + --password X NIP-49 decrypt full
* - BIP-39 mnemonic (space-separated) NIP-06 derive full
* - 64-hex private key (with --private) full
* - npub1 / nprofile1 / 64-hex pubkey read-only
* - NIP-05 identifier (name@domain.tld) read-only (HTTP lookup)
*/
object LoginCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) {
return Json.error("bad_args", "login <nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05> [--password X]")
}
if (dataDir.loadIdentityOrNull() != null) {
return Json.error("exists", "identity already exists at ${dataDir.identityFile}; use a fresh --data-dir or delete it first")
}
val key = rest[0].trim()
val args = Args(rest.drop(1).toTypedArray())
val identity =
resolveIdentity(key, args)
?: return Json.error(
"bad_key",
"could not parse '$key' as any supported identifier",
)
dataDir.saveIdentity(identity)
Json.writeLine(
mapOf(
"npub" to identity.npub,
"hex" to identity.pubKeyHex,
"read_only" to !identity.hasPrivateKey,
"data_dir" to dataDir.root.absolutePath,
),
)
return 0
}
private suspend fun resolveIdentity(
key: String,
args: Args,
): Identity? {
// 1. ncryptsec — password mandatory.
if (key.startsWith("ncryptsec")) {
val pw =
args.flag("password") ?: args.flag("pw")
?: throw IllegalArgumentException("ncryptsec input requires --password")
val privHex = Nip49().decrypt(key, pw)
return Identity.fromPrivateKey(
com.vitorpamplona.quartz.utils.Hex
.decode(privHex),
)
}
// 2. nsec
if (key.startsWith("nsec1")) return Identity.fromNsec(key)
// 3. mnemonic (space-separated, 12/24 words)
if (key.contains(' ') && Nip06().isValidMnemonic(key)) {
val priv = Nip06().privateKeyFromMnemonic(key)
return Identity.fromPrivateKey(priv)
}
// 4. 64-hex privkey — only when explicitly asked; otherwise a bare
// hex string is ambiguous with a pubkey and we default to public.
if (args.bool("private") && isHex64(key)) {
return Identity.fromPrivateKey(
com.vitorpamplona.quartz.utils.Hex
.decode(key),
)
}
// 5. everything else — defer to the shared resolver (npub / nprofile /
// hex pubkey / NIP-05). Read-only.
val pubHex = resolveUserHexOrNull(key, nip05Client()) ?: return null
return Identity.fromPublicKeyHex(pubHex)
}
private fun isHex64(s: String): Boolean = s.length == 64 && s.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }
private fun nip05Client(): Nip05Client {
// Build a throwaway OkHttp client — we don't hold a Context here and
// login is a one-shot CLI invocation anyway.
val http = OkHttpClient.Builder().build()
return Nip05Client(fetcher = OkHttpNip05Fetcher { _ -> http })
}
}
@@ -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.cli.commands
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
object MessageCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Json.error("bad_args", "message <send|list> …")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"send" -> send(dataDir, rest)
"list" -> list(dataDir, rest)
else -> Json.error("bad_args", "message ${tail[0]}")
}
}
private suspend fun send(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "message send <gid> <text>")
val gid = rest[0]
val text = rest[1]
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val bundle = ctx.marmot.buildTextMessage(gid, text)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(bundle.outbound.signedEvent, targets)
Json.writeLine(
mapOf(
"group_id" to gid,
"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 },
),
)
return 0
} finally {
ctx.close()
}
}
private suspend fun list(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "message list <gid>")
val gid = rest[0]
val args = Args(rest.drop(1).toTypedArray())
val limit = args.intFlag("limit", Int.MAX_VALUE)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val raw = ctx.marmot.loadStoredMessages(gid)
val items =
raw
.map { line ->
try {
@Suppress("UNCHECKED_CAST")
val obj = Json.mapper.readValue<Map<String, Any?>>(line)
mapOf(
"id" to obj["id"],
"author" to obj["pubkey"],
"kind" to obj["kind"],
"content" to obj["content"],
"created_at" to obj["created_at"],
)
} catch (_: Exception) {
mapOf("raw" to line)
}
}.takeLast(limit)
Json.writeLine(mapOf("group_id" to gid, "messages" to items))
return 0
} finally {
ctx.close()
}
}
}
@@ -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.cli.commands
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.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
object RelayCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Json.error("bad_args", "relay <add|list|publish-lists> …")
val sub = tail[0]
val rest = tail.drop(1).toTypedArray()
return when (sub) {
"add" -> add(dataDir, Args(rest))
"list" -> list(dataDir)
"publish-lists" -> publishLists(dataDir)
else -> Json.error("bad_args", "relay $sub")
}
}
private fun add(
dataDir: DataDir,
args: Args,
): Int {
val url = args.positional(0, "url")
val type = args.flag("type", "all") ?: "all"
val cfg = dataDir.loadRelays()
val addedTo = mutableListOf<String>()
val targets = if (type == "all") listOf("nip65", "inbox", "key_package") else listOf(type)
for (t in targets) {
if (cfg.add(t, url)) addedTo.add(t)
}
dataDir.saveRelays(cfg)
Json.writeLine(
mapOf(
"url" to url,
"added_to" to addedTo,
"already_present" to (targets - addedTo.toSet()),
),
)
return 0
}
private fun list(dataDir: DataDir): Int {
val cfg = dataDir.loadRelays()
Json.writeLine(
mapOf(
"nip65" to cfg.nip65,
"inbox" to cfg.inbox,
"key_package" to cfg.keyPackage,
),
)
return 0
}
private suspend fun publishLists(dataDir: DataDir): Int {
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val nip65Relays = ctx.relays.normalized("nip65").toList()
val inboxRelays = ctx.relays.normalized("inbox").toList()
val nip65Infos = nip65Relays.map { AdvertisedRelayInfo(it, AdvertisedRelayType.BOTH) }
val nip65Event = AdvertisedRelayListEvent.create(nip65Infos, ctx.signer)
val inboxEvent = ChatMessageRelayListEvent.create(inboxRelays, ctx.signer)
val targets = ctx.anyRelays()
val nip65Result = ctx.publish(nip65Event, targets)
val inboxResult = ctx.publish(inboxEvent, targets)
Json.writeLine(
mapOf(
"nip65_event_id" to nip65Event.id,
"inbox_event_id" to inboxEvent.id,
"accepted_by" to
mapOf(
"nip65" to nip65Result.filterValues { it }.keys.map { it.url },
"inbox" to inboxResult.filterValues { it }.keys.map { it.url },
),
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,142 @@
/*
* 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.stores
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import java.io.File
/**
* Test-harness file stores. **Unencrypted** the production interfaces
* document that implementations MUST encrypt at rest, but this CLI is a
* throwaway interop driver running against scratch keys and local scratch
* state. Do not point it at real account material.
*/
class FileMlsGroupStateStore(
private val dir: File,
) : MlsGroupStateStore {
init {
dir.mkdirs()
}
private fun stateFile(id: String) = File(dir, "$id.state")
private fun retainedFile(id: String) = File(dir, "$id.retained")
override suspend fun save(
nostrGroupId: String,
state: ByteArray,
) {
stateFile(nostrGroupId).writeBytes(state)
}
override suspend fun load(nostrGroupId: String): ByteArray? = stateFile(nostrGroupId).takeIf { it.exists() }?.readBytes()
override suspend fun delete(nostrGroupId: String) {
stateFile(nostrGroupId).delete()
retainedFile(nostrGroupId).delete()
}
override suspend fun listGroups(): List<String> =
dir
.listFiles { f -> f.name.endsWith(".state") }
?.map { it.name.removeSuffix(".state") }
?: emptyList()
override suspend fun saveRetainedEpochs(
nostrGroupId: String,
retainedSecrets: List<ByteArray>,
) {
// Layout: [u32 count][(u32 len, bytes) …] — tiny framing so readers
// can recover independent byte arrays without TLS plumbing.
val f = retainedFile(nostrGroupId)
f.outputStream().use { out ->
val buf = java.nio.ByteBuffer.allocate(4)
buf.putInt(retainedSecrets.size)
out.write(buf.array())
for (secret in retainedSecrets) {
buf.clear()
buf.putInt(secret.size)
out.write(buf.array())
out.write(secret)
}
}
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> {
val f = retainedFile(nostrGroupId)
if (!f.exists()) return emptyList()
val bytes = f.readBytes()
if (bytes.size < 4) return emptyList()
val buf = java.nio.ByteBuffer.wrap(bytes)
val count = buf.int
val result = ArrayList<ByteArray>(count)
repeat(count) {
val len = buf.int
val arr = ByteArray(len)
buf.get(arr)
result.add(arr)
}
return result
}
}
class FileKeyPackageBundleStore(
private val file: File,
) : KeyPackageBundleStore {
override suspend fun save(snapshot: ByteArray) {
file.parentFile?.mkdirs()
file.writeBytes(snapshot)
}
override suspend fun load(): ByteArray? = file.takeIf { it.exists() }?.readBytes()
override suspend fun delete() {
file.delete()
}
}
class FileMarmotMessageStore(
private val dir: File,
) : MarmotMessageStore {
init {
dir.mkdirs()
}
private fun file(id: String) = File(dir, "$id.messages")
override suspend fun appendMessage(
nostrGroupId: String,
innerEventJson: String,
) {
// Each line is one inner event JSON. The store doc tolerates duplicates
// so we don't bother deduping here — readers can do it.
file(nostrGroupId).appendText(innerEventJson.replace("\n", " ") + "\n")
}
override suspend fun loadMessages(nostrGroupId: String): List<String> = file(nostrGroupId).takeIf { it.exists() }?.readLines()?.filter { it.isNotBlank() } ?: emptyList()
override suspend fun delete(nostrGroupId: String) {
file(nostrGroupId).delete()
}
}
@@ -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)
@@ -18,10 +18,15 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.commons.defaults
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer 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 { object Constants {
val nos = RelayUrlNormalizer.normalize("wss://nos.lol") val nos = RelayUrlNormalizer.normalize("wss://nos.lol")
val mom = RelayUrlNormalizer.normalize("wss://nostr.mom") val mom = RelayUrlNormalizer.normalize("wss://nostr.mom")
@@ -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, innerEvent: Event,
): OutboundGroupEvent = outboundProcessor.buildGroupEvent(nostrGroupId, innerEvent) ): 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. * Add a member to a group.
* Returns the commit GroupEvent to publish, and the WelcomeDelivery for the new member. * 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 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 pubkeyleaf 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. * Get the MIP-04 media exporter secret for a group.
* MLS-Exporter("marmot", "encrypted-media", 32) * MLS-Exporter("marmot", "encrypted-media", 32)
@@ -477,3 +540,13 @@ data class GroupMemberInfo(
val leafIndex: Int, val leafIndex: Int,
val pubkey: HexKey, 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 */ /** Whether this group has an encrypted image set */
fun hasImage(): Boolean = imageHash != null && imageKey != null && imageNonce != null 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. * Encode this MarmotGroupData to TLS wire format bytes.
* Mirrors the [decodeTls] format. * Mirrors the [decodeTls] format.
@@ -185,6 +200,30 @@ data class MarmotGroupData(
const val EXTENSION_ID: UShort = 0xF2EEu const val EXTENSION_ID: UShort = 0xF2EEu
const val EXTENSION_ID_INT: Int = 0xF2EE 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. * 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. * 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('@')
}
@@ -34,10 +34,14 @@ actual object PlatformLog {
message: String, message: String,
throwable: Throwable?, throwable: Throwable?,
) { ) {
// Diagnostics go to stderr so callers that pipe stdout (the CLI uses
// stdout for its JSON contract, desktop apps may too) aren't corrupted
// by log interleaving. Consumers that prefer stdout can just redirect
// 2>&1 at the shell level.
if (throwable != null) { if (throwable != null) {
println("${time()} $level: [$tag] $message. Throwable: ${throwable.message}") System.err.println("${time()} $level: [$tag] $message. Throwable: ${throwable.message}")
} else { } else {
println("${time()} $level: [$tag] $message") System.err.println("${time()} $level: [$tag] $message")
} }
} }
+1
View File
@@ -37,3 +37,4 @@ include ':quartz'
include ':commons' include ':commons'
include ':ammolite' include ':ammolite'
include ':desktopApp' include ':desktopApp'
include ':cli'
+1
View File
@@ -1 +1,2 @@
state/ state/
state-headless/
+14 -7
View File
@@ -1,13 +1,20 @@
# Marmot Interop Test Harness # Marmot Interop Test Harness
Interactive harness that validates Amethyst's Marmot/MLS implementation against Two flavours, same scenarios:
**whitenoise-rs** (https://github.com/marmot-protocol/whitenoise-rs), the
reference Rust implementation that powers the White Noise Flutter app.
The harness drives two `wn` daemons (Identities **B** and **C**) from the - **`marmot-interop.sh`** — interactive. Drives B/C via `wn` and **prompts the
command line and prompts the human operator to perform the Amethyst-side steps human** to perform each Amethyst-side step in the mobile UI (Identity A).
in the mobile UI as **Identity A**. Every test records a pass/fail/skip result Use this for final UI verification.
into a tab-separated log, and the summary is printed at the end of the run. - **`marmot-interop-headless.sh`** — zero prompts. Drives A via the `amy` CLI
(`./gradlew :cli:installDist`) and B/C via `wn`. Runs every scenario
end-to-end and exits with a pass/fail summary. Use this for CI and for
iterating on the Nostr/Marmot plumbing without needing to touch a phone.
Both harnesses validate Amethyst against **whitenoise-rs**
(https://github.com/marmot-protocol/whitenoise-rs), the reference Rust
implementation that powers the White Noise Flutter app. Every test records a
pass/fail/skip result into a tab-separated log, and the summary is printed at
the end of the run.
## What gets tested ## What gets tested
+55
View File
@@ -0,0 +1,55 @@
# shellcheck shell=bash
#
# headless/helpers.sh — thin wrappers that keep the per-test code tight.
# --- amy wrapper -------------------------------------------------------------
amy_a() { "$AMY_BIN" --data-dir "$A_DIR" "$@"; }
# Run amy, log stderr, surface JSON on stdout, remember last result.
amy_json() {
local out
if ! out=$(amy_a "$@" 2>>"$LOG_FILE"); then
fail_msg "amy $*: exit $? (see $LOG_FILE)"
printf '%s\n' "$out" >>"$LOG_FILE"
return 1
fi
printf '%s' "$out"
}
# Convenience extractors — the CLI emits one JSON object per success so we can
# jq with impunity.
amy_field() {
# usage: amy_field '.group_id' init [args...]
local path="$1"; shift
amy_json "$@" | jq -r "$path"
}
# --- assertion helpers -------------------------------------------------------
# Assert a substring is present in a variable; append a failed result on miss
# and return 1. Positive case just logs.
assert_contains() {
local haystack="$1" needle="$2" test_id="$3" note="${4:-}"
if [[ "$haystack" == *"$needle"* ]]; then
info "assertion hit: $test_id contains \"$needle\""
return 0
fi
fail_msg "$test_id: missing \"$needle\" (${note:-no note})"
info "actual: $haystack"
record_result "$test_id" fail "${note:-missing \"$needle\"}"
return 1
}
# Assert two strings are equal (leniently trimmed).
assert_eq() {
local actual="$1" expected="$2" test_id="$3" note="${4:-}"
if [[ "${actual// /}" == "${expected// /}" ]]; then
info "assertion hit: $test_id \"$actual\" == \"$expected\""
return 0
fi
fail_msg "$test_id: expected \"$expected\", got \"$actual\" (${note:-})"
record_result "$test_id" fail "${note:-mismatch}"
return 1
}
# --- wn-side pollers (delegates to lib.sh) -----------------------------------
# Both exist in lib.sh already; this file only adds headless-specific niceties.
@@ -0,0 +1,23 @@
--- a/src/relay_control/discovery.rs
+++ b/src/relay_control/discovery.rs
@@ -87,6 +87,20 @@
/// Initial curated relay set from the planning doc.
pub(crate) fn curated_default_relays() -> Vec<RelayUrl> {
+ // marmot-interop-headless patch: honour $WHITENOISE_DISCOVERY_RELAYS
+ // (comma-separated list) when present, so the harness can force wnd
+ // to use a loopback relay instead of the baked-in public set.
+ if let Ok(from_env) = std::env::var("WHITENOISE_DISCOVERY_RELAYS") {
+ let parsed: Vec<RelayUrl> = from_env
+ .split(',')
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .filter_map(|u| RelayUrl::parse(u).ok())
+ .collect();
+ if !parsed.is_empty() {
+ return parsed;
+ }
+ }
[
"wss://index.hzrd149.com",
"wss://indexer.coracle.social",
@@ -0,0 +1,18 @@
--- a/src/bin/wnd.rs
+++ b/src/bin/wnd.rs
@@ -22,6 +22,15 @@
let args = Args::parse();
let config = Config::resolve(args.data_dir.as_ref(), args.logs_dir.as_ref());
+ // marmot-interop-headless patch: allow sandboxes/CI without a real
+ // kernel keyring to fall back to the integration-tests mock keyring
+ // by setting $WHITENOISE_MOCK_KEYRING=1. Requires the daemon to be
+ // built with --features cli,integration-tests. No effect otherwise.
+ #[cfg(feature = "integration-tests")]
+ if std::env::var("WHITENOISE_MOCK_KEYRING").is_ok() {
+ Whitenoise::initialize_mock_keyring_store();
+ }
+
let wn_config = WhitenoiseConfig::new(&config.data_dir, &config.logs_dir, KEYRING_SERVICE_ID);
Whitenoise::initialize_whitenoise(wn_config).await?;
+270
View File
@@ -0,0 +1,270 @@
# shellcheck shell=bash
#
# headless/setup.sh — preflight + daemon lifecycle + identity bootstrap.
# Sourced from marmot-interop-headless.sh.
# --- preflight ---------------------------------------------------------------
preflight() {
banner "Preflight"
for cmd in jq git curl cargo protoc patch; do
if ! command -v "$cmd" >/dev/null 2>&1; then
fail_msg "missing required tool: $cmd"
case "$cmd" in
protoc) info "hint: apt-get install protobuf-compiler (or brew install protobuf on macOS)" ;;
patch) info "hint: apt-get install patch" ;;
esac
exit 1
fi
info "$cmd: $(command -v "$cmd")"
done
# Build `amy` via gradle if missing.
if [[ ! -x "$AMY_BIN" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1
fi
step "building :cli:installDist"
( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE"
fi
[[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; }
info "amy: $AMY_BIN"
# Clone/build whitenoise-rs if needed (shared between both harnesses).
if [[ ! -d "$WN_REPO/.git" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
fail_msg "whitenoise-rs checkout missing at $WN_REPO and --no-build set"; exit 1
fi
step "cloning whitenoise-rs into $WN_REPO"
git clone --depth 1 https://github.com/marmot-protocol/whitenoise-rs.git "$WN_REPO" \
2>&1 | tee -a "$LOG_FILE"
fi
# Two harness-only patches to wnd so it runs fully offline / in
# sandboxes that block outbound + kernel keyring:
# 1. discovery-env: honour $WHITENOISE_DISCOVERY_RELAYS so we can
# point wnd at our loopback relay instead of the baked-in public
# set. Without it wnd exits with NoRelayConnections.
# 2. mock-keyring: honour $WHITENOISE_MOCK_KEYRING so wnd uses the
# integration-tests mock keyring store when the kernel keyutils
# syscalls are blocked (common in containers / CI).
local -a patches=(
"whitenoise-discovery-env.patch"
"whitenoise-mock-keyring.patch"
)
for name in "${patches[@]}"; do
local marker="$WN_REPO/.headless-patched-${name%.patch}"
if [[ ! -f "$marker" ]]; then
step "patching whitenoise-rs: $name"
( cd "$WN_REPO" && patch -p1 --forward --reject-file=- \
<"$SCRIPT_DIR/headless/patches/$name" ) 2>&1 | tee -a "$LOG_FILE"
touch "$marker"
# Invalidate the previous build so the patched source is picked up.
rm -f "$WN_BIN" "$WND_BIN"
fi
done
if [[ ! -x "$WN_BIN" || ! -x "$WND_BIN" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
fail_msg "wn/wnd not found and --no-build set"; exit 1
fi
step "building wn + wnd with integration-tests feature (~5 min first run)"
( cd "$WN_REPO" && \
cargo build --release --features cli,integration-tests --bin wn --bin wnd ) \
2>&1 | tee -a "$LOG_FILE"
fi
info "wn: $WN_BIN"
info "wnd: $WND_BIN"
# Clone/build nostr-rs-relay — the harness's single loopback relay.
if [[ ! -x "$RELAY_BIN" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
fail_msg "nostr-rs-relay not found at $RELAY_BIN and --no-build set"; exit 1
fi
if [[ ! -d "$RELAY_REPO/.git" ]]; then
step "cloning nostr-rs-relay into $RELAY_REPO"
git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \
2>&1 | tee -a "$LOG_FILE"
fi
step "building nostr-rs-relay (~3 min first run)"
( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \
2>&1 | tee -a "$LOG_FILE"
fi
info "relay bin: $RELAY_BIN"
}
# --- local relay -------------------------------------------------------------
# Start nostr-rs-relay on $RELAY_PORT with a minimal config. Every test
# runs against this one loopback endpoint — no external network traffic.
start_local_relay() {
banner "Starting local nostr-rs-relay on $RELAY_URL"
mkdir -p "$RELAY_DATA" "$RELAY_DATA/logs"
# Render a minimal config file each run so port/limits come from the
# harness rather than whatever was left on disk from a previous session.
cat >"$RELAY_DATA/config.toml" <<EOF
[info]
relay_url = "$RELAY_URL"
name = "amethyst-headless-harness"
description = "Loopback relay for marmot-interop-headless.sh — do not use for anything real."
[database]
data_directory = "$RELAY_DATA"
[network]
address = "127.0.0.1"
port = $RELAY_PORT
[options]
reject_future_seconds = 3600
[limits]
# Keep kind:444 / 445 / 1059 / 30443 wide open — the whole point is
# exercising Marmot traffic the public relays reject.
max_event_bytes = 524288
max_ws_message_bytes = 1048576
max_ws_frame_bytes = 1048576
EOF
# Abort early if something else is already bound to the port — failing
# with a clear error beats a mysterious-looking daemon stall later.
if ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$RELAY_PORT\$"; then
fail_msg "port $RELAY_PORT already in use — pass --port N or free it"
exit 1
fi
nohup "$RELAY_BIN" --db "$RELAY_DATA" --config "$RELAY_DATA/config.toml" \
>"$RELAY_DATA/logs/stdout.log" 2>"$RELAY_DATA/logs/stderr.log" &
echo "$!" > "$RELAY_DATA/pid"
step "relay pid $(cat "$RELAY_DATA/pid"); waiting for $RELAY_URL"
local deadline=$(( $(date +%s) + 20 ))
while [[ $(date +%s) -lt $deadline ]]; do
if curl -sSf -m 1 "http://127.0.0.1:$RELAY_PORT/" >/dev/null 2>&1; then
info "relay up"
return 0
fi
sleep 0.5
done
fail_msg "relay never came up (see $RELAY_DATA/logs/stderr.log)"
tail -n 40 "$RELAY_DATA/logs/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true
exit 1
}
stop_local_relay() {
local pid_file="$RELAY_DATA/pid"
[[ -f "$pid_file" ]] || return 0
local pid; pid=$(cat "$pid_file" 2>/dev/null || echo "")
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
info "stopping relay pid $pid"
kill "$pid" 2>/dev/null || true
sleep 1
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$pid_file"
}
# --- daemons -----------------------------------------------------------------
start_daemon() {
local name="$1" data_dir="$2" socket="$3"
step "starting $name daemon"
if [[ -S "$socket" ]] && "$WN_BIN" --socket "$socket" whoami >/dev/null 2>&1; then
info "$name daemon already running"; return 0
fi
rm -f "$socket"
mkdir -p "$data_dir/logs" "$data_dir/release"
# Env vars consumed by the two harness-only wnd patches applied in
# preflight:
# WHITENOISE_DISCOVERY_RELAYS — forces the discovery plane at our
# loopback relay (kills the "can't reach nos.lol" exit path).
# WHITENOISE_MOCK_KEYRING — swaps in the integration-tests mock
# secret store so wnd doesn't fall over when the kernel blocks
# keyutils syscalls.
# Both are harmless on a real host with connectivity + a real keyring.
WHITENOISE_DISCOVERY_RELAYS="$RELAY_URL" \
WHITENOISE_MOCK_KEYRING=1 \
nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" \
>"$data_dir/logs/stdout.log" 2>"$data_dir/logs/stderr.log" &
echo "$!" > "$data_dir/pid"
local deadline=$(( $(date +%s) + 30 ))
while [[ $(date +%s) -lt $deadline ]]; do
if [[ -S "$socket" ]] && "$WN_BIN" --socket "$socket" whoami >/dev/null 2>&1; then
info "$name ready"; return 0
fi
sleep 1
done
fail_msg "$name daemon failed to start (see $data_dir/logs/stderr.log)"
exit 1
}
stop_daemons() {
for d in "$B_DIR" "$C_DIR"; do
if [[ -f "$d/pid" ]]; then
local pid; pid=$(cat "$d/pid" 2>/dev/null || echo "")
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
info "stopping daemon pid $pid"
kill "$pid" 2>/dev/null || true
sleep 1
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$d/pid"
fi
done
}
# --- identities --------------------------------------------------------------
ensure_identity_a() {
step "initialising Identity A (amy)"
local out
out=$(amy_a init) || { fail_msg "amy init failed: $out"; exit 1; }
A_NPUB=$(printf '%s' "$out" | jq -r '.npub')
A_HEX=$(printf '%s' "$out" | jq -r '.hex')
info "A npub: $A_NPUB"
info "A hex: $A_HEX"
}
ensure_identity() {
local who="$1" cmd npub=""
if [[ "$who" == "B" ]]; then cmd=wn_b; else cmd=wn_c; fi
step "ensuring Identity $who (wn)"
local raw
raw=$("$cmd" --json whoami 2>/dev/null || true)
npub=$(extract_pubkey "$raw")
if [[ -z "${npub:-}" ]]; then
# create-identity sometimes exits non-zero (e.g. transient "failed to
# connect to any relays") even though the account was created. Probe
# --json whoami afterwards before giving up.
"$cmd" create-identity 2>&1 | tee -a "$LOG_FILE" || true
raw=$("$cmd" --json whoami 2>/dev/null || true)
npub=$(extract_pubkey "$raw")
fi
[[ -n "$npub" ]] || { fail_msg "could not determine $who npub"; exit 1; }
local hex; hex=$(npub_to_hex "$npub")
if [[ "$who" == "B" ]]; then B_NPUB="$npub"; B_HEX="$hex"
else C_NPUB="$npub"; C_HEX="$hex"; fi
info "$who npub: $npub"
[[ "$hex" != "$npub" ]] && info "$who hex: $hex"
}
# --- relays ------------------------------------------------------------------
# Point all three identities at the loopback relay. We never publish any
# test traffic off-box — public relays reject kind:445 anyway and the
# goal here is tight, deterministic iteration.
configure_relays() {
banner "Configuring relays → $RELAY_URL"
amy_a relay add "$RELAY_URL" --type all >/dev/null
for t in nip65 inbox key_package; do
wn_b relays add --type "$t" "$RELAY_URL" 2>/dev/null || true
wn_c relays add --type "$t" "$RELAY_URL" 2>/dev/null || true
done
# A advertises its NIP-65 + DM inbox lists so B/C can discover where to
# deliver gift wraps. With a single shared relay the lookup is trivial
# but we still publish so we catch regressions in the advertise path.
step "publishing A's NIP-65 + kind:10050 lists"
amy_a relay publish-lists >>"$LOG_FILE" 2>&1 || warn "amy relay publish-lists failed"
step "publishing A's KeyPackage"
amy_a marmot key-package publish >>"$LOG_FILE" 2>&1 || warn "amy marmot key-package publish failed"
}
@@ -0,0 +1,175 @@
# shellcheck shell=bash
#
# headless/tests-create.sh — tests 01..05.
# Focus: KeyPackage discovery, group creation, invites, initial messages.
test_01_keypackage_discovery() {
banner "Test 01 — KeyPackage publish & discovery (MIP-00)"
local id="01 KeyPackage A<->B"
# B finds A's KP
local raw ev
raw=$(wn_b --json keys check "$A_NPUB" 2>>"$LOG_FILE" || true)
ev=$(printf '%s' "$raw" | jq -r '.result.event_id // .event_id // empty')
if [[ -z "$ev" || "$ev" == "null" ]]; then
record_result "$id (B->A)" fail "wn couldn't find A's KP"; return
fi
info "B saw A's KP $ev"
# A finds B's KP (wn publishes automatically via create-identity flow? if not, ask).
wn_b keys publish >>"$LOG_FILE" 2>&1 || warn "wn_b keys publish failed"
sleep 3
local out
out=$(amy_json marmot key-package check "$B_NPUB" 2>/dev/null || true)
if [[ -z "$out" ]]; then
record_result "$id (A->B)" fail "amy couldn't find B's KP"; return
fi
info "A saw B's KP $(printf '%s' "$out" | jq -r .event_id)"
record_result "$id" pass
}
test_02_a_creates_group() {
banner "Test 02 — A creates group, invites B"
local id="02 A->B create+invite"
local gid
gid=$(amy_field '.group_id' marmot group create --name "Interop-02") || {
record_result "$id" fail "create returned no group_id"; return
}
save_state GROUP_02 "$gid"
info "created group $gid"
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || {
record_result "$id" fail "amy group add failed"; return
}
# B waits for invite, accepts.
local b_gid
if ! b_gid=$(wait_for_invite B 60); then
record_result "$id" fail "B never received invite"; return
fi
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
info "B joined $b_gid"
# A -> B message
amy_json marmot message send "$gid" "hello from amethyst" >/dev/null || {
record_result "$id" fail "amy send failed"; return
}
if ! wait_for_message B "$gid" "hello from amethyst" 30; then
record_result "$id" fail "B didn't receive A's message"; return
fi
# B -> A message
wn_b messages send "$gid" "hello from wn" >/dev/null 2>&1 || warn "wn send returned nonzero"
if ! amy_json marmot await message "$gid" --match "hello from wn" --timeout 30 >/dev/null; then
record_result "$id" fail "A didn't receive B's reply"; return
fi
record_result "$id" pass
}
test_03_b_creates_group() {
banner "Test 03 — B creates group, invites A"
local id="03 B->A create+invite"
local out gid
out=$(wn_b --json groups create "Interop-03" "$A_NPUB" 2>>"$LOG_FILE") || {
record_result "$id" fail "wn groups create failed"; return
}
gid=$(printf '%s' "$out" | jq_group_id)
if [[ -z "$gid" ]]; then
record_result "$id" fail "could not parse group_id"; return
fi
save_state GROUP_03 "$gid"
info "group_id: $gid"
# A: poll until it joins.
if ! amy_json marmot await group --name "Interop-03" --timeout 30 >/dev/null; then
record_result "$id" fail "A never joined B's group"; return
fi
# B -> A message
wn_b messages send "$gid" "ping from wn" >/dev/null 2>&1 || true
if ! amy_json marmot await message "$gid" --match "ping from wn" --timeout 30 >/dev/null; then
record_result "$id" fail "A didn't see B's ping"; return
fi
# A -> B reply
amy_json marmot message send "$gid" "pong from amethyst" >/dev/null || {
record_result "$id" fail "amy send pong failed"; return
}
if wait_for_message B "$gid" "pong from amethyst" 30; then
record_result "$id" pass
else
record_result "$id" fail "B didn't see A's pong"
fi
}
test_04_three_member_group() {
banner "Test 04 — 3-member group, add C after create"
local id="04 3-member add-after-create"
wn_c keys publish >/dev/null 2>&1 || true
sleep 3
local gid; gid=$(load_state GROUP_02 || true)
if [[ -z "${gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi
# A adds C
amy_json marmot group add "$gid" "$C_NPUB" >/dev/null || {
record_result "$id" fail "amy group add C failed"; return
}
local c_gid
if ! c_gid=$(wait_for_invite C 60); then
record_result "$id" fail "C never received invite"; return
fi
wn_c groups accept "$c_gid" >/dev/null 2>&1 || true
amy_json marmot message send "$gid" "hello three-member world" >/dev/null || {
record_result "$id" fail "amy send failed"; return
}
if wait_for_message B "$gid" "hello three-member world" 30 \
&& wait_for_message C "$c_gid" "hello three-member world" 30; then
record_result "$id" pass
else
record_result "$id" fail "B or C missed A's post-add message"
fi
}
test_05_b_adds_a_existing() {
banner "Test 05 — B adds A to an existing B+C group"
local id="05 wn adds A existing"
local out gid
out=$(wn_b --json groups create "Interop-05" "$C_NPUB" 2>>"$LOG_FILE") || {
record_result "$id" fail "wn create Interop-05 failed"; return
}
gid=$(printf '%s' "$out" | jq_group_id)
if [[ -z "$gid" ]]; then
record_result "$id" fail "no group_id from create"; return
fi
save_state GROUP_05 "$gid"
wait_for_invite C 30 >/dev/null && wn_c groups accept "$gid" >/dev/null 2>&1 || true
wn_b groups add-members "$gid" "$A_NPUB" >/dev/null 2>&1 || {
record_result "$id" fail "wn add-members A failed"; return
}
# A joins
if ! amy_json marmot await group --name "Interop-05" --timeout 30 >/dev/null; then
record_result "$id" fail "A never received invite to Interop-05"; return
fi
amy_json marmot message send "$gid" "joined from amethyst" >/dev/null || {
record_result "$id" fail "amy send failed"; return
}
if wait_for_message B "$gid" "joined from amethyst" 30 \
&& wait_for_message C "$gid" "joined from amethyst" 30; then
record_result "$id" pass
else
record_result "$id" fail "B or C didn't see A's message"
fi
}
@@ -0,0 +1,172 @@
# shellcheck shell=bash
#
# headless/tests-extras.sh — tests 09, 10, 12, 13.
# Focus: reactions/replies, concurrent commits, offline catchup, KP rotation.
test_09_reply_react_unreact() {
banner "Test 09 — reply / react / unreact"
local id="09 reply/react"
local gid; gid=$(load_state GROUP_02 || true)
if [[ -z "${gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi
# B anchors. Needs a member to be present — if Test 11 already ran and A left,
# skip cleanly so we don't double-fail.
if ! wn_b --json groups members "$gid" 2>/dev/null \
| jq -e --arg p "$A_HEX" '.[]? | select((.pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
record_result "$id" skip "A already left GROUP_02"; return
fi
wn_b messages send "$gid" "anchor for reactions" >/dev/null 2>&1 || true
sleep 3
local msg_id
msg_id=$(wn_b --json messages list "$gid" --limit 10 2>/dev/null \
| jq -r '[.[]? | select((.content // .text // "") == "anchor for reactions")][0].id // empty')
if [[ -z "$msg_id" || "$msg_id" == "null" ]]; then
record_result "$id" fail "couldn't find anchor message id"; return
fi
wn_b messages react "$gid" "$msg_id" "🌮" >/dev/null 2>&1 || true
sleep 3
# amy reply
amy_json marmot message send "$gid" "replying via amy" >/dev/null || {
record_result "$id" fail "amy send reply failed"; return
}
if wait_for_message B "$gid" "replying via amy" 30; then
record_result "$id" pass
else
record_result "$id" fail "B didn't receive reply"
fi
# NB: react/unreact round-trip verification requires a CLI verb we don't have
# yet (amy marmot message react). Once we add it, expand this test.
}
test_10_concurrent_commits() {
banner "Test 10 — Concurrent commits race"
local id="10 concurrent commits"
local gid; gid=$(load_state GROUP_02 || true)
if [[ -z "${gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi
if ! wn_b --json groups members "$gid" 2>/dev/null \
| jq -e --arg p "$A_HEX" '.[]? | select((.pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
record_result "$id" skip "A already left GROUP_02"; return
fi
# Fire both renames in parallel. One wins — MLS single-commit-per-epoch
# guarantees a deterministic outcome.
( amy_json marmot group rename "$gid" "race-from-amethyst" >/dev/null ) &
local a_pid=$!
( wn_b groups rename "$gid" "race-from-wn" >/dev/null 2>&1 ) &
local b_pid=$!
wait "$a_pid" "$b_pid" 2>/dev/null || true
sleep 10
local b_name
b_name=$(wn_b --json groups show "$gid" 2>/dev/null | jq -r '.name // empty')
local a_name
a_name=$(amy_field '.name' marmot group show "$gid" 2>/dev/null || echo "")
if [[ -n "$a_name" && "$a_name" == "$b_name" ]]; then
info "race converged: both sides see \"$a_name\""
# Verify encryption still works.
wn_b messages send "$gid" "post-race ping" >/dev/null 2>&1 || true
if amy_json marmot await message "$gid" --match "post-race ping" --timeout 15 >/dev/null; then
record_result "$id" pass
else
record_result "$id" fail "encryption broken after race"
fi
else
record_result "$id" fail "diverged: A sees \"$a_name\", B sees \"$b_name\""
fi
}
test_12_offline_catchup() {
banner "Test 12 — Offline catch-up"
local id="12 offline catchup"
# Fresh group so we don't collide with other tests.
local out gid
out=$(wn_b --json groups create "Interop-12" "$A_NPUB" 2>>"$LOG_FILE")
gid=$(printf '%s' "$out" | jq_group_id)
[[ -n "$gid" ]] || { record_result "$id" fail "couldn't create Interop-12"; return; }
save_state GROUP_12 "$gid"
# A joins.
amy_json marmot await group --name "Interop-12" --timeout 30 >/dev/null || {
record_result "$id" fail "A never received Interop-12 invite"; return
}
# "Go offline" == don't invoke amy. Meanwhile B sends 5 messages + adds C + sends 3 more + rename.
for i in 1 2 3 4 5; do
wn_b messages send "$gid" "offline-msg-$i" >/dev/null 2>&1 || true
sleep 1
done
wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || true
wait_for_invite C 30 >/dev/null && wn_c groups accept "$gid" >/dev/null 2>&1 || true
for i in 6 7 8; do
wn_b messages send "$gid" "offline-msg-$i" >/dev/null 2>&1 || true
sleep 1
done
wn_b groups rename "$gid" "Interop-12-renamed" >/dev/null 2>&1 || true
sleep 3
# A comes back online — single sync pulls everything.
local show
show=$(amy_json marmot group show "$gid") || {
record_result "$id" fail "amy group show failed"; return
}
local name; name=$(printf '%s' "$show" | jq -r '.name')
if [[ "$name" != "Interop-12-renamed" ]]; then
record_result "$id" fail "A replay missed rename (saw \"$name\")"; return
fi
# All 8 messages should be locally stored.
local msgs; msgs=$(amy_json marmot message list "$gid" --limit 100)
local missing=0
for i in 1 2 3 4 5 6 7 8; do
if ! printf '%s' "$msgs" | jq -e --arg t "offline-msg-$i" \
'.messages[]? | select((.content // "") == $t)' >/dev/null; then
missing=$((missing+1))
info "missing offline-msg-$i"
fi
done
if [[ "$missing" -eq 0 ]]; then
record_result "$id" pass
else
record_result "$id" fail "A missed $missing of 8 offline messages"
fi
}
test_13_keypackage_rotation() {
banner "Test 13 — KeyPackage rotation"
local id="13 keypackage rotation"
local before
before=$(wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -r '.result.event_id // empty')
if [[ -z "$before" ]]; then
record_result "$id" fail "no prior KP for A"; return
fi
amy_json marmot key-package publish >/dev/null || {
record_result "$id" fail "amy key-package publish failed"; return
}
local deadline=$(( $(date +%s) + 60 )) after=""
while [[ $(date +%s) -lt $deadline ]]; do
after=$(wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -r '.result.event_id // empty')
[[ -n "$after" && "$after" != "$before" ]] && break
sleep 3
done
if [[ -n "$after" && "$after" != "$before" ]]; then
info "KP rotated: ${before:0:8}… → ${after:0:8}"
record_result "$id" pass
else
record_result "$id" fail "no new KP event_id observed"
fi
}
@@ -0,0 +1,152 @@
# shellcheck shell=bash
#
# headless/tests-manage.sh — tests 06, 07, 08, 11.
# Focus: removal, metadata rename, admin promote/demote, leave.
test_06_member_removal() {
banner "Test 06 — Member removal + forward secrecy"
local id="06 member removal"
local gid; gid=$(load_state GROUP_05 || true)
if [[ -z "${gid:-}" ]]; then
record_result "$id" skip "no GROUP_05"; return
fi
amy_json marmot group remove "$gid" "$C_NPUB" >/dev/null || {
record_result "$id" fail "amy remove C failed"; return
}
# C should no longer see the group on its own member view.
local deadline=$(( $(date +%s) + 60 )) removed=0
while [[ $(date +%s) -lt $deadline ]]; do
if ! wn_c --json groups members "$gid" 2>/dev/null \
| jq -e --arg p "$C_HEX" '.[]? | select((.pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
removed=1; break
fi
sleep 3
done
if [[ "$removed" -ne 1 ]]; then
warn "C still appears as a member — continuing"
fi
amy_json marmot message send "$gid" "after removing C" >/dev/null || {
record_result "$id" fail "amy send failed"; return
}
wait_for_message B "$gid" "after removing C" 30 || {
record_result "$id" fail "B lost access after C's removal"; return
}
# Forward secrecy: C must NOT see the post-removal message.
sleep 5
if wait_for_message C "$gid" "after removing C" 10; then
record_result "$id" fail "C still decrypted a post-removal message"
else
record_result "$id" pass
fi
}
test_07_metadata_rename() {
banner "Test 07 — Metadata rename round-trip (MIP-01)"
local id="07 metadata rename"
local gid; gid=$(load_state GROUP_02 || true)
if [[ -z "${gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi
amy_json marmot group rename "$gid" "Interop-02-renamed" >/dev/null || {
record_result "$id" fail "amy rename failed"; return
}
local deadline=$(( $(date +%s) + 60 )) seen=""
while [[ $(date +%s) -lt $deadline ]]; do
seen=$(wn_b --json groups show "$gid" 2>/dev/null | jq -r '.name // empty')
[[ "$seen" == "Interop-02-renamed" ]] && break
sleep 3
done
[[ "$seen" == "Interop-02-renamed" ]] || {
record_result "$id" fail "B saw name=\"$seen\" not \"Interop-02-renamed\""; return
}
# Now B renames back and A should pick it up.
wn_b groups rename "$gid" "Interop-02-reverse" >/dev/null 2>&1 || true
if amy_json marmot await rename "$gid" --name "Interop-02-reverse" --timeout 60 >/dev/null; then
record_result "$id" pass
else
record_result "$id" fail "A did not pick up B's rename"
fi
}
test_08_admin_promote_demote() {
banner "Test 08 — Admin promote / demote"
local id="08 admin promote/demote"
local gid; gid=$(load_state GROUP_03 || true)
if [[ -z "${gid:-}" ]]; then
record_result "$id" skip "no GROUP_03"; return
fi
# Ensure 3 members (add C if missing).
wn_c keys publish >/dev/null 2>&1 || true
sleep 2
wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || true
wait_for_invite C 30 >/dev/null && wn_c groups accept "$gid" >/dev/null 2>&1 || true
# B promotes A.
wn_b groups promote "$gid" "$A_NPUB" >/dev/null 2>&1 || {
record_result "$id" fail "wn promote failed"; return
}
# A should reflect the new admin set — poll via amy.
if ! amy_json marmot await admin "$gid" "$A_NPUB" --timeout 30 >/dev/null; then
record_result "$id" fail "A never saw itself promoted"; return
fi
# A now commits a rename — only possible if we're admin.
amy_json marmot group rename "$gid" "Interop-03-by-A" >/dev/null || {
record_result "$id" fail "A (now admin) could not rename"; return
}
# B demotes A.
wn_b groups demote "$gid" "$A_NPUB" >/dev/null 2>&1 || warn "demote returned nonzero"
sleep 5
local admins
admins=$(wn_b --json groups admins "$gid" 2>/dev/null \
| jq -r '.[].pubkey // .[].public_key // .[]' | tr '\n' ' ')
if [[ "$admins" == *"$A_HEX"* ]]; then
record_result "$id" fail "A still admin after demote"
else
record_result "$id" pass
fi
}
test_11_leave_group() {
banner "Test 11 — Leave group"
local id="11 leave group"
local gid; gid=$(load_state GROUP_02 || true)
if [[ -z "${gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi
amy_json marmot group leave "$gid" >/dev/null || {
record_result "$id" fail "amy leave failed"; return
}
local deadline=$(( $(date +%s) + 60 )) gone=0
while [[ $(date +%s) -lt $deadline ]]; do
if ! wn_b --json groups members "$gid" 2>/dev/null \
| jq -e --arg p "$A_HEX" '.[]? | select((.pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
gone=1; break
fi
sleep 3
done
if [[ "$gone" -eq 1 ]]; then
record_result "$id" pass
else
record_result "$id" fail "A still in B's member list after leave"
fi
}
+3
View File
@@ -271,6 +271,9 @@ extract_pubkey() {
# JSON: single object # JSON: single object
v=$(printf '%s' "$raw" | jq -r '.pubkey // .npub // .public_key // empty' 2>/dev/null || true) v=$(printf '%s' "$raw" | jq -r '.pubkey // .npub // .public_key // empty' 2>/dev/null || true)
if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi
# JSON: {"result": [ {"pubkey": …}, … ]} — post-v0.2 `wn --json whoami` shape
v=$(printf '%s' "$raw" | jq -r '.result[0].pubkey // .result[0].npub // .result[0].public_key // empty' 2>/dev/null || true)
if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi
# JSON: array of accounts (whoami may return a list) # JSON: array of accounts (whoami may return a list)
v=$(printf '%s' "$raw" | jq -r '.[0].pubkey // .[0].npub // .[0].public_key // empty' 2>/dev/null || true) v=$(printf '%s' "$raw" | jq -r '.[0].pubkey // .[0].npub // .[0].public_key // empty' 2>/dev/null || true)
if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
#
# marmot-interop-headless.sh — zero-prompt, zero-internet interop harness.
#
# Drives Identity A via the `amy` CLI (./gradlew :cli:installDist) and
# Identities B/C via whitenoise-rs `wn`/`wnd`. Spins up a local
# nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT so nothing ever leaves the
# machine. Matches the 13 test scenarios in marmot-interop.sh but without
# any human prompts — all checks run to completion and the exit code
# reflects pass/fail totals.
#
# Usage: ./marmot-interop-headless.sh [--port N] [--no-build]
#
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-headless"
LOG_DIR="$STATE_DIR/logs"
A_DIR="$STATE_DIR/A"
B_DIR="$STATE_DIR/B"
C_DIR="$STATE_DIR/C"
B_SOCKET="$B_DIR/release/wnd.sock"
C_SOCKET="$C_DIR/release/wnd.sock"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
WN_REPO="${WN_REPO:-$SCRIPT_DIR/state/whitenoise-rs}"
WN_BIN="$WN_REPO/target/release/wn"
WND_BIN="$WN_REPO/target/release/wnd"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
# Local relay wiring — cloned + built during preflight, started on
# $RELAY_PORT. The harness never touches the public internet for test
# traffic; wn/wnd/amy all point at this one loopback endpoint.
RELAY_REPO="${RELAY_REPO:-$STATE_DIR/nostr-rs-relay}"
RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
RELAY_DATA="$STATE_DIR/relay"
RELAY_PORT="${RELAY_PORT:-8080}"
RELAY_URL="ws://127.0.0.1:$RELAY_PORT"
NO_BUILD=0
A_NPUB=""
A_HEX=""
B_NPUB=""
B_HEX=""
C_NPUB=""
C_HEX=""
while [[ $# -gt 0 ]]; do
case "$1" in
--port) RELAY_PORT="$2"; RELAY_URL="ws://127.0.0.1:$RELAY_PORT"; shift ;;
--no-build) NO_BUILD=1 ;;
-h|--help)
sed -n '3,14p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'
exit 0 ;;
*) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;;
esac
shift
done
mkdir -p "$STATE_DIR" "$LOG_DIR" "$A_DIR" "$B_DIR/logs" "$C_DIR/logs"
: >"$LOG_FILE"
: >"$RESULTS_FILE"
# Reuse colours / logging / dump_daemon_diagnostics from the interactive harness.
# shellcheck source=lib.sh
source "$SCRIPT_DIR/lib.sh"
# shellcheck source=headless/setup.sh
source "$SCRIPT_DIR/headless/setup.sh"
# shellcheck source=headless/helpers.sh
source "$SCRIPT_DIR/headless/helpers.sh"
# shellcheck source=headless/tests-create.sh
source "$SCRIPT_DIR/headless/tests-create.sh"
# shellcheck source=headless/tests-manage.sh
source "$SCRIPT_DIR/headless/tests-manage.sh"
# shellcheck source=headless/tests-extras.sh
source "$SCRIPT_DIR/headless/tests-extras.sh"
trap 'stop_daemons; stop_local_relay; print_summary' EXIT
banner "Marmot headless interop harness ($RUN_TS)"
preflight
start_local_relay
start_daemon B "$B_DIR" "$B_SOCKET"
start_daemon C "$C_DIR" "$C_SOCKET"
ensure_identity_a
ensure_identity B
ensure_identity C
configure_relays
test_01_keypackage_discovery
test_02_a_creates_group
test_03_b_creates_group
test_04_three_member_group
test_05_b_adds_a_existing
test_06_member_removal
test_07_metadata_rename
test_08_admin_promote_demote
test_09_reply_react_unreact
test_10_concurrent_commits
test_11_leave_group
test_12_offline_catchup
test_13_keypackage_rotation