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:
+1
-1
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync
|
||||
|
||||
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.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
|
||||
@@ -2001,9 +2001,6 @@ class Account(
|
||||
val manager = marmotManager ?: return "Error: Marmot not initialized"
|
||||
if (!isWriteable()) return "Error: Account is read-only"
|
||||
|
||||
// Build filter for the member's KeyPackages
|
||||
val filter = manager.subscriptionManager.keyPackageFilter(memberPubKey)
|
||||
|
||||
// Per MIP-00, invitees advertise the relays that host their
|
||||
// KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look
|
||||
// there first, then fall back to the invitee's NIP-65 outbox
|
||||
@@ -2025,20 +2022,18 @@ class Account(
|
||||
.outboxRelays()
|
||||
?.toSet()
|
||||
.orEmpty()
|
||||
val fetchRelays = memberKeyPackageRelays + memberOutbox + myOutbox
|
||||
val fetchRelays =
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
.fetchRelaysFor(memberKeyPackageRelays, memberOutbox, myOutbox)
|
||||
|
||||
Log.d("MarmotDbg") {
|
||||
"fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " +
|
||||
"(memberKeyPackageRelays=${memberKeyPackageRelays.size}, memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}"
|
||||
}
|
||||
|
||||
// Query across the combined relay set
|
||||
val filterMap = fetchRelays.associateWith { listOf(filter) }
|
||||
|
||||
val event =
|
||||
client.fetchFirst(
|
||||
filters = filterMap,
|
||||
)
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
.fetchKeyPackage(client, memberPubKey, fetchRelays)
|
||||
|
||||
if (event == null) {
|
||||
Log.w("MarmotDbg") {
|
||||
@@ -2051,21 +2046,12 @@ class Account(
|
||||
"fetchKeyPackageAndAddMember: got KeyPackage event id=${event.id.take(8)}… kind=${event.kind} authored=${event.pubKey.take(8)}…"
|
||||
}
|
||||
|
||||
if (event !is com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent) {
|
||||
Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: unexpected kind ${event.kind}" }
|
||||
return "Error: Unexpected event type received"
|
||||
}
|
||||
|
||||
val keyPackageBase64 = event.keyPackageBase64()
|
||||
if (keyPackageBase64.isBlank()) {
|
||||
Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: KeyPackage event has empty content" }
|
||||
return "Error: KeyPackage event has empty content"
|
||||
}
|
||||
|
||||
val keyPackageBytes =
|
||||
kotlin.io.encoding.Base64
|
||||
.decode(keyPackageBase64)
|
||||
val keyPackageEventId = event.id
|
||||
// The relays embedded in the WelcomeEvent tell the new member
|
||||
// where to subscribe for subsequent GroupEvents. Use our own
|
||||
// outbox — that's where we will publish them.
|
||||
@@ -2077,9 +2063,7 @@ class Account(
|
||||
|
||||
addMarmotGroupMember(
|
||||
nostrGroupId = nostrGroupId,
|
||||
memberPubKey = memberPubKey,
|
||||
keyPackageBytes = keyPackageBytes,
|
||||
keyPackageEventId = keyPackageEventId,
|
||||
keyPackageEvent = event,
|
||||
groupRelays = groupRelays,
|
||||
)
|
||||
|
||||
@@ -2092,14 +2076,13 @@ class Account(
|
||||
*/
|
||||
suspend fun addMarmotGroupMember(
|
||||
nostrGroupId: HexKey,
|
||||
memberPubKey: HexKey,
|
||||
keyPackageBytes: ByteArray,
|
||||
keyPackageEventId: HexKey,
|
||||
keyPackageEvent: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent,
|
||||
groupRelays: List<NormalizedRelayUrl>,
|
||||
) {
|
||||
val memberPubKey = keyPackageEvent.pubKey
|
||||
Log.d("MarmotDbg") {
|
||||
"addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}… " +
|
||||
"keyPackageBytes=${keyPackageBytes.size}B groupRelays=${groupRelays.size}"
|
||||
"groupRelays=${groupRelays.size}"
|
||||
}
|
||||
val manager = marmotManager ?: return
|
||||
if (!isWriteable()) return
|
||||
@@ -2107,9 +2090,7 @@ class Account(
|
||||
val (commitEvent, welcomeDelivery) =
|
||||
manager.addMember(
|
||||
nostrGroupId = nostrGroupId,
|
||||
memberPubKey = memberPubKey,
|
||||
keyPackageBytes = keyPackageBytes,
|
||||
keyPackageEventId = keyPackageEventId,
|
||||
keyPackageEvent = keyPackageEvent,
|
||||
relays = groupRelays,
|
||||
)
|
||||
|
||||
@@ -2173,17 +2154,11 @@ class Account(
|
||||
|
||||
/**
|
||||
* Relays where this account publishes kind:30443 KeyPackage events.
|
||||
*
|
||||
* Per MIP-00, these should match the relays advertised in the user's
|
||||
* kind:10051 KeyPackage Relay List so that other clients can discover
|
||||
* and fetch them. Falls back to the standard outbox set when no list
|
||||
* has been configured yet, since that's also where the user's other
|
||||
* write-oriented events land.
|
||||
* Per MIP-00: prefer kind:10051 KeyPackage Relay List; fall back to NIP-65 outbox.
|
||||
*/
|
||||
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> {
|
||||
val list = keyPackageRelayList.flow.value
|
||||
return if (list.isNotEmpty()) list else outboxRelays.flow.value
|
||||
}
|
||||
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> =
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
.publishRelaysFor(keyPackageRelayList.flow.value, outboxRelays.flow.value)
|
||||
|
||||
/**
|
||||
* Publish or rotate KeyPackage events.
|
||||
|
||||
@@ -37,7 +37,6 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
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.privateOutbox.PrivateOutboxRelayListEvent
|
||||
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.nip57Zaps.LnZapEvent
|
||||
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.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
|
||||
@@ -68,31 +65,6 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
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 =
|
||||
listOf(
|
||||
Permission(CommandType.SIGN_EVENT, RelayAuthEvent.KIND),
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
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.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
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.DefaultSearchRelayList
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip65RelayList
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.Constants
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Constants
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
+3
-3
@@ -20,10 +20,10 @@
|
||||
*/
|
||||
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.Constants
|
||||
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
|
||||
+1
-1
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
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.model.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
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.service.relayClient.eoseManagers.PerUniqueIdEoseManager
|
||||
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.unit.dp
|
||||
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.BroadcastStatus
|
||||
import com.vitorpamplona.amethyst.service.broadcast.RelayResult
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.BroadcastStatus
|
||||
import com.vitorpamplona.amethyst.service.broadcast.RelayResult
|
||||
|
||||
+28
-51
@@ -23,30 +23,18 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
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.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
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.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
|
||||
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
||||
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.NSec
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
|
||||
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.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -223,21 +206,20 @@ class AccountSessionManager(
|
||||
loginSync(newKey, transientAccount, loginWithExternalSigner, packageName, onError)
|
||||
}
|
||||
} else if (EMAIL_PATTERN.matcher(key).matches()) {
|
||||
val nip05 = Nip05Id.parse(key)
|
||||
if (nip05 == null) {
|
||||
onError("Could not parse nip05 address: $nip05")
|
||||
} else {
|
||||
try {
|
||||
val pubkeyInfo = nip05ClientBuilder().get(nip05)
|
||||
if (pubkeyInfo == null) {
|
||||
onError("User not found in the nip05 server: $nip05")
|
||||
} else {
|
||||
loginSync(Hex.decode(pubkeyInfo.pubkey).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError("Could not load nip05 address from the server: $nip05. ${e.message}")
|
||||
// Delegate to the shared quartz resolver so NIP-05 handling stays in
|
||||
// lockstep with the CLI and anywhere else we accept user identifiers.
|
||||
try {
|
||||
val hex =
|
||||
com.vitorpamplona.quartz.nip05DnsIdentifiers
|
||||
.resolveUserHexOrNull(key, nip05ClientBuilder())
|
||||
if (hex == null) {
|
||||
onError("User not found in the nip05 server: $key")
|
||||
} else {
|
||||
loginSync(Hex.decode(hex).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError("Could not load nip05 address from the server: $key. ${e.message}")
|
||||
}
|
||||
} else {
|
||||
loginSync(key, transientAccount, loginWithExternalSigner, packageName, onError)
|
||||
@@ -305,28 +287,23 @@ class AccountSessionManager(
|
||||
|
||||
fun createNewAccount(name: String? = null): AccountSettings {
|
||||
val keyPair = KeyPair()
|
||||
val tempSigner = NostrSignerSync(keyPair)
|
||||
|
||||
val bootstrap =
|
||||
com.vitorpamplona.amethyst.commons.account.bootstrapAccountEvents(
|
||||
signer = NostrSignerSync(keyPair),
|
||||
name = name,
|
||||
)
|
||||
return AccountSettings(
|
||||
keyPair = keyPair,
|
||||
transientAccount = false,
|
||||
backupUserMetadata = tempSigner.sign(MetadataEvent.newUser(name)),
|
||||
backupContactList =
|
||||
ContactListEvent.createFromScratch(
|
||||
followUsers = listOf(ContactTag(keyPair.pubKey.toHexKey(), null, null)),
|
||||
relayUse = emptyMap(),
|
||||
signer = tempSigner,
|
||||
),
|
||||
backupNIP65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, tempSigner),
|
||||
backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner),
|
||||
// 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),
|
||||
backupUserMetadata = bootstrap.userMetadata,
|
||||
backupContactList = bootstrap.contactList,
|
||||
backupNIP65RelayList = bootstrap.nip65RelayList,
|
||||
backupDMRelayList = bootstrap.dmRelayList,
|
||||
backupKeyPackageRelayList = bootstrap.keyPackageRelayList,
|
||||
backupSearchRelayList = bootstrap.searchRelayList,
|
||||
backupIndexRelayList = bootstrap.indexerRelayList,
|
||||
backupChannelList = bootstrap.channelList,
|
||||
backupRelayFeedsList = bootstrap.relayFeedsList,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+17
-30
@@ -1453,14 +1453,12 @@ class AccountViewModel(
|
||||
nostrGroupId: String,
|
||||
text: String,
|
||||
) {
|
||||
val template =
|
||||
com.vitorpamplona.quartz.nip01Core.signers.eventTemplate<com.vitorpamplona.quartz.nip01Core.core.Event>(
|
||||
kind = 9,
|
||||
description = text,
|
||||
)
|
||||
val innerEvent = account.signer.sign<com.vitorpamplona.quartz.nip01Core.core.Event>(template)
|
||||
// Inner event construction lives on MarmotManager so CLI and UI don't drift.
|
||||
// persistOwn=false because Account.sendMarmotGroupMessage routes the outer
|
||||
// event through LocalCache which already handles own-message display.
|
||||
val bundle = account.marmotManager?.buildTextMessage(nostrGroupId, text, persistOwn = false) ?: return
|
||||
val relays = marmotGroupRelays(nostrGroupId)
|
||||
account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays)
|
||||
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, relays)
|
||||
}
|
||||
|
||||
suspend fun sendMarmotGroupMediaMessage(
|
||||
@@ -1558,7 +1556,6 @@ class AccountViewModel(
|
||||
name: String,
|
||||
description: String,
|
||||
) {
|
||||
val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId)
|
||||
// Stamp the inviter's outbox relays into the group metadata so that
|
||||
// every member ends up with a single canonical relay set for kind:445
|
||||
// GroupEvents. Without this, both the inviter and the invitee fall
|
||||
@@ -1569,29 +1566,19 @@ class AccountViewModel(
|
||||
val outboxRelayStrings =
|
||||
account.outboxRelays.flow.value
|
||||
.map { it.url }
|
||||
val mergedRelays =
|
||||
(currentMetadata?.relays.orEmpty() + outboxRelayStrings)
|
||||
.distinct()
|
||||
val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId)
|
||||
val updatedMetadata =
|
||||
if (currentMetadata != null) {
|
||||
currentMetadata.copy(
|
||||
name = name,
|
||||
description = description,
|
||||
relays = mergedRelays,
|
||||
)
|
||||
} else {
|
||||
// No MarmotGroupData extension exists yet — this happens for groups
|
||||
// created before initial metadata was persisted, or right after a
|
||||
// fresh `createMarmotGroup`. Build a brand-new extension with the
|
||||
// creator as the sole admin so the GCE proposal carries valid data.
|
||||
com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData(
|
||||
nostrGroupId = nostrGroupId,
|
||||
name = name,
|
||||
description = description,
|
||||
adminPubkeys = listOf(account.signer.pubKey),
|
||||
relays = mergedRelays,
|
||||
)
|
||||
}
|
||||
currentMetadata
|
||||
?.copy(name = name, description = description)
|
||||
?.withMergedRelays(outboxRelayStrings)
|
||||
?: com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
.bootstrap(
|
||||
nostrGroupId = nostrGroupId,
|
||||
creatorPubKey = account.signer.pubKey,
|
||||
outboxRelays = outboxRelayStrings,
|
||||
name = name,
|
||||
description = description,
|
||||
)
|
||||
val relays = marmotGroupRelays(nostrGroupId)
|
||||
account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
|
||||
}
|
||||
|
||||
+1
-1
@@ -48,10 +48,10 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.model.Constants
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
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.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
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.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
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.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
+3
-3
@@ -49,9 +49,9 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.DefaultDMRelayList
|
||||
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -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()
|
||||
}
|
||||
}
|
||||
+114
@@ -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),
|
||||
)
|
||||
+63
@@ -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)
|
||||
+6
-1
@@ -18,10 +18,15 @@
|
||||
* 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.model
|
||||
package com.vitorpamplona.amethyst.commons.defaults
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
|
||||
/**
|
||||
* Relay URLs that Amethyst (and `amy`) seed new accounts with and use as
|
||||
* sensible defaults across the app. Pure data — lives in commons so JVM /
|
||||
* iOS / desktop builds get the same URLs without duplicating the list.
|
||||
*/
|
||||
object Constants {
|
||||
val nos = RelayUrlNormalizer.normalize("wss://nos.lol")
|
||||
val mom = RelayUrlNormalizer.normalize("wss://nostr.mom")
|
||||
+148
@@ -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)
|
||||
}
|
||||
}
|
||||
+73
@@ -154,6 +154,56 @@ class MarmotManager(
|
||||
innerEvent: Event,
|
||||
): OutboundGroupEvent = outboundProcessor.buildGroupEvent(nostrGroupId, innerEvent)
|
||||
|
||||
/**
|
||||
* Build a kind:9 chat-message GroupEvent from plain text. The inner event is
|
||||
* signed with this manager's signer and optionally persisted to the local
|
||||
* decrypted-message log so `loadStoredMessages` reflects our own outbound
|
||||
* immediately (without waiting for relay loopback).
|
||||
*
|
||||
* Platform callers that already maintain their own "own event" cache (i.e.
|
||||
* Amethyst's `LocalCache.justConsumeMyOwnEvent`) should pass `persistOwn = false`.
|
||||
* Headless callers (CLI) should leave it at the default.
|
||||
*
|
||||
* @return the signed kind:445 outer event together with the inner kind:9
|
||||
* event id, so the caller can reference it for replies/reactions.
|
||||
*/
|
||||
suspend fun buildTextMessage(
|
||||
nostrGroupId: HexKey,
|
||||
text: String,
|
||||
persistOwn: Boolean = true,
|
||||
): TextMessageBundle {
|
||||
val template =
|
||||
com.vitorpamplona.quartz.nip01Core.signers
|
||||
.eventTemplate<Event>(kind = 9, description = text)
|
||||
val innerEvent = signer.sign<Event>(template)
|
||||
val outbound = buildGroupMessage(nostrGroupId, innerEvent)
|
||||
if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson())
|
||||
return TextMessageBundle(outbound = outbound, innerEvent = innerEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member to a group by consuming their published [KeyPackageEvent].
|
||||
*
|
||||
* Convenience over [addMember] that handles base64 decoding and lifts the
|
||||
* event id into the WelcomeDelivery. Prefer this overload — both the UI's
|
||||
* `Account.addMarmotGroupMember` and the CLI's `group add` command call it.
|
||||
*/
|
||||
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
|
||||
suspend fun addMember(
|
||||
nostrGroupId: HexKey,
|
||||
keyPackageEvent: KeyPackageEvent,
|
||||
relays: List<NormalizedRelayUrl>,
|
||||
): Pair<OutboundGroupEvent, WelcomeDelivery?> =
|
||||
addMember(
|
||||
nostrGroupId = nostrGroupId,
|
||||
memberPubKey = keyPackageEvent.pubKey,
|
||||
keyPackageBytes =
|
||||
kotlin.io.encoding.Base64
|
||||
.decode(keyPackageEvent.keyPackageBase64()),
|
||||
keyPackageEventId = keyPackageEvent.id,
|
||||
relays = relays,
|
||||
)
|
||||
|
||||
/**
|
||||
* Add a member to a group.
|
||||
* Returns the commit GroupEvent to publish, and the WelcomeDelivery for the new member.
|
||||
@@ -430,6 +480,19 @@ class MarmotManager(
|
||||
*/
|
||||
fun groupEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch
|
||||
|
||||
/**
|
||||
* Resolve the MLS leaf index for a member by Nostr pubkey, or null if that
|
||||
* pubkey isn't currently in the group.
|
||||
*
|
||||
* `removeMember` needs a leaf index; every caller (UI remove-member dialog
|
||||
* and CLI `group remove`) previously did its own pubkey→leaf lookup through
|
||||
* [memberPubkeys]. Centralised here so the scan lives in one place.
|
||||
*/
|
||||
fun leafIndexOf(
|
||||
nostrGroupId: HexKey,
|
||||
pubKey: HexKey,
|
||||
): Int? = memberPubkeys(nostrGroupId).firstOrNull { it.pubkey == pubKey }?.leafIndex
|
||||
|
||||
/**
|
||||
* Get the MIP-04 media exporter secret for a group.
|
||||
* MLS-Exporter("marmot", "encrypted-media", 32)
|
||||
@@ -477,3 +540,13 @@ data class GroupMemberInfo(
|
||||
val leafIndex: Int,
|
||||
val pubkey: HexKey,
|
||||
)
|
||||
|
||||
/**
|
||||
* Result of [MarmotManager.buildTextMessage]: the signed outer kind:445 event
|
||||
* to publish on group relays, plus the inner kind:9 event (for callers that
|
||||
* need the inner id to reference it in replies or reactions).
|
||||
*/
|
||||
data class TextMessageBundle(
|
||||
val outbound: OutboundGroupEvent,
|
||||
val innerEvent: Event,
|
||||
)
|
||||
|
||||
+91
@@ -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()
|
||||
}
|
||||
+39
@@ -111,6 +111,21 @@ data class MarmotGroupData(
|
||||
/** Whether this group has an encrypted image set */
|
||||
fun hasImage(): Boolean = imageHash != null && imageKey != null && imageNonce != null
|
||||
|
||||
/**
|
||||
* Return a copy with [newRelays] unioned into [relays], de-duplicated and order-preserving.
|
||||
*
|
||||
* Every metadata-updating commit produced by the group creator or an admin SHOULD fold
|
||||
* its author's own outbox into [relays] so new invitees learn a single canonical relay
|
||||
* set for kind:445 from the Welcome, even when the inviter's outbox has drifted since
|
||||
* the last commit. Both the UI and the CLI need this same merge rule — hence living
|
||||
* on the data class.
|
||||
*/
|
||||
fun withMergedRelays(newRelays: Collection<String>): MarmotGroupData {
|
||||
if (newRelays.isEmpty()) return this
|
||||
val merged = (relays + newRelays).distinct()
|
||||
return if (merged == relays) this else copy(relays = merged)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode this MarmotGroupData to TLS wire format bytes.
|
||||
* Mirrors the [decodeTls] format.
|
||||
@@ -185,6 +200,30 @@ data class MarmotGroupData(
|
||||
const val EXTENSION_ID: UShort = 0xF2EEu
|
||||
const val EXTENSION_ID_INT: Int = 0xF2EE
|
||||
|
||||
/**
|
||||
* Build a freshly-minted [MarmotGroupData] for a group with no prior metadata —
|
||||
* i.e. right after `MlsGroupManager.createGroup`. Creator becomes the sole admin
|
||||
* and their outbox relays are stamped as the group's relay set.
|
||||
*
|
||||
* Both the Android UI (`AccountViewModel.updateMarmotGroupMetadata`) and the
|
||||
* headless CLI need this same initial shape; keeping the factory here avoids
|
||||
* subtle drift between them.
|
||||
*/
|
||||
fun bootstrap(
|
||||
nostrGroupId: HexKey,
|
||||
creatorPubKey: HexKey,
|
||||
outboxRelays: Collection<String>,
|
||||
name: String = "",
|
||||
description: String = "",
|
||||
): MarmotGroupData =
|
||||
MarmotGroupData(
|
||||
nostrGroupId = nostrGroupId,
|
||||
name = name,
|
||||
description = description,
|
||||
adminPubkeys = listOf(creatorPubKey),
|
||||
relays = outboxRelays.distinct(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Find and decode the MarmotGroupData extension from a list of MLS extensions.
|
||||
* Returns null if no extension with type 0xF2EE is present or if decoding fails.
|
||||
|
||||
+79
@@ -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,
|
||||
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) {
|
||||
println("${time()} $level: [$tag] $message. Throwable: ${throwable.message}")
|
||||
System.err.println("${time()} $level: [$tag] $message. Throwable: ${throwable.message}")
|
||||
} else {
|
||||
println("${time()} $level: [$tag] $message")
|
||||
System.err.println("${time()} $level: [$tag] $message")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,3 +37,4 @@ include ':quartz'
|
||||
include ':commons'
|
||||
include ':ammolite'
|
||||
include ':desktopApp'
|
||||
include ':cli'
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
state/
|
||||
state-headless/
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
# Marmot Interop Test Harness
|
||||
|
||||
Interactive harness that validates Amethyst's Marmot/MLS implementation against
|
||||
**whitenoise-rs** (https://github.com/marmot-protocol/whitenoise-rs), the
|
||||
reference Rust implementation that powers the White Noise Flutter app.
|
||||
Two flavours, same scenarios:
|
||||
|
||||
The harness drives two `wn` daemons (Identities **B** and **C**) from the
|
||||
command line and prompts the human operator to perform the Amethyst-side steps
|
||||
in the mobile UI as **Identity A**. Every test records a pass/fail/skip result
|
||||
into a tab-separated log, and the summary is printed at the end of the run.
|
||||
- **`marmot-interop.sh`** — interactive. Drives B/C via `wn` and **prompts the
|
||||
human** to perform each Amethyst-side step in the mobile UI (Identity A).
|
||||
Use this for final UI verification.
|
||||
- **`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
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -271,6 +271,9 @@ extract_pubkey() {
|
||||
# JSON: single object
|
||||
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
|
||||
# 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)
|
||||
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
|
||||
|
||||
+107
@@ -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
|
||||
Reference in New Issue
Block a user