From 559c69660f3a7cbc0c8633208ac4ad86dd4a032b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 21:41:47 +0000 Subject: [PATCH] feat(cli,commons): amy login + amy create, share defaults with Amethyst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the "defaults a new Amethyst account gets seeded with" into commons so the UI and amy agree byte-for-byte on what a fresh account looks like. commons additions: - commons/defaults/Constants.kt — relay URL constants (moved from amethyst/) - commons/defaults/AmethystDefaults.kt — DefaultChannels, DefaultNIP65RelaySet, DefaultNIP65List, DefaultGlobalRelays, DefaultDMRelayList, DefaultSearchRelayList, DefaultIndexerRelayList (moved from amethyst/model/AccountSettings.kt) - commons/account/AccountBootstrapEvents.kt — data class + bootstrapAccountEvents(signer, name) that builds the nine events a new Amethyst account publishes: kind:0, 3, 10002, 10050, 10051, 10099, 50, 51, + channel list. One source of truth. Retrofits: - AccountSessionManager.createNewAccount now delegates event construction to the commons helper; it still packages them into AccountSettings for the Android storage path. - DefaultSignerPermissions stays in AccountSettings.kt — it uses Android NIP-55 Permission/CommandType types. - 19 import updates across amethyst/ to point at the new commons paths. New CLI verbs: - amy create [--name NAME]: mint a keypair, write identity.json, seed relays.json with the Amethyst defaults, publish all nine bootstrap events to DefaultNIP65RelaySet via NostrClient.publishAndConfirmDetailed. - amy login KEY [--password X]: accept any identifier form Amethyst's login screen accepts — nsec1, ncryptsec (+ --password, NIP-49), BIP-39 mnemonic (NIP-06), npub1, nprofile1, 64-hex pubkey (default) or 64-hex privkey (with --private), NIP-05 (name@domain.tld, HTTP lookup). Read-only keys land with privKeyHex=null; Identity now carries that cleanly. https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq --- .../relays/eventsync/EventSyncTest.kt | 2 +- .../amethyst/model/AccountSettings.kt | 28 ---- .../indexerRelays/IndexerRelayListState.kt | 2 +- .../searchRelays/SearchRelayListState.kt | 2 +- .../nip65RelayList/Nip65RelayListState.kt | 2 +- .../model/topNavFeeds/OutboxRelayLoader.kt | 2 +- .../follows/FilterFindFollowMetadataForKey.kt | 6 +- .../user/watchers/UserWatcherSubAssembler.kt | 2 +- .../SearchUserWatcherSubAssembler.kt | 2 +- .../amethyst/ui/broadcast/BroadcastBanner.kt | 2 +- .../ui/broadcast/BroadcastDetailsSheet.kt | 2 +- .../ui/screen/AccountSessionManager.kt | 51 +++---- .../feed/types/RenderCreateChannelNote.kt | 2 +- .../FilterFollowingEphemeralChats.kt | 2 +- .../datasource/FilterFollowingPublicChats.kt | 2 +- .../FilterLastMessageFollowingPublicChats.kt | 2 +- .../loggedIn/relays/AllRelayListScreen.kt | 6 +- .../relays/dm/AddDMRelayListDialog.kt | 2 +- .../search/AddSearchRelayListDialog.kt | 2 +- .../com/vitorpamplona/amethyst/cli/Config.kt | 48 ++++--- .../com/vitorpamplona/amethyst/cli/Main.kt | 14 +- .../amethyst/cli/commands/Commands.kt | 10 ++ .../amethyst/cli/commands/CreateCommand.kt | 114 ++++++++++++++++ .../amethyst/cli/commands/LoginCommand.kt | 125 ++++++++++++++++++ .../commons/account/AccountBootstrapEvents.kt | 114 ++++++++++++++++ .../commons/defaults/AmethystDefaults.kt | 63 +++++++++ .../amethyst/commons/defaults}/Constants.kt | 7 +- 27 files changed, 511 insertions(+), 105 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/account/AccountBootstrapEvents.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/AmethystDefaults.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/model => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults}/Constants.kt (90%) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt index f892f6740..bb4feac93 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 51c12ed88..061faf3c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt index 4f739918e..40f30c1db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt index 4fbba2ef2..3a227ef30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt index 6add6cd3e..fec41fa05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip65RelayList/Nip65RelayListState.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt index 558a142b1..0a1544f5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt index fb3457ef6..2d5a301d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt index 698c93b76..a925ca08f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchUserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchUserWatcherSubAssembler.kt index 1fa7801e6..84e06563e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchUserWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchUserWatcherSubAssembler.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt index bd221606f..a5b42e108 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt index 34082f631..7d395c5b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastDetailsSheet.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index cfbdb5cc3..ee43b4c4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -23,29 +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.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 @@ -57,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 @@ -303,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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt index c798e382c..17f41c99f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt index 083ae5411..20d237195 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingEphemeralChats.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt index 149a8d481..c16e28793 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterFollowingPublicChats.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt index 26f82e25d..5e898c764 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterLastMessageFollowingPublicChats.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index 19f103fad..50604871b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt index c99dda469..447b00272 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/AddDMRelayListDialog.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt index 157d49378..478ed05d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt @@ -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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index 03b1dab0e..5469f615c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -31,20 +31,36 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNsec import java.io.File -/** Persisted identity (hex keys + cached bech32 forms for convenience). */ +/** + * 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 privKeyHex: String?, val pubKeyHex: String, - val nsec: String, + val nsec: String?, val npub: String, ) { - fun keyPair(): KeyPair = KeyPair(privKey = privKeyHex.hexToByteArray(), pubKey = pubKeyHex.hexToByteArray()) + 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 { - val kp = KeyPair() - val priv = kp.privKey!! - val pub = kp.pubKey + 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(), @@ -53,16 +69,14 @@ data class Identity( ) } - fun fromNsec(nsec: String): Identity { - val priv = nsec.bechToBytes() - val kp = KeyPair(privKey = priv) - return Identity( - privKeyHex = priv.toHexKey(), - pubKeyHex = kp.pubKey.toHexKey(), - nsec = priv.toNsec(), - npub = kp.pubKey.toNpub(), + /** Read-only identity (no private key). */ + fun fromPublicKeyHex(pubHex: String): Identity = + Identity( + privKeyHex = null, + pubKeyHex = pubHex.lowercase(), + nsec = null, + npub = pubHex.hexToByteArray().toNpub(), ) - } } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index b34978a0c..d9cf303bb 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -107,6 +107,14 @@ private suspend fun dispatch(argv: Array): Int { Commands.init(dataDir, Args(tail)) } + "create" -> { + Commands.create(dataDir, tail) + } + + "login" -> { + Commands.login(dataDir, tail) + } + "whoami" -> { Commands.whoami(dataDir) } @@ -171,8 +179,10 @@ private fun printUsage() { | amy [--data-dir PATH] [args...] | |Identity: - | init [--nsec NSEC] create or import identity - | whoami print current 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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 042b4f387..4b287ce96 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -34,6 +34,16 @@ object Commands { args: Args, ): Int = InitCommands.init(dataDir, args) + suspend fun create( + dataDir: DataDir, + tail: Array, + ): Int = CreateCommand.run(dataDir, tail) + + suspend fun login( + dataDir: DataDir, + tail: Array, + ): Int = LoginCommand.run(dataDir, tail) + suspend fun whoami(dataDir: DataDir): Int = InitCommands.whoami(dataDir) suspend fun relay( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt new file mode 100644 index 000000000..b128d8999 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt @@ -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, + ): 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>() + 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 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt new file mode 100644 index 000000000..3cbd1f88b --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt @@ -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, + ): Int { + if (rest.isEmpty()) { + return Json.error("bad_args", "login [--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 }) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/account/AccountBootstrapEvents.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/account/AccountBootstrapEvents.kt new file mode 100644 index 000000000..dbfb17605 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/account/AccountBootstrapEvents.kt @@ -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 = + 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), + ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/AmethystDefaults.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/AmethystDefaults.kt new file mode 100644 index 000000000..208ee9aeb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/AmethystDefaults.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt index 754080858..089e77541 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt @@ -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")