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.keystorage.SecureKeyStorage
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.desktop.network.DesktopHttpClient
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.nip47WalletConnect.Nip47WalletConnect
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.Job
import kotlinx.coroutines.delay
@@ -92,6 +96,11 @@ class AccountManager internal constructor(
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 MAX_CONSECUTIVE_FAILURES = 3
internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral"
@@ -103,6 +112,13 @@ class AccountManager internal constructor(
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)
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
@@ -421,6 +437,11 @@ class AccountManager internal constructor(
saveLastNpub(npub)
secureStorage.savePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS, ephemeralPrivKeyHex)
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()
@@ -448,6 +469,12 @@ class AccountManager internal constructor(
secureStorage.savePrivateKey(current.npub, privKeyHex)
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)
} catch (e: SecureStorageException) {
Result.failure(e)
@@ -597,6 +624,97 @@ class AccountManager internal constructor(
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 ---
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.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip19Bech32.toNsec
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import java.io.File
import kotlin.io.path.createTempDirectory
@@ -45,9 +47,22 @@ class AccountManagerKeyLoginTest {
private lateinit var tempDir: File
private lateinit var manager: AccountManager
private val keyStore = mutableMapOf<String, String>()
@BeforeTest
fun setup() {
keyStore.clear()
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()
manager = AccountManager(storage, tempDir)
}