feat(multi-account): add account switching, storage, and migration to AccountManager

Phase 3: wire multi-account lifecycle into desktop AccountManager.

- Add DesktopAccountStorage field and allAccounts StateFlow
- switchAccount(): loads new account BEFORE cancelling old state
  (prevents unrecoverable partial failure per coroutines review)
- addAccountToStorage / removeAccountFromStorage for CRUD
- loadViewOnlyAccount() for npub-only accounts
- migrateAndLoadAccounts() for legacy file migration on startup
- saveCurrentAccount / saveBunkerAccount now save to encrypted storage
- Updated test mock setup to support SecureKeyStorage key store

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-24 10:15:50 +03:00
parent 197db2361d
commit 9c6502b089
2 changed files with 133 additions and 0 deletions
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.domain.nip46.NostrConnectLoginUseCase
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException
import com.vitorpamplona.amethyst.commons.model.account.AccountInfo
import com.vitorpamplona.amethyst.commons.model.account.SignerType import com.vitorpamplona.amethyst.commons.model.account.SignerType
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -50,6 +51,9 @@ import com.vitorpamplona.quartz.nip19Bech32.toNsec
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -92,6 +96,11 @@ class AccountManager internal constructor(
return AccountManager(storage) return AccountManager(storage)
} }
fun createWithStorage(
secureStorage: SecureKeyStorage,
homeDir: File = File(System.getProperty("user.home")),
): AccountManager = AccountManager(secureStorage, homeDir)
internal const val HEARTBEAT_INTERVAL_MS = 60_000L internal const val HEARTBEAT_INTERVAL_MS = 60_000L
internal const val MAX_CONSECUTIVE_FAILURES = 3 internal const val MAX_CONSECUTIVE_FAILURES = 3
internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral" internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral"
@@ -103,6 +112,13 @@ class AccountManager internal constructor(
File(homeDir, ".amethyst") File(homeDir, ".amethyst")
} }
val accountStorage: DesktopAccountStorage by lazy {
DesktopAccountStorage(secureStorage, homeDir)
}
private val _allAccounts = MutableStateFlow<ImmutableList<AccountInfo>>(persistentListOf())
val allAccounts: StateFlow<ImmutableList<AccountInfo>> = _allAccounts.asStateFlow()
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut) private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
val accountState: StateFlow<AccountState> = _accountState.asStateFlow() val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
@@ -421,6 +437,11 @@ class AccountManager internal constructor(
saveLastNpub(npub) saveLastNpub(npub)
secureStorage.savePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS, ephemeralPrivKeyHex) secureStorage.savePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS, ephemeralPrivKeyHex)
saveBunkerUri(bunkerUri) saveBunkerUri(bunkerUri)
// Also save to multi-account storage
val info = AccountInfo(npub = npub, signerType = SignerType.Remote(bunkerUri))
addAccountToStorage(info)
accountStorage.setCurrentAccount(npub)
} }
fun hasBunkerAccount(): Boolean = getBunkerFile().exists() fun hasBunkerAccount(): Boolean = getBunkerFile().exists()
@@ -448,6 +469,12 @@ class AccountManager internal constructor(
secureStorage.savePrivateKey(current.npub, privKeyHex) secureStorage.savePrivateKey(current.npub, privKeyHex)
saveLastNpub(current.npub) saveLastNpub(current.npub)
// Also save to multi-account storage
val info = AccountInfo(npub = current.npub, signerType = current.signerType)
addAccountToStorage(info)
accountStorage.setCurrentAccount(current.npub)
Result.success(Unit) Result.success(Unit)
} catch (e: SecureStorageException) { } catch (e: SecureStorageException) {
Result.failure(e) Result.failure(e)
@@ -597,6 +624,97 @@ class AccountManager internal constructor(
heartbeatJob = null heartbeatJob = null
} }
// --- Multi-account management ---
suspend fun refreshAccountList() {
_allAccounts.value = accountStorage.loadAccounts().toImmutableList()
}
suspend fun addAccountToStorage(info: AccountInfo) {
accountStorage.saveAccount(info)
refreshAccountList()
}
suspend fun removeAccountFromStorage(npub: String) {
val current = currentAccount()
accountStorage.deleteAccount(npub)
// Clean up keys
try {
secureStorage.deletePrivateKey(npub)
} catch (_: SecureStorageException) {
}
refreshAccountList()
// If we removed the active account, load the next one or log out
if (current?.npub == npub) {
val next = accountStorage.currentAccount()
if (next != null) {
loadSavedAccount()
} else {
logout(deleteKey = false)
}
}
}
/**
* Switch to a different account.
* Critical: loads new account BEFORE cancelling old state to prevent
* unrecoverable partial failure.
*/
suspend fun switchAccount(targetNpub: String): Result<AccountState.LoggedIn> {
val accounts = accountStorage.loadAccounts()
val target =
accounts.find { it.npub == targetNpub }
?: return Result.failure(Exception("Account not found: $targetNpub"))
// Phase 1: load + validate new account BEFORE touching current state
val sType = target.signerType
val newState =
when (sType) {
is SignerType.Internal -> loadInternalAccount(target.npub)
is SignerType.Remote -> loadBunkerAccount(sType.bunkerUri, target.npub)
is SignerType.ViewOnly -> loadViewOnlyAccount(target.npub)
}
if (newState.isFailure) return newState
// Phase 2: transition succeeded — update storage and cleanup
accountStorage.setCurrentAccount(targetNpub)
// Reload NWC for the new account
loadNwcConnection()
return newState
}
private fun loadViewOnlyAccount(npub: String): Result<AccountState.LoggedIn> {
val pubKeyHex =
decodePublicKeyAsHexOrNull(npub)
?: return Result.failure(Exception("Invalid npub: $npub"))
val keyPair = KeyPair(pubKey = pubKeyHex.hexToByteArray())
val signer = NostrSignerInternal(keyPair)
val state =
AccountState.LoggedIn(
signer = signer,
pubKeyHex = pubKeyHex,
npub = npub,
nsec = null,
isReadOnly = true,
signerType = SignerType.ViewOnly,
)
_accountState.value = state
return Result.success(state)
}
suspend fun migrateAndLoadAccounts() {
accountStorage.migrateFromLegacyFiles(this)
refreshAccountList()
}
// --- Accessors --- // --- Accessors ---
fun isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn fun isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn
@@ -26,8 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip19Bech32.toNsec import com.vitorpamplona.quartz.nip19Bech32.toNsec
import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import java.io.File import java.io.File
import kotlin.io.path.createTempDirectory import kotlin.io.path.createTempDirectory
@@ -45,9 +47,22 @@ class AccountManagerKeyLoginTest {
private lateinit var tempDir: File private lateinit var tempDir: File
private lateinit var manager: AccountManager private lateinit var manager: AccountManager
private val keyStore = mutableMapOf<String, String>()
@BeforeTest @BeforeTest
fun setup() { fun setup() {
keyStore.clear()
storage = mockk(relaxed = true) storage = mockk(relaxed = true)
// Wire up key store so DesktopAccountStorage encryption works
val keySlot = slot<String>()
val valueSlot = slot<String>()
coEvery { storage.getPrivateKey(capture(keySlot)) } answers { keyStore[keySlot.captured] }
coEvery { storage.savePrivateKey(capture(keySlot), capture(valueSlot)) } answers {
keyStore[keySlot.captured] = valueSlot.captured
}
coEvery { storage.hasPrivateKey(any()) } answers { keyStore.containsKey(firstArg()) }
tempDir = createTempDirectory("acctmgr-key-test").toFile() tempDir = createTempDirectory("acctmgr-key-test").toFile()
manager = AccountManager(storage, tempDir) manager = AccountManager(storage, tempDir)
} }