commit b6b9795b18522710de9cfdada4524023a832c2d8 parent a9fd63975b42e7774283e180abdd32a89af8b4d8 Author: triesap <tyson@radroots.org> Date: Mon, 10 Aug 2026 02:19:55 +0000 identity: separate Nostr identity from signer binding - Rename live domain, runtime, FFI, Kotlin, and UI concepts from accounts to identities - Model signer binding independently with the implemented local-keyring binding only - Preserve V1 through V10 migrations and historical SQLite table and column coordinates - Refresh the FFI v4 contract and cover mismatch, recovery, restart, and identity regressions Diffstat:
65 files changed, 5602 insertions(+), 5462 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/accounts/ui/AccountsUiModel.kt b/app/desktop/src/main/kotlin/org/harvestcircle/accounts/ui/AccountsUiModel.kt @@ -1,168 +0,0 @@ -package org.harvestcircle.accounts.ui - -import org.harvestcircle.application.AccountEntryMode -import org.harvestcircle.application.HarvestCircleRoute -import org.harvestcircle.application.HarvestCircleStoreState -import org.harvestcircle.application.RemovalImpactState -import org.harvestcircle.application.RemovalStatus -import org.harvestcircle.ffi.AccountDto -import org.harvestcircle.ffi.ActiveAccountDto -import org.harvestcircle.ffi.ProfileLoadStateDto -import org.harvestcircle.ffi.RelayConnectionStateDto -import org.harvestcircle.ffi.SessionStateDto -import org.harvestcircle.ffi.WireErrorCode -import org.harvestcircle.ffi.WireRecoveryAction - -data class AccountUiModel( - val publicKeyHex: String, - val npub: String, - val shortNpub: String, - val label: String, - val keyAvailability: String, - val selected: Boolean, - val active: Boolean, -) - -data class ProfileUiModel( - val name: String, - val displayName: String, - val nip05: String, - val about: String, - val picture: String, -) - -data class ActiveAccountUiModel( - val account: AccountUiModel, - val heading: String, - val relayState: String, - val profileState: String, - val profile: ProfileUiModel, -) - -class GeneratedKeyBackupUiModel( - val npub: String, - val nsec: String, -) { - override fun toString(): String = "GeneratedKeyBackupUiModel(npub=$npub, nsec=[REDACTED])" -} - -data class HarvestCircleUiModel( - val route: HarvestCircleRoute, - val accounts: List<AccountUiModel>, - val activeAccount: ActiveAccountUiModel?, - val configuredRelays: List<String>, - val importDraft: String, - val generatedKeyBackup: GeneratedKeyBackupUiModel?, - val pendingRemovalPublicKeyHex: String?, - val removalImpact: RemovalImpactState?, - val removalStatus: RemovalStatus, - val lastRemovedPublicKeyHex: String?, - val accountChooserVisible: Boolean, - val accountEntryMode: AccountEntryMode, - val session: SessionStateDto, - val busy: Boolean, - val problem: String?, - val importGuidance: String?, - val recoveryAction: WireRecoveryAction, -) - -fun HarvestCircleStoreState.toUiModel(): HarvestCircleUiModel { - val selectedPublicKeyHex = snapshot.selectedPublicKeyHex - val activePublicKeyHex = snapshot.activeAccount?.account?.publicKeyHex - val accounts = - snapshot.accounts.map { - it.toUiModel( - selected = it.publicKeyHex == selectedPublicKeyHex, - active = it.publicKeyHex == activePublicKeyHex, - ) - } - return HarvestCircleUiModel( - route = route, - accounts = accounts, - activeAccount = snapshot.activeAccount?.toUiModel(selectedPublicKeyHex), - configuredRelays = snapshot.configuredRelays, - importDraft = importDraft, - generatedKeyBackup = - generatedKeyBackup?.let { - GeneratedKeyBackupUiModel(npub = it.npub, nsec = it.revealNsec()) - }, - pendingRemovalPublicKeyHex = pendingRemovalPublicKeyHex, - removalImpact = removalImpact, - removalStatus = removalStatus, - lastRemovedPublicKeyHex = lastRemovedPublicKeyHex, - accountChooserVisible = accountChooserVisible, - accountEntryMode = accountEntryMode, - session = snapshot.session, - busy = busy, - problem = - problem - ?: snapshot.recoverableProblem?.message - ?: snapshot.sessionError?.message - ?: snapshot.lifecycleError?.message, - importGuidance = importGuidance(lastFailureCode, recoveryAction), - recoveryAction = recoveryAction, - ) -} - -private fun importGuidance( - code: WireErrorCode?, - recoveryAction: WireRecoveryAction, -): String? = - when { - code == WireErrorCode.INVALID_SECRET_KEY -> "Enter a valid nsec or 64-character hexadecimal secret key." - code == WireErrorCode.ACCOUNT_ALREADY_EXISTS -> "This Nostr account is already saved." - code == WireErrorCode.CREDENTIAL_MISSING || recoveryAction == WireRecoveryAction.REPAIR_CREDENTIAL -> - "This saved account is missing its local credential. Re-enter its secret key to repair it." - else -> null - } - -private const val SHORT_NPUB_MAX_LENGTH = 24 -private const val SHORT_NPUB_PREFIX_LENGTH = 14 -private const val SHORT_NPUB_SUFFIX_LENGTH = 8 - -fun shortenNpub(npub: String): String = - if (npub.length <= SHORT_NPUB_MAX_LENGTH) { - npub - } else { - "${npub.take(SHORT_NPUB_PREFIX_LENGTH)}…${npub.takeLast(SHORT_NPUB_SUFFIX_LENGTH)}" - } - -private fun AccountDto.toUiModel( - selected: Boolean, - active: Boolean = false, -) = AccountUiModel( - publicKeyHex = publicKeyHex, - npub = npub, - shortNpub = shortenNpub(npub), - label = displayLabel.ifBlank { shortenNpub(npub) }, - keyAvailability = keyAvailability.name.lowercase().replace('_', ' '), - selected = selected, - active = active, -) - -private fun ActiveAccountDto.toUiModel(selectedPublicKeyHex: String?) = - ActiveAccountUiModel( - account = - account.toUiModel( - selected = account.publicKeyHex == selectedPublicKeyHex, - active = true, - ), - heading = - profile?.displayName?.takeIf(String::isNotBlank) - ?: profile?.name?.takeIf(String::isNotBlank) - ?: account.displayLabel.ifBlank { shortenNpub(account.npub) }, - relayState = relayState.toDisplayText(), - profileState = profileState.toDisplayText(), - profile = - ProfileUiModel( - name = profile?.name.orEmpty(), - displayName = profile?.displayName.orEmpty(), - nip05 = profile?.nip05.orEmpty(), - about = profile?.about.orEmpty(), - picture = profile?.picture.orEmpty(), - ), - ) - -private fun RelayConnectionStateDto.toDisplayText(): String = name.lowercase().replace('_', ' ') - -private fun ProfileLoadStateDto.toDisplayText(): String = name.lowercase().replace('_', ' ') diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/accounts/ui/HarvestCircleScreen.kt b/app/desktop/src/main/kotlin/org/harvestcircle/accounts/ui/HarvestCircleScreen.kt @@ -1,495 +0,0 @@ -package org.harvestcircle.accounts.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.password -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import org.harvestcircle.application.AccountEntryMode -import org.harvestcircle.application.HarvestCircleRoute - -private val WindowBackgroundColor = Color(0xFFF5F5F2) -private val ButtonBackgroundColor = Color(0xFFE7E7E2) -private val InputBackgroundColor = Color(0xFFFEFDF8) - -data class HarvestCircleUiActions( - val chooseCreateAccount: () -> Unit = {}, - val chooseImportAccount: () -> Unit = {}, - val cancelAccountEntry: () -> Unit = {}, - val editImportDraft: (String) -> Unit = {}, - val generateAccount: () -> Unit = {}, - val importSecretKey: () -> Unit = {}, - val copyText: (String) -> Unit = {}, - val acknowledgeGeneratedKeyBackup: () -> Unit = {}, - val cancelGeneratedKeyBackup: () -> Unit = {}, - val selectAccount: (String) -> Unit = {}, - val activateAccount: (String) -> Unit = {}, - val requestAccountRemoval: (String) -> Unit = {}, - val cancelAccountRemoval: () -> Unit = {}, - val confirmAccountRemoval: () -> Unit = {}, - val refreshActiveProfile: () -> Unit = {}, - val retryLastCommand: () -> Unit = {}, - val signOut: () -> Unit = {}, - val showAccountChooser: () -> Unit = {}, - val hideAccountChooser: () -> Unit = {}, -) - -@Composable -fun StartupFailureScreen(problem: String) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("startup-failure"), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - BasicText("HarvestCircle") - BasicText(problem, Modifier.testTag("startup-problem")) - } -} - -@Composable -fun HarvestCircleScreen( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, -) { - model.generatedKeyBackup?.let { backup -> - GeneratedKeyRecoveryScreen(backup, actions) - return - } - when (model.route) { - HarvestCircleRoute.OPENING -> LifecycleScreen("Opening local account store", "lifecycle-opening") - HarvestCircleRoute.CHECKING_COMPATIBILITY -> - LifecycleScreen( - "Checking native compatibility", - "lifecycle-compatibility", - ) - HarvestCircleRoute.ACQUIRING_OWNERSHIP -> - LifecycleScreen( - "Acquiring local account store", - "lifecycle-ownership", - ) - HarvestCircleRoute.MIGRATING -> - LifecycleScreen( - "Updating local account store", - "lifecycle-migrating", - ) - HarvestCircleRoute.RECOVERING -> - LifecycleScreen( - "Recovering local account state", - "lifecycle-recovering", - ) - HarvestCircleRoute.SHUTTING_DOWN -> LifecycleScreen("Shutting down", "lifecycle-shutting-down") - HarvestCircleRoute.CLOSED -> LifecycleScreen("Closed", "lifecycle-closed") - HarvestCircleRoute.BLOCKED -> - LifecycleScreen( - model.problem ?: "Local account access is blocked.", - "lifecycle-blocked", - ) - HarvestCircleRoute.FATAL -> - LifecycleScreen( - model.problem ?: "The application could not continue.", - "lifecycle-fatal", - ) - HarvestCircleRoute.DEGRADED -> InactiveAccountsScreen(model, actions, degraded = true) - HarvestCircleRoute.ACTIVE_ACCOUNT -> { - if (model.activeAccount != null && !model.accountChooserVisible) { - ActiveAccountHome(model, model.activeAccount, actions) - } else { - InactiveAccountsScreen(model, actions) - } - } - HarvestCircleRoute.ACCOUNTS -> InactiveAccountsScreen(model, actions) - } -} - -@Composable -private fun LifecycleScreen( - message: String, - testTag: String, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .testTag(testTag), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - BasicText("HarvestCircle") - BasicText(message) - } -} - -@Composable -private fun ActiveAccountHome( - model: HarvestCircleUiModel, - active: ActiveAccountUiModel, - actions: HarvestCircleUiActions, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("home-screen"), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - BasicText("HarvestCircle") - BasicText(active.heading) - BasicText(active.account.npub, Modifier.testTag("active-npub")) - BasicText(active.account.publicKeyHex, Modifier.testTag("active-pubkey-hex")) - BasicText("Name: ${active.profile.name}", Modifier.testTag("active-profile-name")) - BasicText("Display name: ${active.profile.displayName}") - BasicText("NIP-05 (unverified): ${active.profile.nip05}") - BasicText("About: ${active.profile.about}", Modifier.testTag("active-profile-about")) - BasicText("Picture: ${active.profile.picture}") - BasicText("Relay: ${active.relayState}", Modifier.testTag("relay-state")) - BasicText("Profile: ${active.profileState}", Modifier.testTag("profile-state")) - BasicText("Configured relays") - if (model.configuredRelays.isEmpty()) { - BasicText("None") - } else { - model.configuredRelays.forEach { relay -> BasicText(relay) } - } - TextAction( - text = "Switch account", - testTag = "switch-account", - contentDescription = "Choose another saved account", - enabled = !model.busy, - onClick = actions.showAccountChooser, - ) - TextAction( - text = "Refresh metadata", - testTag = "refresh-profile", - contentDescription = "Refresh active Nostr profile metadata", - enabled = !model.busy, - onClick = actions.refreshActiveProfile, - ) - TextAction( - text = "Sign out", - testTag = "sign-out", - contentDescription = "Sign out of the active account", - enabled = !model.busy, - onClick = actions.signOut, - ) - model.problem?.let { BasicText(it, Modifier.testTag("home-problem")) } - RecoveryAction(model, actions) - } -} - -@Composable -private fun InactiveAccountsScreen( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, - degraded: Boolean = false, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .testTag("accounts-screen"), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - BasicText("HarvestCircle") - BasicText("Accounts") - if (degraded) { - BasicText(model.problem ?: "Nostr relay access is unavailable. Local accounts remain available.") - } - - if (model.activeAccount != null) { - BasicText("Choose an account to activate. The current account remains active until replacement succeeds.") - TextAction( - text = "Back to active account", - testTag = "return-home", - contentDescription = "Return to the active account", - onClick = actions.hideAccountChooser, - ) - } - - AccountEntry(model, actions) - - model.problem?.let { - BasicText(it, Modifier.testTag("accounts-problem")) - } - RecoveryAction(model, actions) - - if (model.accounts.isEmpty()) { - BasicText("No saved accounts.", Modifier.testTag("accounts-empty")) - } else { - SavedAccountList(model, actions) - } - } -} - -@Composable -private fun RecoveryAction( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, -) { - if (model.recoveryAction == org.harvestcircle.ffi.WireRecoveryAction.RETRY) { - TextAction( - text = "Retry", - testTag = "retry-last-command", - contentDescription = "Retry the last failed action", - enabled = !model.busy, - onClick = actions.retryLastCommand, - ) - } -} - -@Composable -private fun AccountEntry( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, -) { - when (model.accountEntryMode) { - AccountEntryMode.CHOICE -> { - TextAction( - text = "Create account", - testTag = "choose-create-account", - contentDescription = "Create a new Nostr account", - enabled = !model.busy, - onClick = actions.chooseCreateAccount, - ) - TextAction( - text = "Import key", - testTag = "choose-import-account", - contentDescription = "Import an existing Nostr secret key", - enabled = !model.busy, - onClick = actions.chooseImportAccount, - ) - } - AccountEntryMode.CREATE -> { - TextAction( - text = "Back", - testTag = "cancel-account-entry", - contentDescription = "Return to account choices", - enabled = !model.busy, - onClick = actions.cancelAccountEntry, - ) - TextAction( - text = "Generate new key", - testTag = "generate-key", - contentDescription = "Generate a new Nostr key", - enabled = !model.busy && model.generatedKeyBackup == null, - onClick = actions.generateAccount, - ) - } - AccountEntryMode.IMPORT -> { - val importFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { importFocusRequester.requestFocus() } - TextAction( - text = "Back", - testTag = "cancel-account-entry", - contentDescription = "Return to account choices", - enabled = !model.busy, - onClick = actions.cancelAccountEntry, - ) - BasicTextField( - value = model.importDraft, - onValueChange = actions.editImportDraft, - enabled = !model.busy, - visualTransformation = PasswordVisualTransformation(), - modifier = - Modifier - .fillMaxWidth() - .semantics { - contentDescription = "Nostr secret key" - password() - }.focusRequester(importFocusRequester) - .testTag("import-nsec-input") - .background(InputBackgroundColor) - .padding(8.dp), - decorationBox = { innerTextField -> - if (model.importDraft.isEmpty()) BasicText("nsec or secret-key hex") - innerTextField() - }, - ) - model.importGuidance?.let { guidance -> - BasicText(guidance, Modifier.testTag("import-guidance")) - } - TextAction( - text = "Add existing key", - testTag = "import-key", - contentDescription = "Import an existing Nostr secret key", - enabled = !model.busy && model.importDraft.isNotBlank(), - onClick = actions.importSecretKey, - ) - } - } -} - -@Composable -private fun ColumnScope.SavedAccountList( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, -) { - LazyColumn( - modifier = - Modifier - .fillMaxWidth() - .weight(1f) - .testTag("saved-account-list"), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(model.accounts, key = AccountUiModel::publicKeyHex) { account -> - Column( - modifier = - Modifier - .fillMaxWidth() - .semantics { selected = account.selected } - .testTag("account-row:${account.publicKeyHex}") - .background(InputBackgroundColor) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - BasicText(account.label) - BasicText(account.npub) - BasicText("Key: ${account.keyAvailability}") - if (account.selected) BasicText("Selected") - if (account.active) BasicText("Active") - TextAction( - text = if (account.selected) "Selected account" else "Select", - testTag = "select-account:${account.publicKeyHex}", - contentDescription = "Select ${account.label}", - enabled = !model.busy && !account.selected, - onClick = { actions.selectAccount(account.publicKeyHex) }, - ) - TextAction( - text = if (account.active) "Active account" else "Activate", - testTag = "activate-account:${account.publicKeyHex}", - contentDescription = "Activate ${account.label}", - enabled = !model.busy && !account.active, - onClick = { actions.activateAccount(account.publicKeyHex) }, - ) - TextAction( - text = "Remove", - testTag = "remove-account:${account.publicKeyHex}", - contentDescription = "Remove ${account.label}", - enabled = !model.busy, - onClick = { actions.requestAccountRemoval(account.publicKeyHex) }, - ) - if (model.pendingRemovalPublicKeyHex == account.publicKeyHex) { - BasicText("Remove this saved account?") - if (model.removalImpact?.deletesLocalCredential == true) { - BasicText("Its local credential will be deleted from the operating-system keyring.") - } - if (model.removalImpact?.signsOut == true) { - BasicText("The active session will be signed out before removal.") - } - TextAction( - text = "Cancel", - testTag = "remove-cancel", - contentDescription = "Cancel account removal", - onClick = actions.cancelAccountRemoval, - ) - TextAction( - text = "Confirm removal", - testTag = "remove-confirm", - contentDescription = "Confirm account removal", - enabled = !model.busy, - onClick = actions.confirmAccountRemoval, - ) - } - } - } - } -} - -@Composable -private fun GeneratedKeyRecoveryScreen( - backup: GeneratedKeyBackupUiModel, - actions: HarvestCircleUiActions, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("generated-key-backup"), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - BasicText("Save this key") - BasicText("Losing this secret key means losing access to the account.") - BasicText(backup.npub) - BasicText(backup.nsec, Modifier.testTag("generated-nsec")) - TextAction( - text = "Copy", - testTag = "copy-generated-key", - contentDescription = "Copy generated Nostr secret key", - onClick = { actions.copyText(backup.nsec) }, - ) - TextAction( - text = "Cancel", - testTag = "cancel-generated-key", - contentDescription = "Cancel generated account", - onClick = actions.cancelGeneratedKeyBackup, - ) - TextAction( - text = "I have saved this key", - testTag = "acknowledge-key-backup", - contentDescription = "Confirm generated key backup", - onClick = actions.acknowledgeGeneratedKeyBackup, - ) - } -} - -@Composable -internal fun TextAction( - text: String, - testTag: String, - contentDescription: String, - enabled: Boolean = true, - onClick: () -> Unit, -) { - BasicText( - text = text, - modifier = - Modifier - .semantics { - role = Role.Button - this.contentDescription = contentDescription - if (!enabled) disabled() - }.testTag(testTag) - .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) - .background(ButtonBackgroundColor) - .padding(8.dp), - ) -} diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleAppStore.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleAppStore.kt @@ -20,8 +20,8 @@ enum class HarvestCircleRoute { ACQUIRING_OWNERSHIP, MIGRATING, RECOVERING, - ACCOUNTS, - ACTIVE_ACCOUNT, + IDENTITYS, + ACTIVE_IDENTITY, DEGRADED, BLOCKED, SHUTTING_DOWN, @@ -39,7 +39,7 @@ enum class CommandStatus { FAILED_TERMINAL, } -enum class AccountEntryMode { +enum class IdentityEntryMode { CHOICE, CREATE, IMPORT, @@ -69,8 +69,8 @@ data class HarvestCircleStoreState( val removalImpact: RemovalImpactState? = null, val removalStatus: RemovalStatus = RemovalStatus.NONE, val lastRemovedPublicKeyHex: String? = null, - val accountChooserVisible: Boolean = false, - val accountEntryMode: AccountEntryMode = AccountEntryMode.CHOICE, + val identityChooserVisible: Boolean = false, + val identityEntryMode: IdentityEntryMode = IdentityEntryMode.CHOICE, val busy: Boolean = false, val commandStatus: CommandStatus = CommandStatus.IDLE, val lastCommandRequestId: String? = null, @@ -123,29 +123,29 @@ class HarvestCircleAppStore( ) } - fun chooseCreateAccount() { - mutableState.value = mutableState.value.copy(accountEntryMode = AccountEntryMode.CREATE, problem = null) + fun chooseCreateIdentity() { + mutableState.value = mutableState.value.copy(identityEntryMode = IdentityEntryMode.CREATE, problem = null) } - fun chooseImportAccount() { - mutableState.value = mutableState.value.copy(accountEntryMode = AccountEntryMode.IMPORT, problem = null) + fun chooseImportIdentity() { + mutableState.value = mutableState.value.copy(identityEntryMode = IdentityEntryMode.IMPORT, problem = null) } - fun cancelAccountEntry() { + fun cancelIdentityEntry() { mutableState.value = mutableState.value.copy( - accountEntryMode = AccountEntryMode.CHOICE, + identityEntryMode = IdentityEntryMode.CHOICE, importDraft = "", problem = null, ) } - fun generateAccount() { + fun generateIdentity() { launchCommand { - val recovery = gateway.beginGeneratedAccount() + val recovery = gateway.beginGeneratedIdentity() var installed = false try { - val backup = GeneratedKeyBackup(recovery.account.npub, recovery.takeRecoveryNsec()) + val backup = GeneratedKeyBackup(recovery.identity.npub, recovery.takeRecoveryNsec()) pendingGeneratedRecovery = PendingGeneratedRecovery(recovery, backup) mutableState.value = mutableState.value.copy(generatedKeyBackup = backup) installed = true @@ -205,27 +205,27 @@ class HarvestCircleAppStore( if (rejectIfUnavailable()) return val input = mutableState.value.importDraft.encodeToByteArray() mutableState.value = mutableState.value.copy(importDraft = "") - runTypedCommand(HarvestCircleCommand.ImportAccount(input)) + runTypedCommand(HarvestCircleCommand.ImportIdentity(input)) } - fun selectAccount(publicKeyHex: String) { - runTypedCommand(HarvestCircleCommand.SelectAccount(publicKeyHex)) + fun selectIdentity(publicKeyHex: String) { + runTypedCommand(HarvestCircleCommand.SelectIdentity(publicKeyHex)) } - fun activateAccount(publicKeyHex: String) { - runTypedCommand(HarvestCircleCommand.ActivateAccount(publicKeyHex), hideChooser = true) + fun activateIdentity(publicKeyHex: String) { + runTypedCommand(HarvestCircleCommand.ActivateIdentity(publicKeyHex), hideChooser = true) } fun signOut() { runTypedCommand(HarvestCircleCommand.SignOut, hideChooser = true) } - fun showAccountChooser() { - mutableState.value = mutableState.value.copy(accountChooserVisible = true, problem = null) + fun showIdentityChooser() { + mutableState.value = mutableState.value.copy(identityChooserVisible = true, problem = null) } - fun hideAccountChooser() { - mutableState.value = mutableState.value.copy(accountChooserVisible = false) + fun hideIdentityChooser() { + mutableState.value = mutableState.value.copy(identityChooserVisible = false) } fun refreshActiveProfile() { @@ -241,12 +241,12 @@ class HarvestCircleAppStore( runTypedCommand(retry) } - fun requestAccountRemoval(publicKeyHex: String) { + fun requestIdentityRemoval(publicKeyHex: String) { launchCommand { runCatching { pendingRemoval?.close() pendingRemoval = null - val ticket = gateway.requestAccountRemoval(publicKeyHex) + val ticket = gateway.requestIdentityRemoval(publicKeyHex) if (closed) { ticket.close() return@runCatching @@ -268,7 +268,7 @@ class HarvestCircleAppStore( } } - fun cancelAccountRemoval() { + fun cancelIdentityRemoval() { pendingRemoval?.close() pendingRemoval = null mutableState.value = @@ -279,17 +279,17 @@ class HarvestCircleAppStore( ) } - fun confirmAccountRemoval() { + fun confirmIdentityRemoval() { val ticket = pendingRemoval ?: run { - rejectUnavailableIntent("Account removal confirmation is not available.") + rejectUnavailableIntent("Identity removal confirmation is not available.") return } pendingRemoval = null mutableState.value = mutableState.value.copy(removalStatus = RemovalStatus.CONFIRMING) runSnapshotCommand { try { - gateway.confirmAccountRemoval(ticket).also { + gateway.confirmIdentityRemoval(ticket).also { mutableState.value = mutableState.value.copy( pendingRemovalPublicKeyHex = null, @@ -337,13 +337,13 @@ class HarvestCircleAppStore( recoveryAction = WireRecoveryAction.NONE, ) if (hideChooser) { - mutableState.value = mutableState.value.copy(accountChooserVisible = false) + mutableState.value = mutableState.value.copy(identityChooserVisible = false) } } is HarvestCircleCommandResult.Rejected -> { retryableCommand = command.takeIf { - result.failure.retryable && it !is HarvestCircleCommand.ImportAccount + result.failure.retryable && it !is HarvestCircleCommand.ImportIdentity } mutableState.value = mutableState.value.copy( @@ -405,7 +405,7 @@ class HarvestCircleAppStore( ) return true } - if (mutableState.value.route !in setOf(HarvestCircleRoute.ACCOUNTS, HarvestCircleRoute.ACTIVE_ACCOUNT)) { + if (mutableState.value.route !in setOf(HarvestCircleRoute.IDENTITYS, HarvestCircleRoute.ACTIVE_IDENTITY)) { mutableState.value = mutableState.value.copy( commandStatus = CommandStatus.FAILED_TERMINAL, @@ -499,7 +499,7 @@ internal fun AppSnapshotDto.toHarvestCircleRoute(): HarvestCircleRoute = AppLifecycleDto.ACQUIRING_OWNERSHIP -> HarvestCircleRoute.ACQUIRING_OWNERSHIP AppLifecycleDto.MIGRATING -> HarvestCircleRoute.MIGRATING AppLifecycleDto.RECOVERING -> HarvestCircleRoute.RECOVERING - AppLifecycleDto.READY -> if (activeAccount != null) HarvestCircleRoute.ACTIVE_ACCOUNT else HarvestCircleRoute.ACCOUNTS + AppLifecycleDto.READY -> if (activeIdentity != null) HarvestCircleRoute.ACTIVE_IDENTITY else HarvestCircleRoute.IDENTITYS AppLifecycleDto.DEGRADED -> HarvestCircleRoute.DEGRADED AppLifecycleDto.BLOCKED -> HarvestCircleRoute.BLOCKED AppLifecycleDto.SHUTTING_DOWN -> HarvestCircleRoute.SHUTTING_DOWN diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt @@ -5,13 +5,13 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.CoroutineScope -import org.harvestcircle.accounts.ui.HarvestCircleScreen -import org.harvestcircle.accounts.ui.HarvestCircleUiActions -import org.harvestcircle.accounts.ui.StartupFailureScreen -import org.harvestcircle.accounts.ui.toUiModel import org.harvestcircle.ffi.HarvestCircleAppCore import org.harvestcircle.ffi.HarvestCircleException import org.harvestcircle.ffi.compatibilityDescriptor +import org.harvestcircle.identities.ui.HarvestCircleScreen +import org.harvestcircle.identities.ui.HarvestCircleUiActions +import org.harvestcircle.identities.ui.StartupFailureScreen +import org.harvestcircle.identities.ui.toUiModel internal typealias HarvestCircleStoreFactory = (CoroutineScope) -> HarvestCircleAppStore @@ -41,25 +41,25 @@ fun HarvestCircleApplication(storeFactory: HarvestCircleStoreFactory = ::createH model = store.state.value.toUiModel(), actions = HarvestCircleUiActions( - chooseCreateAccount = store::chooseCreateAccount, - chooseImportAccount = store::chooseImportAccount, - cancelAccountEntry = store::cancelAccountEntry, + chooseCreateIdentity = store::chooseCreateIdentity, + chooseImportIdentity = store::chooseImportIdentity, + cancelIdentityEntry = store::cancelIdentityEntry, editImportDraft = store::editImportDraft, - generateAccount = store::generateAccount, + generateIdentity = store::generateIdentity, importSecretKey = store::importSecretKey, copyText = { value -> clipboard.copy(value) }, acknowledgeGeneratedKeyBackup = store::acknowledgeGeneratedKeyBackup, cancelGeneratedKeyBackup = store::cancelGeneratedKeyBackup, - selectAccount = store::selectAccount, - activateAccount = store::activateAccount, - requestAccountRemoval = store::requestAccountRemoval, - cancelAccountRemoval = store::cancelAccountRemoval, - confirmAccountRemoval = store::confirmAccountRemoval, + selectIdentity = store::selectIdentity, + activateIdentity = store::activateIdentity, + requestIdentityRemoval = store::requestIdentityRemoval, + cancelIdentityRemoval = store::cancelIdentityRemoval, + confirmIdentityRemoval = store::confirmIdentityRemoval, refreshActiveProfile = store::refreshActiveProfile, retryLastCommand = store::retryLastCommand, signOut = store::signOut, - showAccountChooser = store::showAccountChooser, - hideAccountChooser = store::hideAccountChooser, + showIdentityChooser = store::showIdentityChooser, + hideIdentityChooser = store::hideIdentityChooser, ), ) } diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleCoreGateway.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleCoreGateway.kt @@ -2,12 +2,12 @@ package org.harvestcircle.application import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking -import org.harvestcircle.ffi.AccountDto import org.harvestcircle.ffi.AppSnapshotDto import org.harvestcircle.ffi.GeneratedRecoveryRequest import org.harvestcircle.ffi.HarvestCircleAppCore import org.harvestcircle.ffi.HarvestCircleChangeObserver import org.harvestcircle.ffi.HarvestCircleException +import org.harvestcircle.ffi.IdentityDto import org.harvestcircle.ffi.ObserverSubscription import org.harvestcircle.ffi.RemovalRequest import org.harvestcircle.ffi.RequestContextDto @@ -26,7 +26,7 @@ interface RemovalTicket : AutoCloseable { interface GeneratedRecoveryTicket : AutoCloseable { val requestId: String - val account: AccountDto + val identity: IdentityDto fun takeRecoveryNsec(): String @@ -41,15 +41,15 @@ data class HarvestCircleChange( ) sealed interface HarvestCircleCommand { - data class ImportAccount( + data class ImportIdentity( val bytes: ByteArray, ) : HarvestCircleCommand - data class SelectAccount( + data class SelectIdentity( val publicKeyHex: String, ) : HarvestCircleCommand - data class ActivateAccount( + data class ActivateIdentity( val publicKeyHex: String, ) : HarvestCircleCommand @@ -97,11 +97,11 @@ interface HarvestCircleCoreGateway : AutoCloseable { suspend fun bootstrap(): AppSnapshotDto - suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket + suspend fun beginGeneratedIdentity(): GeneratedRecoveryTicket - suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket + suspend fun requestIdentityRemoval(publicKeyHex: String): RemovalTicket - suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto + suspend fun confirmIdentityRemoval(ticket: RemovalTicket): AppSnapshotDto fun shutdown(): HarvestCircleShutdownReceipt } @@ -132,14 +132,14 @@ class NativeHarvestCircleCoreGateway( return try { val snapshot = when (command) { - is HarvestCircleCommand.ImportAccount -> + is HarvestCircleCommand.ImportIdentity -> try { - core.importAccountV2(context, command.bytes).snapshot + core.importIdentity(context, command.bytes).snapshot } finally { command.bytes.fill(0) } - is HarvestCircleCommand.SelectAccount -> core.selectAccount(command.publicKeyHex) - is HarvestCircleCommand.ActivateAccount -> core.activateAccount(command.publicKeyHex) + is HarvestCircleCommand.SelectIdentity -> core.selectIdentity(command.publicKeyHex) + is HarvestCircleCommand.ActivateIdentity -> core.activateIdentity(command.publicKeyHex) HarvestCircleCommand.SignOut -> core.signOut() HarvestCircleCommand.RefreshProfile -> core.refreshActiveProfile() } @@ -155,12 +155,12 @@ class NativeHarvestCircleCoreGateway( override suspend fun bootstrap(): AppSnapshotDto = core.bootstrap() - override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket { + override suspend fun beginGeneratedIdentity(): GeneratedRecoveryTicket { val requestId = nextRequestId() return try { - val request = core.beginGeneratedAccountV2() + val request = core.beginGeneratedIdentity() try { - NativeGeneratedRecoveryTicket(core, request, ::requestContext, requestId, request.account()) + NativeGeneratedRecoveryTicket(core, request, ::requestContext, requestId, request.identity()) } catch (error: Exception) { request.close() throw error @@ -177,12 +177,12 @@ class NativeHarvestCircleCoreGateway( } } - override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = - NativeRemovalTicket(core.requestAccountRemoval(publicKeyHex)) + override suspend fun requestIdentityRemoval(publicKeyHex: String): RemovalTicket = + NativeRemovalTicket(core.requestIdentityRemoval(publicKeyHex)) - override suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto { + override suspend fun confirmIdentityRemoval(ticket: RemovalTicket): AppSnapshotDto { require(ticket is NativeRemovalTicket) { "Removal ticket does not belong to native core" } - return core.confirmAccountRemoval(requestContext(), ticket.request) + return core.confirmIdentityRemoval(requestContext(), ticket.request) } override fun shutdown(): HarvestCircleShutdownReceipt = @@ -260,7 +260,7 @@ private class NativeGeneratedRecoveryTicket( private val request: GeneratedRecoveryRequest, private val requestContext: () -> RequestContextDto, override val requestId: String, - override val account: AccountDto, + override val identity: IdentityDto, ) : GeneratedRecoveryTicket { override fun takeRecoveryNsec(): String = try { @@ -276,14 +276,14 @@ private class NativeGeneratedRecoveryTicket( override suspend fun acknowledge(): AppSnapshotDto { val context = requestContext() - return call("The generated account could not be saved.", context.requestId) { - core.acknowledgeGeneratedAccountV2(context, request) + return call("The generated identity could not be saved.", context.requestId) { + core.acknowledgeGeneratedIdentity(context, request) } } override suspend fun cancel(): Boolean = call("The generated key could not be cancelled safely.") { - core.cancelGeneratedAccountV2(request) + core.cancelGeneratedIdentity(request) } override fun close() { diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt @@ -10,7 +10,7 @@ internal const val EXPECTED_DISTRIBUTION_PACKAGE_VERSION = "1.0.0" internal const val EXPECTED_PRODUCT_COORDINATE_DIGEST = "f81db525a0228782530799911879fb55cb25e8e631d09605fd5084e9bd88fbbe" internal const val EXPECTED_SOURCE_PROVENANCE_DIGEST = "d4d54ab897e98a93dfbe27a9d9589dbc38c3b7e2163097617d59beaf64e0358c" internal const val EXPECTED_SOURCE_FOUNDATION_BASELINE = "a2038b3e25b9e34f0b8fd001f26a8ed10b5772cb" -internal const val EXPECTED_FFI_CONTRACT_HASH = "b54e9d096174cfdf2020b6cd2e7547b83f4071f0fed89e1cafe67aa2686a2893" +internal const val EXPECTED_FFI_CONTRACT_HASH = "638a2d8d18bb4c26a4d68a5c6d34294fc0b76c51c068ea116f26d1b598f62a75" internal val EXPECTED_FFI_CONTRACT_MAJOR: UShort = 4.toUShort() internal val MINIMUM_FFI_CONTRACT_MINOR: UShort = 0.toUShort() internal const val EXPECTED_SNAPSHOT_SCHEMA: UInt = 1U diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt @@ -7,12 +7,12 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import org.harvestcircle.ffi.AccountCommandReceiptDto -import org.harvestcircle.ffi.AccountDto import org.harvestcircle.ffi.AppSnapshotDto import org.harvestcircle.ffi.GeneratedRecoveryRequest import org.harvestcircle.ffi.HarvestCircleAppCore import org.harvestcircle.ffi.HarvestCircleChangeObserver +import org.harvestcircle.ffi.IdentityCommandReceiptDto +import org.harvestcircle.ffi.IdentityDto import org.harvestcircle.ffi.ObserverSubscription import org.harvestcircle.ffi.RemovalRequest import org.harvestcircle.ffi.RequestContextDto @@ -74,7 +74,7 @@ class NativeHarvestCircleRuntime internal constructor( val handle = callNative { native.beginGeneratedIdentity() } val requestId = RecoveryRequestId.from(handleIds.next("recovery")) return try { - val identity = handle.account().toIdentitySummary() + val identity = handle.identity().toIdentitySummary() val backup = GeneratedKeyBackup(identity.npub, handle.takeRecoverySecret()) recoveryMutex.withLock { recoveryHandles[requestId] = handle } GeneratedIdentityRecovery( @@ -278,7 +278,7 @@ internal interface NativeCorePort : AutoCloseable { suspend fun importIdentity( context: RequestContextDto, secretKey: ByteArray, - ): AccountCommandReceiptDto + ): IdentityCommandReceiptDto suspend fun selectIdentity(publicKeyHex: String): AppSnapshotDto @@ -299,7 +299,7 @@ internal interface NativeCorePort : AutoCloseable { } internal interface NativeGeneratedRecoveryHandle : AutoCloseable { - fun account(): AccountDto + fun identity(): IdentityDto fun expiresAtSeconds(): Long @@ -338,36 +338,36 @@ private class UniFfiNativeCorePort( } override suspend fun beginGeneratedIdentity(): NativeGeneratedRecoveryHandle = - UniFfiGeneratedRecoveryHandle(core.beginGeneratedAccountV2()) + UniFfiGeneratedRecoveryHandle(core.beginGeneratedIdentity()) override suspend fun acknowledgeGeneratedIdentity( context: RequestContextDto, request: NativeGeneratedRecoveryHandle, - ): AppSnapshotDto = core.acknowledgeGeneratedAccountV2(context, request.generated()) + ): AppSnapshotDto = core.acknowledgeGeneratedIdentity(context, request.generated()) override suspend fun cancelGeneratedIdentity(request: NativeGeneratedRecoveryHandle): Boolean = - core.cancelGeneratedAccountV2(request.generated()) + core.cancelGeneratedIdentity(request.generated()) override suspend fun importIdentity( context: RequestContextDto, secretKey: ByteArray, - ): AccountCommandReceiptDto = core.importAccountV2(context, secretKey) + ): IdentityCommandReceiptDto = core.importIdentity(context, secretKey) - override suspend fun selectIdentity(publicKeyHex: String): AppSnapshotDto = core.selectAccount(publicKeyHex) + override suspend fun selectIdentity(publicKeyHex: String): AppSnapshotDto = core.selectIdentity(publicKeyHex) - override suspend fun activateIdentity(publicKeyHex: String): AppSnapshotDto = core.activateAccount(publicKeyHex) + override suspend fun activateIdentity(publicKeyHex: String): AppSnapshotDto = core.activateIdentity(publicKeyHex) override suspend fun signOut(): AppSnapshotDto = core.signOut() override suspend fun refreshActiveProfile(): AppSnapshotDto = core.refreshActiveProfile() override suspend fun requestIdentityRemoval(publicKeyHex: String): NativeRemovalHandle = - UniFfiRemovalHandle(core.requestAccountRemoval(publicKeyHex)) + UniFfiRemovalHandle(core.requestIdentityRemoval(publicKeyHex)) override suspend fun confirmIdentityRemoval( context: RequestContextDto, request: NativeRemovalHandle, - ): AppSnapshotDto = core.confirmAccountRemoval(context, request.generated()) + ): AppSnapshotDto = core.confirmIdentityRemoval(context, request.generated()) override suspend fun shutdown(): ShutdownReceiptDto = core.shutdownV2() @@ -385,7 +385,7 @@ private class UniFfiNativeCorePort( private class UniFfiGeneratedRecoveryHandle( val request: GeneratedRecoveryRequest, ) : NativeGeneratedRecoveryHandle { - override fun account(): AccountDto = request.account() + override fun identity(): IdentityDto = request.identity() override fun expiresAtSeconds(): Long = request.expiresAtSeconds() diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeRuntimeMappings.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeRuntimeMappings.kt @@ -1,12 +1,11 @@ package org.harvestcircle.application -import org.harvestcircle.ffi.AccountCommandReceiptDto -import org.harvestcircle.ffi.AccountDto -import org.harvestcircle.ffi.ActiveAccountDto +import org.harvestcircle.ffi.ActiveIdentityDto import org.harvestcircle.ffi.AppLifecycleDto import org.harvestcircle.ffi.AppSnapshotDto import org.harvestcircle.ffi.HarvestCircleException -import org.harvestcircle.ffi.KeyAvailabilityDto +import org.harvestcircle.ffi.IdentityCommandReceiptDto +import org.harvestcircle.ffi.IdentityDto import org.harvestcircle.ffi.ProfileDto import org.harvestcircle.ffi.ProfileLoadStateDto import org.harvestcircle.ffi.RelayConnectionStateDto @@ -14,7 +13,8 @@ import org.harvestcircle.ffi.RequestContextDto import org.harvestcircle.ffi.SafeErrorDto import org.harvestcircle.ffi.SessionStateDto import org.harvestcircle.ffi.ShutdownReceiptDto -import org.harvestcircle.ffi.SignerKindDto +import org.harvestcircle.ffi.SignerAvailabilityDto +import org.harvestcircle.ffi.SignerBindingKindDto import org.harvestcircle.ffi.SnapshotChangeDto import org.harvestcircle.ffi.WireErrorCategory import org.harvestcircle.ffi.WireErrorCode @@ -27,26 +27,26 @@ internal fun RequestContext.toNative(): RequestContextDto = deadlineMillis = deadlineMillis, ) -internal fun AccountCommandReceiptDto.toApplicationResult(): ApplicationCommandResult.Committed = +internal fun IdentityCommandReceiptDto.toApplicationResult(): ApplicationCommandResult.Committed = ApplicationCommandResult.Committed( operationId = OperationId.from(requestId), committedRevision = SnapshotRevision(committedRevision), snapshot = snapshot.toApplicationSnapshot(), ) -internal fun AccountDto.toIdentitySummary(): IdentitySummary = +internal fun IdentityDto.toIdentitySummary(): IdentitySummary = IdentitySummary( id = IdentityId.fromPublicKeyHex(publicKeyHex), npub = npub, displayLabel = displayLabel, - signer = SignerBindingSummary(signerKind.toSignerBindingKind(), keyAvailability.toSignerAvailability()), + signer = SignerBindingSummary(signerBindingKind.toSignerBindingKind(), signerAvailability.toSignerAvailability()), createdAt = UnixSeconds(createdAtSeconds), lastUsedAt = lastUsedAtSeconds?.let(::UnixSeconds), ) -internal fun ActiveAccountDto.toActiveIdentity(configuredRelays: List<String>): ActiveIdentity = +internal fun ActiveIdentityDto.toActiveIdentity(configuredRelays: List<String>): ActiveIdentity = ActiveIdentity( - identity = account.toIdentitySummary(), + identity = identity.toIdentitySummary(), relays = RelaySummary(configuredRelays, relayState.toRelayConnectionState()), profileState = profileState.toProfileLoadState(), profile = profile?.toProfileSummary(), @@ -58,12 +58,12 @@ internal fun AppSnapshotDto.toApplicationSnapshot(): ApplicationSnapshot = lifecycle = lifecycle.toApplicationLifecycle(), lifecycleProblem = lifecycleError?.toApplicationProblem(), configuredRelays = configuredRelays, - identities = accounts.map(AccountDto::toIdentitySummary), + identities = identities.map(IdentityDto::toIdentitySummary), selectedIdentityId = selectedPublicKeyHex?.let(IdentityId::fromPublicKeyHex), session = session.toSessionLifecycle(), sessionSubjectIdentityId = sessionSubjectPublicKeyHex?.let(IdentityId::fromPublicKeyHex), sessionProblem = sessionError?.toApplicationProblem(), - activeIdentity = activeAccount?.toActiveIdentity(configuredRelays), + activeIdentity = activeIdentity?.toActiveIdentity(configuredRelays), recoverableProblem = recoverableProblem?.toApplicationProblem(), ) @@ -134,19 +134,16 @@ internal fun SessionStateDto.toSessionLifecycle(): SessionLifecycle = SessionStateDto.FAILED -> SessionLifecycle.Failed } -internal fun SignerKindDto.toSignerBindingKind(): SignerBindingKind = +internal fun SignerBindingKindDto.toSignerBindingKind(): SignerBindingKind = when (this) { - SignerKindDto.LOCAL_SECRET -> SignerBindingKind.LocalKeyring - SignerKindDto.WATCH_ONLY -> SignerBindingKind.Unsupported("read-only") - SignerKindDto.REMOTE_NIP46 -> SignerBindingKind.Unsupported("remote-nip46") + SignerBindingKindDto.LOCAL_KEYRING -> SignerBindingKind.LocalKeyring } -internal fun KeyAvailabilityDto.toSignerAvailability(): SignerAvailability = +internal fun SignerAvailabilityDto.toSignerAvailability(): SignerAvailability = when (this) { - KeyAvailabilityDto.AVAILABLE -> SignerAvailability.Available - KeyAvailabilityDto.CREDENTIAL_MISSING -> SignerAvailability.CredentialMissing - KeyAvailabilityDto.STORE_UNAVAILABLE -> SignerAvailability.StoreUnavailable - KeyAvailabilityDto.NOT_REQUIRED -> SignerAvailability.NotRequired + SignerAvailabilityDto.AVAILABLE -> SignerAvailability.Available + SignerAvailabilityDto.CREDENTIAL_MISSING -> SignerAvailability.CredentialMissing + SignerAvailabilityDto.STORE_UNAVAILABLE -> SignerAvailability.StoreUnavailable } internal fun RelayConnectionStateDto.toRelayConnectionState(): RelayConnectionState = @@ -171,11 +168,11 @@ internal fun WireErrorCode.toApplicationErrorCode(): ApplicationErrorCode = when (this) { WireErrorCode.INVALID_PUBLIC_KEY -> ApplicationErrorCode.InvalidPublicKey WireErrorCode.INVALID_SECRET_KEY -> ApplicationErrorCode.InvalidSecretKey - WireErrorCode.INVALID_ACCOUNT_METADATA -> ApplicationErrorCode.InvalidIdentityMetadata + WireErrorCode.INVALID_IDENTITY_METADATA -> ApplicationErrorCode.InvalidIdentityMetadata WireErrorCode.INVALID_PROFILE_METADATA -> ApplicationErrorCode.InvalidProfileMetadata WireErrorCode.INVALID_APPLICATION_STATE -> ApplicationErrorCode.InvalidApplicationState - WireErrorCode.ACCOUNT_ALREADY_EXISTS -> ApplicationErrorCode.IdentityAlreadyExists - WireErrorCode.ACCOUNT_NOT_FOUND -> ApplicationErrorCode.IdentityNotFound + WireErrorCode.IDENTITY_ALREADY_EXISTS -> ApplicationErrorCode.IdentityAlreadyExists + WireErrorCode.IDENTITY_NOT_FOUND -> ApplicationErrorCode.IdentityNotFound WireErrorCode.KEYRING_UNAVAILABLE -> ApplicationErrorCode.KeyringUnavailable WireErrorCode.CREDENTIAL_MISSING -> ApplicationErrorCode.CredentialMissing WireErrorCode.STORAGE_UNAVAILABLE -> ApplicationErrorCode.StorageUnavailable diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt b/app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt @@ -5,8 +5,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState -import org.harvestcircle.accounts.ui.StartupFailureScreen import org.harvestcircle.application.HarvestCircleApplication +import org.harvestcircle.identities.ui.StartupFailureScreen import java.awt.Dimension import java.awt.Taskbar import javax.imageio.ImageIO diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/identity/ui/HarvestCircleScreen.kt b/app/desktop/src/main/kotlin/org/harvestcircle/identity/ui/HarvestCircleScreen.kt @@ -0,0 +1,495 @@ +package org.harvestcircle.identities.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.password +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import org.harvestcircle.application.HarvestCircleRoute +import org.harvestcircle.application.IdentityEntryMode + +private val WindowBackgroundColor = Color(0xFFF5F5F2) +private val ButtonBackgroundColor = Color(0xFFE7E7E2) +private val InputBackgroundColor = Color(0xFFFEFDF8) + +data class HarvestCircleUiActions( + val chooseCreateIdentity: () -> Unit = {}, + val chooseImportIdentity: () -> Unit = {}, + val cancelIdentityEntry: () -> Unit = {}, + val editImportDraft: (String) -> Unit = {}, + val generateIdentity: () -> Unit = {}, + val importSecretKey: () -> Unit = {}, + val copyText: (String) -> Unit = {}, + val acknowledgeGeneratedKeyBackup: () -> Unit = {}, + val cancelGeneratedKeyBackup: () -> Unit = {}, + val selectIdentity: (String) -> Unit = {}, + val activateIdentity: (String) -> Unit = {}, + val requestIdentityRemoval: (String) -> Unit = {}, + val cancelIdentityRemoval: () -> Unit = {}, + val confirmIdentityRemoval: () -> Unit = {}, + val refreshActiveProfile: () -> Unit = {}, + val retryLastCommand: () -> Unit = {}, + val signOut: () -> Unit = {}, + val showIdentityChooser: () -> Unit = {}, + val hideIdentityChooser: () -> Unit = {}, +) + +@Composable +fun StartupFailureScreen(problem: String) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .testTag("startup-failure"), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + BasicText("HarvestCircle") + BasicText(problem, Modifier.testTag("startup-problem")) + } +} + +@Composable +fun HarvestCircleScreen( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, +) { + model.generatedKeyBackup?.let { backup -> + GeneratedKeyRecoveryScreen(backup, actions) + return + } + when (model.route) { + HarvestCircleRoute.OPENING -> LifecycleScreen("Opening local identity store", "lifecycle-opening") + HarvestCircleRoute.CHECKING_COMPATIBILITY -> + LifecycleScreen( + "Checking native compatibility", + "lifecycle-compatibility", + ) + HarvestCircleRoute.ACQUIRING_OWNERSHIP -> + LifecycleScreen( + "Acquiring local identity store", + "lifecycle-ownership", + ) + HarvestCircleRoute.MIGRATING -> + LifecycleScreen( + "Updating local identity store", + "lifecycle-migrating", + ) + HarvestCircleRoute.RECOVERING -> + LifecycleScreen( + "Recovering local identity state", + "lifecycle-recovering", + ) + HarvestCircleRoute.SHUTTING_DOWN -> LifecycleScreen("Shutting down", "lifecycle-shutting-down") + HarvestCircleRoute.CLOSED -> LifecycleScreen("Closed", "lifecycle-closed") + HarvestCircleRoute.BLOCKED -> + LifecycleScreen( + model.problem ?: "Local identity access is blocked.", + "lifecycle-blocked", + ) + HarvestCircleRoute.FATAL -> + LifecycleScreen( + model.problem ?: "The application could not continue.", + "lifecycle-fatal", + ) + HarvestCircleRoute.DEGRADED -> InactiveIdentitiesScreen(model, actions, degraded = true) + HarvestCircleRoute.ACTIVE_IDENTITY -> { + if (model.activeIdentity != null && !model.identityChooserVisible) { + ActiveIdentityHome(model, model.activeIdentity, actions) + } else { + InactiveIdentitiesScreen(model, actions) + } + } + HarvestCircleRoute.IDENTITYS -> InactiveIdentitiesScreen(model, actions) + } +} + +@Composable +private fun LifecycleScreen( + message: String, + testTag: String, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .testTag(testTag), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + BasicText("HarvestCircle") + BasicText(message) + } +} + +@Composable +private fun ActiveIdentityHome( + model: HarvestCircleUiModel, + active: ActiveIdentityUiModel, + actions: HarvestCircleUiActions, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .testTag("home-screen"), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + BasicText("HarvestCircle") + BasicText(active.heading) + BasicText(active.identity.npub, Modifier.testTag("active-npub")) + BasicText(active.identity.publicKeyHex, Modifier.testTag("active-pubkey-hex")) + BasicText("Name: ${active.profile.name}", Modifier.testTag("active-profile-name")) + BasicText("Display name: ${active.profile.displayName}") + BasicText("NIP-05 (unverified): ${active.profile.nip05}") + BasicText("About: ${active.profile.about}", Modifier.testTag("active-profile-about")) + BasicText("Picture: ${active.profile.picture}") + BasicText("Relay: ${active.relayState}", Modifier.testTag("relay-state")) + BasicText("Profile: ${active.profileState}", Modifier.testTag("profile-state")) + BasicText("Configured relays") + if (model.configuredRelays.isEmpty()) { + BasicText("None") + } else { + model.configuredRelays.forEach { relay -> BasicText(relay) } + } + TextAction( + text = "Switch identity", + testTag = "switch-identity", + contentDescription = "Choose another saved identity", + enabled = !model.busy, + onClick = actions.showIdentityChooser, + ) + TextAction( + text = "Refresh metadata", + testTag = "refresh-profile", + contentDescription = "Refresh active Nostr profile metadata", + enabled = !model.busy, + onClick = actions.refreshActiveProfile, + ) + TextAction( + text = "Sign out", + testTag = "sign-out", + contentDescription = "Sign out of the active identity", + enabled = !model.busy, + onClick = actions.signOut, + ) + model.problem?.let { BasicText(it, Modifier.testTag("home-problem")) } + RecoveryAction(model, actions) + } +} + +@Composable +private fun InactiveIdentitiesScreen( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, + degraded: Boolean = false, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .testTag("identities-screen"), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + BasicText("HarvestCircle") + BasicText("Identities") + if (degraded) { + BasicText(model.problem ?: "Nostr relay access is unavailable. Local identities remain available.") + } + + if (model.activeIdentity != null) { + BasicText("Choose an identity to activate. The current identity remains active until replacement succeeds.") + TextAction( + text = "Back to active identity", + testTag = "return-home", + contentDescription = "Return to the active identity", + onClick = actions.hideIdentityChooser, + ) + } + + IdentityEntry(model, actions) + + model.problem?.let { + BasicText(it, Modifier.testTag("identities-problem")) + } + RecoveryAction(model, actions) + + if (model.identities.isEmpty()) { + BasicText("No saved identities.", Modifier.testTag("identities-empty")) + } else { + SavedIdentityList(model, actions) + } + } +} + +@Composable +private fun RecoveryAction( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, +) { + if (model.recoveryAction == org.harvestcircle.ffi.WireRecoveryAction.RETRY) { + TextAction( + text = "Retry", + testTag = "retry-last-command", + contentDescription = "Retry the last failed action", + enabled = !model.busy, + onClick = actions.retryLastCommand, + ) + } +} + +@Composable +private fun IdentityEntry( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, +) { + when (model.identityEntryMode) { + IdentityEntryMode.CHOICE -> { + TextAction( + text = "Create identity", + testTag = "choose-create-identity", + contentDescription = "Create a new Nostr identity", + enabled = !model.busy, + onClick = actions.chooseCreateIdentity, + ) + TextAction( + text = "Import key", + testTag = "choose-import-identity", + contentDescription = "Import an existing Nostr secret key", + enabled = !model.busy, + onClick = actions.chooseImportIdentity, + ) + } + IdentityEntryMode.CREATE -> { + TextAction( + text = "Back", + testTag = "cancel-identity-entry", + contentDescription = "Return to identity choices", + enabled = !model.busy, + onClick = actions.cancelIdentityEntry, + ) + TextAction( + text = "Generate new key", + testTag = "generate-key", + contentDescription = "Generate a new Nostr key", + enabled = !model.busy && model.generatedKeyBackup == null, + onClick = actions.generateIdentity, + ) + } + IdentityEntryMode.IMPORT -> { + val importFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { importFocusRequester.requestFocus() } + TextAction( + text = "Back", + testTag = "cancel-identity-entry", + contentDescription = "Return to identity choices", + enabled = !model.busy, + onClick = actions.cancelIdentityEntry, + ) + BasicTextField( + value = model.importDraft, + onValueChange = actions.editImportDraft, + enabled = !model.busy, + visualTransformation = PasswordVisualTransformation(), + modifier = + Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Nostr secret key" + password() + }.focusRequester(importFocusRequester) + .testTag("import-nsec-input") + .background(InputBackgroundColor) + .padding(8.dp), + decorationBox = { innerTextField -> + if (model.importDraft.isEmpty()) BasicText("nsec or secret-key hex") + innerTextField() + }, + ) + model.importGuidance?.let { guidance -> + BasicText(guidance, Modifier.testTag("import-guidance")) + } + TextAction( + text = "Add existing key", + testTag = "import-key", + contentDescription = "Import an existing Nostr secret key", + enabled = !model.busy && model.importDraft.isNotBlank(), + onClick = actions.importSecretKey, + ) + } + } +} + +@Composable +private fun ColumnScope.SavedIdentityList( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, +) { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .testTag("saved-identity-list"), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(model.identities, key = IdentityUiModel::publicKeyHex) { identity -> + Column( + modifier = + Modifier + .fillMaxWidth() + .semantics { selected = identity.selected } + .testTag("identity-row:${identity.publicKeyHex}") + .background(InputBackgroundColor) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + BasicText(identity.label) + BasicText(identity.npub) + BasicText("Key: ${identity.signerAvailability}") + if (identity.selected) BasicText("Selected") + if (identity.active) BasicText("Active") + TextAction( + text = if (identity.selected) "Selected identity" else "Select", + testTag = "select-identity:${identity.publicKeyHex}", + contentDescription = "Select ${identity.label}", + enabled = !model.busy && !identity.selected, + onClick = { actions.selectIdentity(identity.publicKeyHex) }, + ) + TextAction( + text = if (identity.active) "Active identity" else "Activate", + testTag = "activate-identity:${identity.publicKeyHex}", + contentDescription = "Activate ${identity.label}", + enabled = !model.busy && !identity.active, + onClick = { actions.activateIdentity(identity.publicKeyHex) }, + ) + TextAction( + text = "Remove", + testTag = "remove-identity:${identity.publicKeyHex}", + contentDescription = "Remove ${identity.label}", + enabled = !model.busy, + onClick = { actions.requestIdentityRemoval(identity.publicKeyHex) }, + ) + if (model.pendingRemovalPublicKeyHex == identity.publicKeyHex) { + BasicText("Remove this saved identity?") + if (model.removalImpact?.deletesLocalCredential == true) { + BasicText("Its local credential will be deleted from the operating-system keyring.") + } + if (model.removalImpact?.signsOut == true) { + BasicText("The active session will be signed out before removal.") + } + TextAction( + text = "Cancel", + testTag = "remove-cancel", + contentDescription = "Cancel identity removal", + onClick = actions.cancelIdentityRemoval, + ) + TextAction( + text = "Confirm removal", + testTag = "remove-confirm", + contentDescription = "Confirm identity removal", + enabled = !model.busy, + onClick = actions.confirmIdentityRemoval, + ) + } + } + } + } +} + +@Composable +private fun GeneratedKeyRecoveryScreen( + backup: GeneratedKeyBackupUiModel, + actions: HarvestCircleUiActions, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .testTag("generated-key-backup"), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + BasicText("Save this key") + BasicText("Losing this secret key means losing access to the identity.") + BasicText(backup.npub) + BasicText(backup.nsec, Modifier.testTag("generated-nsec")) + TextAction( + text = "Copy", + testTag = "copy-generated-key", + contentDescription = "Copy generated Nostr secret key", + onClick = { actions.copyText(backup.nsec) }, + ) + TextAction( + text = "Cancel", + testTag = "cancel-generated-key", + contentDescription = "Cancel generated identity", + onClick = actions.cancelGeneratedKeyBackup, + ) + TextAction( + text = "I have saved this key", + testTag = "acknowledge-key-backup", + contentDescription = "Confirm generated key backup", + onClick = actions.acknowledgeGeneratedKeyBackup, + ) + } +} + +@Composable +internal fun TextAction( + text: String, + testTag: String, + contentDescription: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { + BasicText( + text = text, + modifier = + Modifier + .semantics { + role = Role.Button + this.contentDescription = contentDescription + if (!enabled) disabled() + }.testTag(testTag) + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) + .background(ButtonBackgroundColor) + .padding(8.dp), + ) +} diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/identity/ui/IdentityUiModel.kt b/app/desktop/src/main/kotlin/org/harvestcircle/identity/ui/IdentityUiModel.kt @@ -0,0 +1,168 @@ +package org.harvestcircle.identities.ui + +import org.harvestcircle.application.HarvestCircleRoute +import org.harvestcircle.application.HarvestCircleStoreState +import org.harvestcircle.application.IdentityEntryMode +import org.harvestcircle.application.RemovalImpactState +import org.harvestcircle.application.RemovalStatus +import org.harvestcircle.ffi.ActiveIdentityDto +import org.harvestcircle.ffi.IdentityDto +import org.harvestcircle.ffi.ProfileLoadStateDto +import org.harvestcircle.ffi.RelayConnectionStateDto +import org.harvestcircle.ffi.SessionStateDto +import org.harvestcircle.ffi.WireErrorCode +import org.harvestcircle.ffi.WireRecoveryAction + +data class IdentityUiModel( + val publicKeyHex: String, + val npub: String, + val shortNpub: String, + val label: String, + val signerAvailability: String, + val selected: Boolean, + val active: Boolean, +) + +data class ProfileUiModel( + val name: String, + val displayName: String, + val nip05: String, + val about: String, + val picture: String, +) + +data class ActiveIdentityUiModel( + val identity: IdentityUiModel, + val heading: String, + val relayState: String, + val profileState: String, + val profile: ProfileUiModel, +) + +class GeneratedKeyBackupUiModel( + val npub: String, + val nsec: String, +) { + override fun toString(): String = "GeneratedKeyBackupUiModel(npub=$npub, nsec=[REDACTED])" +} + +data class HarvestCircleUiModel( + val route: HarvestCircleRoute, + val identities: List<IdentityUiModel>, + val activeIdentity: ActiveIdentityUiModel?, + val configuredRelays: List<String>, + val importDraft: String, + val generatedKeyBackup: GeneratedKeyBackupUiModel?, + val pendingRemovalPublicKeyHex: String?, + val removalImpact: RemovalImpactState?, + val removalStatus: RemovalStatus, + val lastRemovedPublicKeyHex: String?, + val identityChooserVisible: Boolean, + val identityEntryMode: IdentityEntryMode, + val session: SessionStateDto, + val busy: Boolean, + val problem: String?, + val importGuidance: String?, + val recoveryAction: WireRecoveryAction, +) + +fun HarvestCircleStoreState.toUiModel(): HarvestCircleUiModel { + val selectedPublicKeyHex = snapshot.selectedPublicKeyHex + val activePublicKeyHex = snapshot.activeIdentity?.identity?.publicKeyHex + val identities = + snapshot.identities.map { + it.toUiModel( + selected = it.publicKeyHex == selectedPublicKeyHex, + active = it.publicKeyHex == activePublicKeyHex, + ) + } + return HarvestCircleUiModel( + route = route, + identities = identities, + activeIdentity = snapshot.activeIdentity?.toUiModel(selectedPublicKeyHex), + configuredRelays = snapshot.configuredRelays, + importDraft = importDraft, + generatedKeyBackup = + generatedKeyBackup?.let { + GeneratedKeyBackupUiModel(npub = it.npub, nsec = it.revealNsec()) + }, + pendingRemovalPublicKeyHex = pendingRemovalPublicKeyHex, + removalImpact = removalImpact, + removalStatus = removalStatus, + lastRemovedPublicKeyHex = lastRemovedPublicKeyHex, + identityChooserVisible = identityChooserVisible, + identityEntryMode = identityEntryMode, + session = snapshot.session, + busy = busy, + problem = + problem + ?: snapshot.recoverableProblem?.message + ?: snapshot.sessionError?.message + ?: snapshot.lifecycleError?.message, + importGuidance = importGuidance(lastFailureCode, recoveryAction), + recoveryAction = recoveryAction, + ) +} + +private fun importGuidance( + code: WireErrorCode?, + recoveryAction: WireRecoveryAction, +): String? = + when { + code == WireErrorCode.INVALID_SECRET_KEY -> "Enter a valid nsec or 64-character hexadecimal secret key." + code == WireErrorCode.IDENTITY_ALREADY_EXISTS -> "This Nostr identity is already saved." + code == WireErrorCode.CREDENTIAL_MISSING || recoveryAction == WireRecoveryAction.REPAIR_CREDENTIAL -> + "This saved identity is missing its local credential. Re-enter its secret key to repair it." + else -> null + } + +private const val SHORT_NPUB_MAX_LENGTH = 24 +private const val SHORT_NPUB_PREFIX_LENGTH = 14 +private const val SHORT_NPUB_SUFFIX_LENGTH = 8 + +fun shortenNpub(npub: String): String = + if (npub.length <= SHORT_NPUB_MAX_LENGTH) { + npub + } else { + "${npub.take(SHORT_NPUB_PREFIX_LENGTH)}…${npub.takeLast(SHORT_NPUB_SUFFIX_LENGTH)}" + } + +private fun IdentityDto.toUiModel( + selected: Boolean, + active: Boolean = false, +) = IdentityUiModel( + publicKeyHex = publicKeyHex, + npub = npub, + shortNpub = shortenNpub(npub), + label = displayLabel.ifBlank { shortenNpub(npub) }, + signerAvailability = signerAvailability.name.lowercase().replace('_', ' '), + selected = selected, + active = active, +) + +private fun ActiveIdentityDto.toUiModel(selectedPublicKeyHex: String?) = + ActiveIdentityUiModel( + identity = + identity.toUiModel( + selected = identity.publicKeyHex == selectedPublicKeyHex, + active = true, + ), + heading = + profile?.displayName?.takeIf(String::isNotBlank) + ?: profile?.name?.takeIf(String::isNotBlank) + ?: identity.displayLabel.ifBlank { shortenNpub(identity.npub) }, + relayState = relayState.toDisplayText(), + profileState = profileState.toDisplayText(), + profile = + ProfileUiModel( + name = profile?.name.orEmpty(), + displayName = profile?.displayName.orEmpty(), + nip05 = profile?.nip05.orEmpty(), + about = profile?.about.orEmpty(), + picture = profile?.picture.orEmpty(), + ), + ) + +private fun RelayConnectionStateDto.toDisplayText(): String = name.lowercase().replace('_', ' ') + +private fun ProfileLoadStateDto.toDisplayText(): String = name.lowercase().replace('_', ' ') diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/accounts/ui/AccountsUiModelTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/accounts/ui/AccountsUiModelTest.kt @@ -1,127 +0,0 @@ -package org.harvestcircle.accounts.ui - -import org.harvestcircle.application.GeneratedKeyBackup -import org.harvestcircle.application.HarvestCircleStoreState -import org.harvestcircle.ffi.AccountDto -import org.harvestcircle.ffi.ActiveAccountDto -import org.harvestcircle.ffi.AppLifecycleDto -import org.harvestcircle.ffi.AppSnapshotDto -import org.harvestcircle.ffi.KeyAvailabilityDto -import org.harvestcircle.ffi.ProfileDto -import org.harvestcircle.ffi.ProfileLoadStateDto -import org.harvestcircle.ffi.RelayConnectionStateDto -import org.harvestcircle.ffi.SessionStateDto -import org.harvestcircle.ffi.SignerKindDto -import org.harvestcircle.ffi.WireErrorCode -import org.harvestcircle.ffi.WireRecoveryAction -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class AccountsUiModelTest { - @Test - fun mapsPublicNostrIdentityAndProfileState() { - val account = account() - val snapshot = - snapshot( - account = account, - active = - ActiveAccountDto( - account = account, - relayState = RelayConnectionStateDto.CONNECTED, - profileState = ProfileLoadStateDto.FRESH, - profile = ProfileDto("alice", "Alice", "alice@example.com", "Farmer", "https://example.com/a.png"), - ), - ) - - val model = HarvestCircleStoreState(snapshot).toUiModel() - - assertEquals("Alice", model.activeAccount?.heading) - assertEquals("connected", model.activeAccount?.relayState) - assertEquals("fresh", model.activeAccount?.profileState) - assertEquals("alice@example.com", model.activeAccount?.profile?.nip05) - assertEquals(listOf("ws://localhost:8080"), model.configuredRelays) - assertFalse(model.accountChooserVisible) - assertFalse( - model.accounts - .single() - .label - .contains("server", ignoreCase = true), - ) - assertTrue(model.accounts.single().selected) - assertTrue(model.accounts.single().active) - } - - @Test - fun mapsSafeProblemAndTransientBackupSeparatelyFromSnapshot() { - val state = - HarvestCircleStoreState( - snapshot = snapshot(), - generatedKeyBackup = GeneratedKeyBackup("npub1generated", "nsec1generated"), - problem = "Try again.", - ) - - val model = state.toUiModel() - - assertEquals("Try again.", model.problem) - assertEquals("nsec1generated", model.generatedKeyBackup?.nsec) - assertNull(state.snapshot.recoverableProblem) - } - - @Test - fun shortensOnlyLongNpubValues() { - assertEquals("npub1short", shortenNpub("npub1short")) - assertEquals("npub1abcdefghi…34567890", shortenNpub("npub1abcdefghijklmnopqrstuvwxyz1234567890")) - } - - @Test - fun mapsTypedImportFailuresToSpecificRepairGuidance() { - val invalid = - HarvestCircleStoreState( - snapshot = snapshot(), - lastFailureCode = WireErrorCode.INVALID_SECRET_KEY, - ).toUiModel() - val repair = - HarvestCircleStoreState( - snapshot = snapshot(), - lastFailureCode = WireErrorCode.CREDENTIAL_MISSING, - recoveryAction = WireRecoveryAction.REPAIR_CREDENTIAL, - ).toUiModel() - - assertEquals("Enter a valid nsec or 64-character hexadecimal secret key.", invalid.importGuidance) - assertEquals( - "This saved account is missing its local credential. Re-enter its secret key to repair it.", - repair.importGuidance, - ) - } -} - -private fun snapshot( - account: AccountDto? = null, - active: ActiveAccountDto? = null, -) = AppSnapshotDto( - revision = 1UL, - lifecycle = AppLifecycleDto.READY, - lifecycleError = null, - configuredRelays = listOf("ws://localhost:8080"), - accounts = listOfNotNull(account), - selectedPublicKeyHex = account?.publicKeyHex, - session = if (active == null) SessionStateDto.SIGNED_OUT else SessionStateDto.ACTIVE, - sessionSubjectPublicKeyHex = active?.account?.publicKeyHex, - sessionError = null, - activeAccount = active, - recoverableProblem = null, -) - -private fun account() = - AccountDto( - publicKeyHex = "12".repeat(32), - npub = "npub1abcdefghijklmnopqrstuvwxyz1234567890", - displayLabel = "Alice", - signerKind = SignerKindDto.LOCAL_SECRET, - keyAvailability = KeyAvailabilityDto.AVAILABLE, - createdAtSeconds = 1, - lastUsedAtSeconds = null, - ) diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/accounts/ui/HarvestCircleScreenTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/accounts/ui/HarvestCircleScreenTest.kt @@ -1,384 +0,0 @@ -package org.harvestcircle.accounts.ui - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsFocused -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.assertIsSelected -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollToNode -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.v2.runComposeUiTest -import org.harvestcircle.application.AccountEntryMode -import org.harvestcircle.application.HarvestCircleRoute -import org.harvestcircle.application.RemovalImpactState -import org.harvestcircle.application.RemovalStatus -import org.harvestcircle.ffi.SessionStateDto -import org.harvestcircle.ffi.WireRecoveryAction -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -@OptIn(ExperimentalTestApi::class) -class HarvestCircleScreenTest { - @Test - fun rendersEveryNonReadyLifecycleRouteWithoutAccountControls() = - runComposeUiTest { - var model by mutableStateOf(emptyUiModel().copy(route = HarvestCircleRoute.OPENING)) - setContent { HarvestCircleScreen(model, HarvestCircleUiActions()) } - - val routes = - listOf( - HarvestCircleRoute.OPENING to "lifecycle-opening", - HarvestCircleRoute.CHECKING_COMPATIBILITY to "lifecycle-compatibility", - HarvestCircleRoute.ACQUIRING_OWNERSHIP to "lifecycle-ownership", - HarvestCircleRoute.MIGRATING to "lifecycle-migrating", - HarvestCircleRoute.RECOVERING to "lifecycle-recovering", - HarvestCircleRoute.BLOCKED to "lifecycle-blocked", - HarvestCircleRoute.SHUTTING_DOWN to "lifecycle-shutting-down", - HarvestCircleRoute.FATAL to "lifecycle-fatal", - HarvestCircleRoute.CLOSED to "lifecycle-closed", - ) - routes.forEach { (route, tag) -> - model = emptyUiModel(problem = "Safe lifecycle problem").copy(route = route) - waitForIdle() - onNodeWithTag(tag).assertIsDisplayed() - onAllNodesWithTag("generate-key").assertCountEquals(0) - } - - model = emptyUiModel(problem = "Relay access is unavailable.").copy(route = HarvestCircleRoute.DEGRADED) - waitForIdle() - onNodeWithTag("accounts-screen").assertIsDisplayed() - onNodeWithTag("accounts-problem").assertIsDisplayed() - } - - @Test - fun inactiveScreenGeneratesAndImportsMaskedSecretInput() = - runComposeUiTest { - var importDraft by mutableStateOf("") - var accountEntryMode by mutableStateOf(AccountEntryMode.CHOICE) - var generateCalls = 0 - var importCalls = 0 - setContent { - HarvestCircleScreen( - model = emptyUiModel(importDraft = importDraft).copy(accountEntryMode = accountEntryMode), - actions = - HarvestCircleUiActions( - chooseCreateAccount = { accountEntryMode = AccountEntryMode.CREATE }, - chooseImportAccount = { accountEntryMode = AccountEntryMode.IMPORT }, - cancelAccountEntry = { accountEntryMode = AccountEntryMode.CHOICE }, - editImportDraft = { importDraft = it }, - generateAccount = { generateCalls += 1 }, - importSecretKey = { importCalls += 1 }, - ), - ) - } - - onNodeWithTag("accounts-screen").assertIsDisplayed() - onNodeWithText("HarvestCircle").assertIsDisplayed() - onNodeWithTag("choose-create-account").performClick() - onNodeWithTag("generate-key").performClick() - onNodeWithTag("cancel-account-entry").performClick() - onNodeWithTag("choose-import-account").performClick() - onNodeWithTag("import-nsec-input").assertIsFocused() - onNodeWithTag("import-nsec-input").performTextInput("nsec1secret") - onNodeWithTag("import-key").performClick() - - assertEquals(1, generateCalls) - assertEquals(1, importCalls) - assertEquals("nsec1secret", importDraft) - assertTrue( - onNodeWithTag("import-nsec-input").fetchSemanticsNode().config.any { - it.key.name == "Password" && it.value == Unit - }, - ) - } - - @Test - fun inactiveScreenShowsSafeFailureAndNoGenericFields() = - runComposeUiTest { - setContent { - HarvestCircleScreen( - model = emptyUiModel(problem = "The secret key is invalid."), - actions = HarvestCircleUiActions(), - ) - } - - onNodeWithText("The secret key is invalid.").assertIsDisplayed() - onNodeWithTag("accounts-empty").assertIsDisplayed() - } - - @Test - fun generatedKeyBackupCopiesAndClearsOnlyAfterAcknowledgement() = - runComposeUiTest { - var backup: GeneratedKeyBackupUiModel? by mutableStateOf( - GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), - ) - var copied: String? = null - setContent { - HarvestCircleScreen( - model = emptyUiModel().copy(generatedKeyBackup = backup), - actions = - HarvestCircleUiActions( - copyText = { copied = it }, - acknowledgeGeneratedKeyBackup = { backup = null }, - ), - ) - } - - onNodeWithTag("generated-key-backup").assertIsDisplayed() - onAllNodesWithTag("accounts-screen").assertCountEquals(0) - onAllNodesWithTag("generate-key").assertCountEquals(0) - onNodeWithTag("generated-nsec").assertIsDisplayed() - onNodeWithTag("copy-generated-key").performClick() - assertEquals("nsec1generated", copied) - - onNodeWithTag("acknowledge-key-backup").performClick() - onAllNodesWithTag("generated-key-backup").assertCountEquals(0) - onAllNodesWithTag("generated-nsec").assertCountEquals(0) - } - - @Test - fun generatedKeyRecoveryCanBeCancelledWithoutExposingAccountControls() = - runComposeUiTest { - var backup: GeneratedKeyBackupUiModel? by mutableStateOf( - GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), - ) - var cancelled = 0 - setContent { - HarvestCircleScreen( - model = emptyUiModel().copy(generatedKeyBackup = backup), - actions = - HarvestCircleUiActions( - cancelGeneratedKeyBackup = { - cancelled += 1 - backup = null - }, - ), - ) - } - - onNodeWithTag("cancel-generated-key").performClick() - assertEquals(1, cancelled) - onAllNodesWithTag("generated-key-backup").assertCountEquals(0) - } - - @Test - fun savedAccountsSelectActivateAndRequireRemovalConfirmation() = - runComposeUiTest { - val first = accountUi("11".repeat(32), selected = true) - val second = accountUi("22".repeat(32), selected = false) - var pendingRemoval: String? by mutableStateOf(null) - val selected = mutableListOf<String>() - val activated = mutableListOf<String>() - var confirmations = 0 - setContent { - HarvestCircleScreen( - model = - emptyUiModel().copy( - accounts = listOf(first, second), - pendingRemovalPublicKeyHex = pendingRemoval, - removalImpact = - pendingRemoval?.let { - RemovalImpactState(it, deletesLocalCredential = true, signsOut = true, expiresAtSeconds = 60) - }, - ), - actions = - HarvestCircleUiActions( - selectAccount = selected::add, - activateAccount = activated::add, - requestAccountRemoval = { pendingRemoval = it }, - cancelAccountRemoval = { pendingRemoval = null }, - confirmAccountRemoval = { confirmations += 1 }, - ), - ) - } - - onNodeWithTag("saved-account-list").assertIsDisplayed() - onNodeWithTag("account-row:${first.publicKeyHex}").assertIsSelected() - onNodeWithTag("select-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() - onNodeWithTag("activate-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() - assertEquals(listOf(second.publicKeyHex), selected) - assertEquals(listOf(second.publicKeyHex), activated) - - onNodeWithTag("remove-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() - onNodeWithText("Its local credential will be deleted from the operating-system keyring.").assertIsDisplayed() - onNodeWithText("The active session will be signed out before removal.").assertIsDisplayed() - onNodeWithTag("remove-cancel", useUnmergedTree = true).performClick() - assertEquals(null, pendingRemoval) - onNodeWithTag("remove-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() - onNodeWithTag("remove-confirm", useUnmergedTree = true).performClick() - assertEquals(1, confirmations) - } - - @Test - fun savedAccountListRemainsReachableForLargeRegistries() = - runComposeUiTest { - val accounts = - (0 until 100).map { index -> - accountUi(index.toString(16).padStart(64, '0'), selected = index == 0) - } - setContent { - HarvestCircleScreen( - model = emptyUiModel().copy(accounts = accounts), - actions = HarvestCircleUiActions(), - ) - } - - val lastTag = "account-row:${accounts.last().publicKeyHex}" - onNodeWithTag("saved-account-list").performScrollToNode(hasTestTag(lastTag)) - onNodeWithTag(lastTag).assertIsDisplayed() - } - - @Test - fun activeHomeShowsIdentityProfileRelayAndCommands() = - runComposeUiTest { - var refreshCalls = 0 - var signOutCalls = 0 - val account = accountUi("33".repeat(32), selected = true) - val active = - ActiveAccountUiModel( - account = account, - heading = "Alice", - relayState = "connected", - profileState = "fresh", - profile = - ProfileUiModel( - name = "alice", - displayName = "Alice", - nip05 = "alice@example.com", - about = "Local grower", - picture = "https://example.com/alice.png", - ), - ) - setContent { - HarvestCircleScreen( - model = - emptyUiModel().copy( - route = HarvestCircleRoute.ACTIVE_ACCOUNT, - accounts = listOf(account), - activeAccount = active, - configuredRelays = listOf("ws://localhost:8080"), - session = SessionStateDto.ACTIVE, - ), - actions = - HarvestCircleUiActions( - refreshActiveProfile = { refreshCalls += 1 }, - signOut = { signOutCalls += 1 }, - ), - ) - } - - onNodeWithTag("home-screen").assertIsDisplayed() - onNodeWithTag("active-npub").assertIsDisplayed() - onNodeWithTag("active-pubkey-hex").assertIsDisplayed() - onNodeWithTag("active-profile-name").assertIsDisplayed() - onNodeWithTag("active-profile-about").assertIsDisplayed() - onNodeWithTag("relay-state").assertIsDisplayed() - onNodeWithTag("profile-state").assertIsDisplayed() - onNodeWithText("ws://localhost:8080").assertIsDisplayed() - onNodeWithTag("refresh-profile").performClick() - onNodeWithTag("sign-out").performClick() - assertEquals(1, refreshCalls) - assertEquals(1, signOutCalls) - } - - @Test - fun activeAccountCanOpenChooserWithoutDroppingCurrentSession() = - runComposeUiTest { - val first = accountUi("44".repeat(32), selected = true, active = true) - val second = accountUi("55".repeat(32), selected = false) - val active = - ActiveAccountUiModel( - account = first, - heading = first.label, - relayState = "connected", - profileState = "cached", - profile = ProfileUiModel("", "", "", "", ""), - ) - var chooserVisible by mutableStateOf(false) - var activated: String? = null - setContent { - HarvestCircleScreen( - model = - emptyUiModel().copy( - route = HarvestCircleRoute.ACTIVE_ACCOUNT, - accounts = listOf(first, second), - activeAccount = active, - session = SessionStateDto.ACTIVE, - accountChooserVisible = chooserVisible, - ), - actions = - HarvestCircleUiActions( - showAccountChooser = { chooserVisible = true }, - hideAccountChooser = { chooserVisible = false }, - activateAccount = { activated = it }, - ), - ) - } - - onNodeWithTag("switch-account").performClick() - onNodeWithTag("accounts-screen").assertIsDisplayed() - onNodeWithTag("activate-account:${first.publicKeyHex}", useUnmergedTree = true).assertIsNotEnabled() - onNodeWithText("Active").assertIsDisplayed() - onNodeWithTag("activate-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() - assertEquals(second.publicKeyHex, activated) - assertEquals( - SessionStateDto.ACTIVE, - emptyUiModel() - .copy( - activeAccount = active, - session = SessionStateDto.ACTIVE, - ).session, - ) - onNodeWithTag("return-home").performClick() - onNodeWithTag("home-screen").assertIsDisplayed() - } -} - -private fun emptyUiModel( - importDraft: String = "", - problem: String? = null, - importGuidance: String? = null, - recoveryAction: WireRecoveryAction = WireRecoveryAction.NONE, -) = HarvestCircleUiModel( - route = HarvestCircleRoute.ACCOUNTS, - accounts = emptyList(), - activeAccount = null, - configuredRelays = emptyList(), - importDraft = importDraft, - generatedKeyBackup = null, - pendingRemovalPublicKeyHex = null, - removalImpact = null, - removalStatus = RemovalStatus.NONE, - lastRemovedPublicKeyHex = null, - accountChooserVisible = false, - accountEntryMode = AccountEntryMode.CHOICE, - session = SessionStateDto.SIGNED_OUT, - busy = false, - problem = problem, - importGuidance = importGuidance, - recoveryAction = recoveryAction, -) - -private fun accountUi( - publicKeyHex: String, - selected: Boolean, - active: Boolean = false, -) = AccountUiModel( - publicKeyHex = publicKeyHex, - npub = "npub1${publicKeyHex.take(12)}", - shortNpub = "npub1${publicKeyHex.take(12)}", - label = "Account ${publicKeyHex.take(2)}", - keyAvailability = "available", - selected = selected, - active = active, -) diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleAppStoreTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleAppStoreTest.kt @@ -3,12 +3,12 @@ package org.harvestcircle.application import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.harvestcircle.ffi.AccountDto import org.harvestcircle.ffi.AppLifecycleDto import org.harvestcircle.ffi.AppSnapshotDto -import org.harvestcircle.ffi.KeyAvailabilityDto +import org.harvestcircle.ffi.IdentityDto import org.harvestcircle.ffi.SessionStateDto -import org.harvestcircle.ffi.SignerKindDto +import org.harvestcircle.ffi.SignerAvailabilityDto +import org.harvestcircle.ffi.SignerBindingKindDto import org.harvestcircle.ffi.WireErrorCategory import org.harvestcircle.ffi.WireErrorCode import org.harvestcircle.ffi.WireRecoveryAction @@ -47,7 +47,7 @@ class HarvestCircleAppStoreTest { val store = HarvestCircleAppStore(gateway, this) advanceUntilIdle() - store.generateAccount() + store.generateIdentity() advanceUntilIdle() assertEquals( @@ -56,7 +56,7 @@ class HarvestCircleAppStoreTest { ?.revealNsec(), ) assertEquals( - "npub1account", + "npub1identity", store.state.value.generatedKeyBackup ?.npub, ) @@ -67,13 +67,13 @@ class HarvestCircleAppStoreTest { } @Test - fun `cancels staged generated account without committing it`() = + fun `cancels staged generated identity without committing it`() = runTest { val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) val store = HarvestCircleAppStore(gateway, this) advanceUntilIdle() val revisionBeforeGeneration = store.state.value.snapshot.revision - store.generateAccount() + store.generateIdentity() advanceUntilIdle() store.cancelGeneratedKeyBackup() @@ -94,7 +94,7 @@ class HarvestCircleAppStoreTest { val store = HarvestCircleAppStore(gateway, this) advanceUntilIdle() - store.generateAccount() + store.generateIdentity() advanceUntilIdle() assertNull(store.state.value.generatedKeyBackup) @@ -112,7 +112,7 @@ class HarvestCircleAppStoreTest { } val store = HarvestCircleAppStore(gateway, this) advanceUntilIdle() - store.generateAccount() + store.generateIdentity() advanceUntilIdle() store.acknowledgeGeneratedKeyBackup() @@ -122,7 +122,7 @@ class HarvestCircleAppStoreTest { assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) assertEquals("fake-generated-request", store.state.value.lastCommandRequestId) assertEquals( - "The generated account could not be saved. Import the recovery key you saved to try again.", + "The generated identity could not be saved. Import the recovery key you saved to try again.", store.state.value.problem, ) store.close() @@ -137,7 +137,7 @@ class HarvestCircleAppStoreTest { } val store = HarvestCircleAppStore(gateway, this) advanceUntilIdle() - store.generateAccount() + store.generateIdentity() advanceUntilIdle() store.cancelGeneratedKeyBackup() @@ -175,10 +175,10 @@ class HarvestCircleAppStoreTest { val store = HarvestCircleAppStore(gateway, this) advanceUntilIdle() - store.requestAccountRemoval("00".repeat(32)) + store.requestIdentityRemoval("00".repeat(32)) advanceUntilIdle() assertEquals("00".repeat(32), store.state.value.pendingRemovalPublicKeyHex) - store.confirmAccountRemoval() + store.confirmIdentityRemoval() advanceUntilIdle() assertNull(store.state.value.pendingRemovalPublicKeyHex) @@ -343,7 +343,7 @@ private class FakeHarvestCircleCoreGateway( return it } when (command) { - is HarvestCircleCommand.ImportAccount -> { + is HarvestCircleCommand.ImportIdentity -> { lastImportBuffer = command.bytes importedSecrets += command.bytes.decodeToString() command.bytes.fill(0) @@ -363,9 +363,9 @@ private class FakeHarvestCircleCoreGateway( override suspend fun bootstrap(): AppSnapshotDto = bootstrapSnapshot.also(::emit) - override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = + override suspend fun beginGeneratedIdentity(): GeneratedRecoveryTicket = FakeGeneratedRecoveryTicket( - account = account(), + identity = identity(), failRecoveryRead = failGeneratedRecoveryRead, failAcknowledgement = failGeneratedAcknowledgement, cancellationResult = generatedCancellationResult, @@ -375,9 +375,9 @@ private class FakeHarvestCircleCoreGateway( committed(current) }.also { lastGeneratedRecoveryTicket = it } - override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = FakeRemovalTicket().also { lastRemovalTicket = it } + override suspend fun requestIdentityRemoval(publicKeyHex: String): RemovalTicket = FakeRemovalTicket().also { lastRemovalTicket = it } - override suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto { + override suspend fun confirmIdentityRemoval(ticket: RemovalTicket): AppSnapshotDto { if (failRemovalConfirmation) error("injected confirmation failure") return current } @@ -394,7 +394,7 @@ private class FakeHarvestCircleCoreGateway( } private class FakeGeneratedRecoveryTicket( - override val account: AccountDto, + override val identity: IdentityDto, private val failRecoveryRead: Boolean, private val failAcknowledgement: Boolean, private val cancellationResult: Boolean, @@ -420,7 +420,7 @@ private class FakeGeneratedRecoveryTicket( retryable = false, WireRecoveryAction.NONE, requestId, - "The generated account could not be saved. Import the recovery key you saved to try again.", + "The generated identity could not be saved. Import the recovery key you saved to try again.", ), ) } @@ -460,22 +460,22 @@ private fun snapshot( lifecycle = lifecycle, lifecycleError = null, configuredRelays = emptyList(), - accounts = emptyList(), + identities = emptyList(), selectedPublicKeyHex = null, session = SessionStateDto.SIGNED_OUT, sessionSubjectPublicKeyHex = null, sessionError = null, - activeAccount = null, + activeIdentity = null, recoverableProblem = null, ) -private fun account() = - AccountDto( +private fun identity() = + IdentityDto( publicKeyHex = "00".repeat(32), - npub = "npub1account", - displayLabel = "Account", - signerKind = SignerKindDto.LOCAL_SECRET, - keyAvailability = KeyAvailabilityDto.AVAILABLE, + npub = "npub1identity", + displayLabel = "Identity", + signerBindingKind = SignerBindingKindDto.LOCAL_KEYRING, + signerAvailability = SignerAvailabilityDto.AVAILABLE, createdAtSeconds = 0, lastUsedAtSeconds = null, ) diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt @@ -83,11 +83,11 @@ private class ApplicationGateway : HarvestCircleCoreGateway { override suspend fun bootstrap() = applicationSnapshot(1UL) - override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = error("unused") + override suspend fun beginGeneratedIdentity(): GeneratedRecoveryTicket = error("unused") - override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = error("unused") + override suspend fun requestIdentityRemoval(publicKeyHex: String): RemovalTicket = error("unused") - override suspend fun confirmAccountRemoval(ticket: RemovalTicket) = error("unused") + override suspend fun confirmIdentityRemoval(ticket: RemovalTicket) = error("unused") override fun shutdown(): HarvestCircleShutdownReceipt { closed = true @@ -105,11 +105,11 @@ private fun applicationSnapshot(revision: ULong) = lifecycle = AppLifecycleDto.READY, lifecycleError = null, configuredRelays = emptyList(), - accounts = emptyList(), + identities = emptyList(), selectedPublicKeyHex = null, session = SessionStateDto.SIGNED_OUT, sessionSubjectPublicKeyHex = null, sessionError = null, - activeAccount = null, + activeIdentity = null, recoverableProblem = null, ) diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeGeneratedRecoveryTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeGeneratedRecoveryTest.kt @@ -38,13 +38,13 @@ class NativeGeneratedRecoveryTest { val gateway = NativeHarvestCircleCoreGateway(core) try { gateway.bootstrap() - val recovery = gateway.beginGeneratedAccount() + val recovery = gateway.beginGeneratedIdentity() - assertTrue(recovery.account.npub.startsWith("npub1")) + assertTrue(recovery.identity.npub.startsWith("npub1")) assertTrue(recovery.takeRecoveryNsec().startsWith("nsec1")) assertTrue(recovery.cancel()) assertFalse(recovery.cancel()) - assertEquals(0, gateway.snapshot().accounts.size) + assertEquals(0, gateway.snapshot().identities.size) recovery.close() } finally { gateway.shutdown() diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt @@ -4,13 +4,12 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import org.harvestcircle.ffi.AccountCommandReceiptDto -import org.harvestcircle.ffi.AccountDto -import org.harvestcircle.ffi.ActiveAccountDto +import org.harvestcircle.ffi.ActiveIdentityDto import org.harvestcircle.ffi.AppLifecycleDto import org.harvestcircle.ffi.AppSnapshotDto import org.harvestcircle.ffi.HarvestCircleException -import org.harvestcircle.ffi.KeyAvailabilityDto +import org.harvestcircle.ffi.IdentityCommandReceiptDto +import org.harvestcircle.ffi.IdentityDto import org.harvestcircle.ffi.ProfileDto import org.harvestcircle.ffi.ProfileLoadStateDto import org.harvestcircle.ffi.RelayConnectionStateDto @@ -18,7 +17,8 @@ import org.harvestcircle.ffi.RequestContextDto import org.harvestcircle.ffi.SafeErrorDto import org.harvestcircle.ffi.SessionStateDto import org.harvestcircle.ffi.ShutdownReceiptDto -import org.harvestcircle.ffi.SignerKindDto +import org.harvestcircle.ffi.SignerAvailabilityDto +import org.harvestcircle.ffi.SignerBindingKindDto import org.harvestcircle.ffi.SnapshotChangeDto import org.harvestcircle.ffi.WireErrorCategory import org.harvestcircle.ffi.WireErrorCode @@ -49,16 +49,16 @@ class NativeRuntimeMappingsTest { .size, ) assertEquals( - 3, - SignerKindDto.entries - .map(SignerKindDto::toSignerBindingKind) + 1, + SignerBindingKindDto.entries + .map(SignerBindingKindDto::toSignerBindingKind) .distinct() .size, ) assertEquals( - 4, - KeyAvailabilityDto.entries - .map(KeyAvailabilityDto::toSignerAvailability) + 3, + SignerAvailabilityDto.entries + .map(SignerAvailabilityDto::toSignerAvailability) .distinct() .size, ) @@ -109,7 +109,7 @@ class NativeRuntimeMappingsTest { assertEquals(ApplicationErrorCode.RelayConnectionFailed, mapped.lifecycleProblem?.code) assertEquals(native.configuredRelays, mapped.configuredRelays) assertEquals( - native.accounts.single().publicKeyHex, + native.identities.single().publicKeyHex, mapped.identities .single() .id.value, @@ -135,7 +135,7 @@ class NativeRuntimeMappingsTest { fun receiptChangeContextAndShutdownMappingsPreserveRevisions() { val snapshot = populatedSnapshot(revision = 2UL) val result = - AccountCommandReceiptDto("operation-7", 2UL, snapshot) + IdentityCommandReceiptDto("operation-7", 2UL, snapshot) .toApplicationResult() assertEquals(OperationId.from("operation-7"), result.operationId) assertEquals(SnapshotRevision(2UL), result.committedRevision) @@ -179,10 +179,9 @@ class NativeRuntimeMappingsTest { } @Test - fun signerVariantsRemainExplicitWithoutClaimingFutureSupport() { - assertEquals(SignerBindingKind.LocalKeyring, SignerKindDto.LOCAL_SECRET.toSignerBindingKind()) - assertIs<SignerBindingKind.Unsupported>(SignerKindDto.WATCH_ONLY.toSignerBindingKind()) - assertIs<SignerBindingKind.Unsupported>(SignerKindDto.REMOTE_NIP46.toSignerBindingKind()) + fun onlyTheImplementedLocalKeyringBindingIsPublished() { + assertEquals(SignerBindingKind.LocalKeyring, SignerBindingKindDto.LOCAL_KEYRING.toSignerBindingKind()) + assertEquals(1, SignerBindingKindDto.entries.size) } } @@ -207,7 +206,7 @@ class NativeHarvestCircleRuntimeTest { runtime.execute(ApplicationCommand.AcknowledgeGeneratedIdentity(recovery.requestId, context)) assertTrue(port.generated.closed) - val identityId = IdentityId.fromPublicKeyHex(nativeAccount().publicKeyHex) + val identityId = IdentityId.fromPublicKeyHex(nativeIdentity().publicKeyHex) val removal = runtime.requestIdentityRemoval(identityId) assertEquals("removal-1", removal.requestId.value) runtime.execute(ApplicationCommand.ConfirmIdentityRemoval(removal.requestId, context)) @@ -277,9 +276,9 @@ private class FakeNativeCorePort : NativeCorePort { override suspend fun importIdentity( context: RequestContextDto, secretKey: ByteArray, - ): AccountCommandReceiptDto { + ): IdentityCommandReceiptDto { importedSecret = secretKey.copyOf() - return AccountCommandReceiptDto(context.requestId, snapshot.revision, snapshot) + return IdentityCommandReceiptDto(context.requestId, snapshot.revision, snapshot) } override suspend fun selectIdentity(publicKeyHex: String): AppSnapshotDto = snapshot @@ -314,7 +313,7 @@ private class FakeNativeCorePort : NativeCorePort { private class FakeGeneratedRecoveryHandle : NativeGeneratedRecoveryHandle { var closed = false - override fun account(): AccountDto = nativeAccount() + override fun identity(): IdentityDto = nativeIdentity() override fun expiresAtSeconds(): Long = 100 @@ -328,7 +327,7 @@ private class FakeGeneratedRecoveryHandle : NativeGeneratedRecoveryHandle { private class FakeRemovalHandle : NativeRemovalHandle { var closed = false - override fun publicKeyHex(): String = nativeAccount().publicKeyHex + override fun publicKeyHex(): String = nativeIdentity().publicKeyHex override fun deletesLocalCredential(): Boolean = true @@ -342,20 +341,20 @@ private class FakeRemovalHandle : NativeRemovalHandle { } private fun populatedSnapshot(revision: ULong): AppSnapshotDto { - val account = nativeAccount() + val identity = nativeIdentity() return AppSnapshotDto( revision = revision, lifecycle = AppLifecycleDto.DEGRADED, lifecycleError = safeError(WireErrorCode.RELAY_CONNECTION_FAILED), configuredRelays = listOf("wss://relay.example"), - accounts = listOf(account), - selectedPublicKeyHex = account.publicKeyHex, + identities = listOf(identity), + selectedPublicKeyHex = identity.publicKeyHex, session = SessionStateDto.ACTIVE, - sessionSubjectPublicKeyHex = account.publicKeyHex, + sessionSubjectPublicKeyHex = identity.publicKeyHex, sessionError = safeError(WireErrorCode.CREDENTIAL_MISSING), - activeAccount = - ActiveAccountDto( - account = account, + activeIdentity = + ActiveIdentityDto( + identity = identity, relayState = RelayConnectionStateDto.CONNECTED, profileState = ProfileLoadStateDto.FRESH, profile = ProfileDto("name", "display", "name@example.com", "about", "https://example.com/p.png"), @@ -370,22 +369,22 @@ private fun emptySnapshot(): AppSnapshotDto = lifecycle = AppLifecycleDto.READY, lifecycleError = null, configuredRelays = emptyList(), - accounts = emptyList(), + identities = emptyList(), selectedPublicKeyHex = null, session = SessionStateDto.SIGNED_OUT, sessionSubjectPublicKeyHex = null, sessionError = null, - activeAccount = null, + activeIdentity = null, recoverableProblem = null, ) -private fun nativeAccount(): AccountDto = - AccountDto( +private fun nativeIdentity(): IdentityDto = + IdentityDto( publicKeyHex = "01".repeat(32), npub = "npub1identity", displayLabel = "Identity", - signerKind = SignerKindDto.LOCAL_SECRET, - keyAvailability = KeyAvailabilityDto.AVAILABLE, + signerBindingKind = SignerBindingKindDto.LOCAL_KEYRING, + signerAvailability = SignerAvailabilityDto.AVAILABLE, createdAtSeconds = 1, lastUsedAtSeconds = 2, ) diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/architecture/NostrOnlySourceGuardTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/architecture/NostrOnlySourceGuardTest.kt @@ -10,17 +10,17 @@ import kotlin.test.assertEquals class NostrOnlySourceGuardTest { @Test - fun activeKotlinSourcesContainNoRetiredAccountArchitecture() { + fun activeKotlinSourcesContainNoRetiredIdentityArchitecture() { val sourceRoot = findSourceRoot() val forbidden = listOf( "server" + "url", - "account" + " server", + "identity" + " server", "editadd" + "server" + "url", "login" + "status", "java.util." + "uuid", - "accounts" + "reducer", - "accounts" + "store", + "identities" + "reducer", + "identities" + "store", ) val findings = Files.walk(sourceRoot).use { paths -> diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/identity/ui/HarvestCircleScreenTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/identity/ui/HarvestCircleScreenTest.kt @@ -0,0 +1,384 @@ +package org.harvestcircle.identities.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.v2.runComposeUiTest +import org.harvestcircle.application.HarvestCircleRoute +import org.harvestcircle.application.IdentityEntryMode +import org.harvestcircle.application.RemovalImpactState +import org.harvestcircle.application.RemovalStatus +import org.harvestcircle.ffi.SessionStateDto +import org.harvestcircle.ffi.WireRecoveryAction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class HarvestCircleScreenTest { + @Test + fun rendersEveryNonReadyLifecycleRouteWithoutIdentityControls() = + runComposeUiTest { + var model by mutableStateOf(emptyUiModel().copy(route = HarvestCircleRoute.OPENING)) + setContent { HarvestCircleScreen(model, HarvestCircleUiActions()) } + + val routes = + listOf( + HarvestCircleRoute.OPENING to "lifecycle-opening", + HarvestCircleRoute.CHECKING_COMPATIBILITY to "lifecycle-compatibility", + HarvestCircleRoute.ACQUIRING_OWNERSHIP to "lifecycle-ownership", + HarvestCircleRoute.MIGRATING to "lifecycle-migrating", + HarvestCircleRoute.RECOVERING to "lifecycle-recovering", + HarvestCircleRoute.BLOCKED to "lifecycle-blocked", + HarvestCircleRoute.SHUTTING_DOWN to "lifecycle-shutting-down", + HarvestCircleRoute.FATAL to "lifecycle-fatal", + HarvestCircleRoute.CLOSED to "lifecycle-closed", + ) + routes.forEach { (route, tag) -> + model = emptyUiModel(problem = "Safe lifecycle problem").copy(route = route) + waitForIdle() + onNodeWithTag(tag).assertIsDisplayed() + onAllNodesWithTag("generate-key").assertCountEquals(0) + } + + model = emptyUiModel(problem = "Relay access is unavailable.").copy(route = HarvestCircleRoute.DEGRADED) + waitForIdle() + onNodeWithTag("identities-screen").assertIsDisplayed() + onNodeWithTag("identities-problem").assertIsDisplayed() + } + + @Test + fun inactiveScreenGeneratesAndImportsMaskedSecretInput() = + runComposeUiTest { + var importDraft by mutableStateOf("") + var identityEntryMode by mutableStateOf(IdentityEntryMode.CHOICE) + var generateCalls = 0 + var importCalls = 0 + setContent { + HarvestCircleScreen( + model = emptyUiModel(importDraft = importDraft).copy(identityEntryMode = identityEntryMode), + actions = + HarvestCircleUiActions( + chooseCreateIdentity = { identityEntryMode = IdentityEntryMode.CREATE }, + chooseImportIdentity = { identityEntryMode = IdentityEntryMode.IMPORT }, + cancelIdentityEntry = { identityEntryMode = IdentityEntryMode.CHOICE }, + editImportDraft = { importDraft = it }, + generateIdentity = { generateCalls += 1 }, + importSecretKey = { importCalls += 1 }, + ), + ) + } + + onNodeWithTag("identities-screen").assertIsDisplayed() + onNodeWithText("HarvestCircle").assertIsDisplayed() + onNodeWithTag("choose-create-identity").performClick() + onNodeWithTag("generate-key").performClick() + onNodeWithTag("cancel-identity-entry").performClick() + onNodeWithTag("choose-import-identity").performClick() + onNodeWithTag("import-nsec-input").assertIsFocused() + onNodeWithTag("import-nsec-input").performTextInput("nsec1secret") + onNodeWithTag("import-key").performClick() + + assertEquals(1, generateCalls) + assertEquals(1, importCalls) + assertEquals("nsec1secret", importDraft) + assertTrue( + onNodeWithTag("import-nsec-input").fetchSemanticsNode().config.any { + it.key.name == "Password" && it.value == Unit + }, + ) + } + + @Test + fun inactiveScreenShowsSafeFailureAndNoGenericFields() = + runComposeUiTest { + setContent { + HarvestCircleScreen( + model = emptyUiModel(problem = "The secret key is invalid."), + actions = HarvestCircleUiActions(), + ) + } + + onNodeWithText("The secret key is invalid.").assertIsDisplayed() + onNodeWithTag("identities-empty").assertIsDisplayed() + } + + @Test + fun generatedKeyBackupCopiesAndClearsOnlyAfterAcknowledgement() = + runComposeUiTest { + var backup: GeneratedKeyBackupUiModel? by mutableStateOf( + GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), + ) + var copied: String? = null + setContent { + HarvestCircleScreen( + model = emptyUiModel().copy(generatedKeyBackup = backup), + actions = + HarvestCircleUiActions( + copyText = { copied = it }, + acknowledgeGeneratedKeyBackup = { backup = null }, + ), + ) + } + + onNodeWithTag("generated-key-backup").assertIsDisplayed() + onAllNodesWithTag("identities-screen").assertCountEquals(0) + onAllNodesWithTag("generate-key").assertCountEquals(0) + onNodeWithTag("generated-nsec").assertIsDisplayed() + onNodeWithTag("copy-generated-key").performClick() + assertEquals("nsec1generated", copied) + + onNodeWithTag("acknowledge-key-backup").performClick() + onAllNodesWithTag("generated-key-backup").assertCountEquals(0) + onAllNodesWithTag("generated-nsec").assertCountEquals(0) + } + + @Test + fun generatedKeyRecoveryCanBeCancelledWithoutExposingIdentityControls() = + runComposeUiTest { + var backup: GeneratedKeyBackupUiModel? by mutableStateOf( + GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), + ) + var cancelled = 0 + setContent { + HarvestCircleScreen( + model = emptyUiModel().copy(generatedKeyBackup = backup), + actions = + HarvestCircleUiActions( + cancelGeneratedKeyBackup = { + cancelled += 1 + backup = null + }, + ), + ) + } + + onNodeWithTag("cancel-generated-key").performClick() + assertEquals(1, cancelled) + onAllNodesWithTag("generated-key-backup").assertCountEquals(0) + } + + @Test + fun savedIdentitiesSelectActivateAndRequireRemovalConfirmation() = + runComposeUiTest { + val first = identityUi("11".repeat(32), selected = true) + val second = identityUi("22".repeat(32), selected = false) + var pendingRemoval: String? by mutableStateOf(null) + val selected = mutableListOf<String>() + val activated = mutableListOf<String>() + var confirmations = 0 + setContent { + HarvestCircleScreen( + model = + emptyUiModel().copy( + identities = listOf(first, second), + pendingRemovalPublicKeyHex = pendingRemoval, + removalImpact = + pendingRemoval?.let { + RemovalImpactState(it, deletesLocalCredential = true, signsOut = true, expiresAtSeconds = 60) + }, + ), + actions = + HarvestCircleUiActions( + selectIdentity = selected::add, + activateIdentity = activated::add, + requestIdentityRemoval = { pendingRemoval = it }, + cancelIdentityRemoval = { pendingRemoval = null }, + confirmIdentityRemoval = { confirmations += 1 }, + ), + ) + } + + onNodeWithTag("saved-identity-list").assertIsDisplayed() + onNodeWithTag("identity-row:${first.publicKeyHex}").assertIsSelected() + onNodeWithTag("select-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() + onNodeWithTag("activate-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() + assertEquals(listOf(second.publicKeyHex), selected) + assertEquals(listOf(second.publicKeyHex), activated) + + onNodeWithTag("remove-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() + onNodeWithText("Its local credential will be deleted from the operating-system keyring.").assertIsDisplayed() + onNodeWithText("The active session will be signed out before removal.").assertIsDisplayed() + onNodeWithTag("remove-cancel", useUnmergedTree = true).performClick() + assertEquals(null, pendingRemoval) + onNodeWithTag("remove-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() + onNodeWithTag("remove-confirm", useUnmergedTree = true).performClick() + assertEquals(1, confirmations) + } + + @Test + fun savedIdentityListRemainsReachableForLargeRegistries() = + runComposeUiTest { + val identities = + (0 until 100).map { index -> + identityUi(index.toString(16).padStart(64, '0'), selected = index == 0) + } + setContent { + HarvestCircleScreen( + model = emptyUiModel().copy(identities = identities), + actions = HarvestCircleUiActions(), + ) + } + + val lastTag = "identity-row:${identities.last().publicKeyHex}" + onNodeWithTag("saved-identity-list").performScrollToNode(hasTestTag(lastTag)) + onNodeWithTag(lastTag).assertIsDisplayed() + } + + @Test + fun activeHomeShowsIdentityProfileRelayAndCommands() = + runComposeUiTest { + var refreshCalls = 0 + var signOutCalls = 0 + val identity = identityUi("33".repeat(32), selected = true) + val active = + ActiveIdentityUiModel( + identity = identity, + heading = "Alice", + relayState = "connected", + profileState = "fresh", + profile = + ProfileUiModel( + name = "alice", + displayName = "Alice", + nip05 = "alice@example.com", + about = "Local grower", + picture = "https://example.com/alice.png", + ), + ) + setContent { + HarvestCircleScreen( + model = + emptyUiModel().copy( + route = HarvestCircleRoute.ACTIVE_IDENTITY, + identities = listOf(identity), + activeIdentity = active, + configuredRelays = listOf("ws://localhost:8080"), + session = SessionStateDto.ACTIVE, + ), + actions = + HarvestCircleUiActions( + refreshActiveProfile = { refreshCalls += 1 }, + signOut = { signOutCalls += 1 }, + ), + ) + } + + onNodeWithTag("home-screen").assertIsDisplayed() + onNodeWithTag("active-npub").assertIsDisplayed() + onNodeWithTag("active-pubkey-hex").assertIsDisplayed() + onNodeWithTag("active-profile-name").assertIsDisplayed() + onNodeWithTag("active-profile-about").assertIsDisplayed() + onNodeWithTag("relay-state").assertIsDisplayed() + onNodeWithTag("profile-state").assertIsDisplayed() + onNodeWithText("ws://localhost:8080").assertIsDisplayed() + onNodeWithTag("refresh-profile").performClick() + onNodeWithTag("sign-out").performClick() + assertEquals(1, refreshCalls) + assertEquals(1, signOutCalls) + } + + @Test + fun activeIdentityCanOpenChooserWithoutDroppingCurrentSession() = + runComposeUiTest { + val first = identityUi("44".repeat(32), selected = true, active = true) + val second = identityUi("55".repeat(32), selected = false) + val active = + ActiveIdentityUiModel( + identity = first, + heading = first.label, + relayState = "connected", + profileState = "cached", + profile = ProfileUiModel("", "", "", "", ""), + ) + var chooserVisible by mutableStateOf(false) + var activated: String? = null + setContent { + HarvestCircleScreen( + model = + emptyUiModel().copy( + route = HarvestCircleRoute.ACTIVE_IDENTITY, + identities = listOf(first, second), + activeIdentity = active, + session = SessionStateDto.ACTIVE, + identityChooserVisible = chooserVisible, + ), + actions = + HarvestCircleUiActions( + showIdentityChooser = { chooserVisible = true }, + hideIdentityChooser = { chooserVisible = false }, + activateIdentity = { activated = it }, + ), + ) + } + + onNodeWithTag("switch-identity").performClick() + onNodeWithTag("identities-screen").assertIsDisplayed() + onNodeWithTag("activate-identity:${first.publicKeyHex}", useUnmergedTree = true).assertIsNotEnabled() + onNodeWithText("Active").assertIsDisplayed() + onNodeWithTag("activate-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() + assertEquals(second.publicKeyHex, activated) + assertEquals( + SessionStateDto.ACTIVE, + emptyUiModel() + .copy( + activeIdentity = active, + session = SessionStateDto.ACTIVE, + ).session, + ) + onNodeWithTag("return-home").performClick() + onNodeWithTag("home-screen").assertIsDisplayed() + } +} + +private fun emptyUiModel( + importDraft: String = "", + problem: String? = null, + importGuidance: String? = null, + recoveryAction: WireRecoveryAction = WireRecoveryAction.NONE, +) = HarvestCircleUiModel( + route = HarvestCircleRoute.IDENTITYS, + identities = emptyList(), + activeIdentity = null, + configuredRelays = emptyList(), + importDraft = importDraft, + generatedKeyBackup = null, + pendingRemovalPublicKeyHex = null, + removalImpact = null, + removalStatus = RemovalStatus.NONE, + lastRemovedPublicKeyHex = null, + identityChooserVisible = false, + identityEntryMode = IdentityEntryMode.CHOICE, + session = SessionStateDto.SIGNED_OUT, + busy = false, + problem = problem, + importGuidance = importGuidance, + recoveryAction = recoveryAction, +) + +private fun identityUi( + publicKeyHex: String, + selected: Boolean, + active: Boolean = false, +) = IdentityUiModel( + publicKeyHex = publicKeyHex, + npub = "npub1${publicKeyHex.take(12)}", + shortNpub = "npub1${publicKeyHex.take(12)}", + label = "Identity ${publicKeyHex.take(2)}", + signerAvailability = "available", + selected = selected, + active = active, +) diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/identity/ui/IdentityUiModelTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/identity/ui/IdentityUiModelTest.kt @@ -0,0 +1,127 @@ +package org.harvestcircle.identities.ui + +import org.harvestcircle.application.GeneratedKeyBackup +import org.harvestcircle.application.HarvestCircleStoreState +import org.harvestcircle.ffi.ActiveIdentityDto +import org.harvestcircle.ffi.AppLifecycleDto +import org.harvestcircle.ffi.AppSnapshotDto +import org.harvestcircle.ffi.IdentityDto +import org.harvestcircle.ffi.ProfileDto +import org.harvestcircle.ffi.ProfileLoadStateDto +import org.harvestcircle.ffi.RelayConnectionStateDto +import org.harvestcircle.ffi.SessionStateDto +import org.harvestcircle.ffi.SignerAvailabilityDto +import org.harvestcircle.ffi.SignerBindingKindDto +import org.harvestcircle.ffi.WireErrorCode +import org.harvestcircle.ffi.WireRecoveryAction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class IdentityUiModelTest { + @Test + fun mapsPublicNostrIdentityAndProfileState() { + val identity = identity() + val snapshot = + snapshot( + identity = identity, + active = + ActiveIdentityDto( + identity = identity, + relayState = RelayConnectionStateDto.CONNECTED, + profileState = ProfileLoadStateDto.FRESH, + profile = ProfileDto("alice", "Alice", "alice@example.com", "Farmer", "https://example.com/a.png"), + ), + ) + + val model = HarvestCircleStoreState(snapshot).toUiModel() + + assertEquals("Alice", model.activeIdentity?.heading) + assertEquals("connected", model.activeIdentity?.relayState) + assertEquals("fresh", model.activeIdentity?.profileState) + assertEquals("alice@example.com", model.activeIdentity?.profile?.nip05) + assertEquals(listOf("ws://localhost:8080"), model.configuredRelays) + assertFalse(model.identityChooserVisible) + assertFalse( + model.identities + .single() + .label + .contains("server", ignoreCase = true), + ) + assertTrue(model.identities.single().selected) + assertTrue(model.identities.single().active) + } + + @Test + fun mapsSafeProblemAndTransientBackupSeparatelyFromSnapshot() { + val state = + HarvestCircleStoreState( + snapshot = snapshot(), + generatedKeyBackup = GeneratedKeyBackup("npub1generated", "nsec1generated"), + problem = "Try again.", + ) + + val model = state.toUiModel() + + assertEquals("Try again.", model.problem) + assertEquals("nsec1generated", model.generatedKeyBackup?.nsec) + assertNull(state.snapshot.recoverableProblem) + } + + @Test + fun shortensOnlyLongNpubValues() { + assertEquals("npub1short", shortenNpub("npub1short")) + assertEquals("npub1abcdefghi…34567890", shortenNpub("npub1abcdefghijklmnopqrstuvwxyz1234567890")) + } + + @Test + fun mapsTypedImportFailuresToSpecificRepairGuidance() { + val invalid = + HarvestCircleStoreState( + snapshot = snapshot(), + lastFailureCode = WireErrorCode.INVALID_SECRET_KEY, + ).toUiModel() + val repair = + HarvestCircleStoreState( + snapshot = snapshot(), + lastFailureCode = WireErrorCode.CREDENTIAL_MISSING, + recoveryAction = WireRecoveryAction.REPAIR_CREDENTIAL, + ).toUiModel() + + assertEquals("Enter a valid nsec or 64-character hexadecimal secret key.", invalid.importGuidance) + assertEquals( + "This saved identity is missing its local credential. Re-enter its secret key to repair it.", + repair.importGuidance, + ) + } +} + +private fun snapshot( + identity: IdentityDto? = null, + active: ActiveIdentityDto? = null, +) = AppSnapshotDto( + revision = 1UL, + lifecycle = AppLifecycleDto.READY, + lifecycleError = null, + configuredRelays = listOf("ws://localhost:8080"), + identities = listOfNotNull(identity), + selectedPublicKeyHex = identity?.publicKeyHex, + session = if (active == null) SessionStateDto.SIGNED_OUT else SessionStateDto.ACTIVE, + sessionSubjectPublicKeyHex = active?.identity?.publicKeyHex, + sessionError = null, + activeIdentity = active, + recoverableProblem = null, +) + +private fun identity() = + IdentityDto( + publicKeyHex = "12".repeat(32), + npub = "npub1abcdefghijklmnopqrstuvwxyz1234567890", + displayLabel = "Alice", + signerBindingKind = SignerBindingKindDto.LOCAL_KEYRING, + signerAvailability = SignerAvailabilityDto.AVAILABLE, + createdAtSeconds = 1, + lastUsedAtSeconds = null, + ) diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ApplicationModels.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ApplicationModels.kt @@ -26,19 +26,10 @@ enum class SignerAvailability { Available, CredentialMissing, StoreUnavailable, - NotRequired, } -sealed interface SignerBindingKind { - data object LocalKeyring : SignerBindingKind - - data class Unsupported( - val protocol: String, - ) : SignerBindingKind { - init { - requireSafeText(protocol, "Signer protocol", 64) - } - } +enum class SignerBindingKind { + LocalKeyring, } data class SignerBindingSummary( diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/RuntimeContractsTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/RuntimeContractsTest.kt @@ -59,7 +59,7 @@ class RuntimeContractsTest { .size, ) assertEquals( - 4, + 3, SignerAvailability.entries .map(::availabilityName) .distinct() @@ -177,7 +177,6 @@ private fun availabilityName(value: SignerAvailability): String = SignerAvailability.Available -> "available" SignerAvailability.CredentialMissing -> "credential-missing" SignerAvailability.StoreUnavailable -> "store-unavailable" - SignerAvailability.NotRequired -> "not-required" } private fun relayName(value: RelayConnectionState): String = diff --git a/core/compatibility/harvestcircle-ffi-v4.properties b/core/compatibility/harvestcircle-ffi-v4.properties @@ -2,7 +2,7 @@ schema=harvestcircle.ffi.v4 contract.id=harvestcircle-desktop-ffi-v4 contract.major=4 contract.minor=0 -contract.hash=b54e9d096174cfdf2020b6cd2e7547b83f4071f0fed89e1cafe67aa2686a2893 +contract.hash=638a2d8d18bb4c26a4d68a5c6d34294fc0b76c51c068ea116f26d1b598f62a75 product.coordinate_digest=f81db525a0228782530799911879fb55cb25e8e631d09605fd5084e9bd88fbbe snapshot.schema=1 storage.schema.minimum=5 diff --git a/core/crates/harvestcircle_application/src/accounts.rs b/core/crates/harvestcircle_application/src/accounts.rs @@ -1,1822 +0,0 @@ -use std::sync::{Mutex, MutexGuard}; - -use crate::{ - AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, - Clock, DurableOperationKind, DurableOperationPhase, DurableOperationRepository, - DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic, - OperationId, OperationJournal, OperationPriorState, PendingAccountOperation, - RemovalConfirmationToken, SecretStore, StagedGeneratedKey, StateTransition, -}; -use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - Nsec, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, -}; - -pub struct GenerateAccountReceipt { - account: AccountSummary, - generated_nsec: Nsec, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ImportAccountReceipt { - account: AccountSummary, -} - -impl ImportAccountReceipt { - #[must_use] - pub const fn account(&self) -> &AccountSummary { - &self.account - } -} - -impl GenerateAccountReceipt { - #[must_use] - pub const fn account(&self) -> &AccountSummary { - &self.account - } - - #[must_use] - pub const fn generated_nsec(&self) -> &Nsec { - &self.generated_nsec - } -} - -impl AppCore { - /// Commits a staged generated key only after its recovery acknowledgement. - /// - /// # Errors - /// - /// Returns a safe conflict, keyring, persistence, or recovery error. - #[allow(clippy::too_many_arguments)] - pub fn commit_staged_generated_key( - &self, - request_id: &DurableRequestId, - staged: StagedGeneratedKey, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - operations: &(impl DurableOperationRepository + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<ImportAccountReceipt, SafeError> { - let expected_revision = staged.expected_revision(); - self.require_revision(expected_revision)?; - let (account, secret) = staged.into_commit_parts(); - self.persist_account_durable( - request_id, - DurableOperationKind::Create, - expected_revision, - &account, - secret, - None, - accounts, - app_state, - secrets, - operations, - clock, - )?; - Ok(ImportAccountReceipt { account }) - } - - /// Generates and commits one account under a durable caller request. - /// - /// # Errors - /// - /// Returns a safe conflict, keyring, persistence, or state error. Staged recovery transport - /// replaces this transitional generated-secret receipt in the custody phase. - #[allow(clippy::too_many_arguments)] - pub fn generate_account_durable( - &self, - request_id: &DurableRequestId, - expected_revision: u64, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - operations: &(impl DurableOperationRepository + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<GenerateAccountReceipt, SafeError> { - self.require_revision(expected_revision)?; - let generated = self.key_material().generate()?; - let (public_key, npub, secret, nsec) = generated.into_parts(); - let account = AccountSummary::new( - AccountIdentity::verify(public_key, npub.as_str().to_owned())?, - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(clock.now()), - None, - )?; - self.persist_account_durable( - request_id, - DurableOperationKind::Create, - expected_revision, - &account, - secret, - None, - accounts, - app_state, - secrets, - operations, - clock, - )?; - Ok(GenerateAccountReceipt { - account, - generated_nsec: nsec, - }) - } - - /// Imports or explicitly repairs one local account under a durable caller request. - /// - /// # Errors - /// - /// Returns a safe conflict, validation, keyring, persistence, or state error. - #[allow(clippy::too_many_arguments)] - pub fn import_secret_key_durable( - &self, - request_id: &DurableRequestId, - expected_revision: u64, - input: SecretKeyInput, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - operations: &(impl DurableOperationRepository + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<ImportAccountReceipt, SafeError> { - if let Some(existing) = operations.load_durable_operation(request_id)? { - return if existing - .terminal() - .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) - { - accounts - .find_account(existing.account())? - .map(|account| ImportAccountReceipt { account }) - .ok_or_else(recovery_required) - } else { - Err(recovery_required()) - }; - } - self.require_revision(expected_revision)?; - let imported = self.key_material().import(input)?; - let (public_key, npub, secret) = imported.into_parts(); - let previous = accounts.find_account(public_key)?; - if let Some(existing) = &previous - && (existing.signer().availability() != BindingAvailability::CredentialMissing - || secrets.contains(public_key)?) - { - return Err(account_exists()); - } - if previous.is_none() && secrets.contains(public_key)? { - return Err(account_exists()); - } - let account = if let Some(existing) = &previous { - existing.with_binding_availability(BindingAvailability::Available) - } else { - AccountSummary::new( - AccountIdentity::verify(public_key, npub.as_str().to_owned())?, - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(clock.now()), - None, - )? - }; - let kind = if previous.is_some() { - DurableOperationKind::Repair - } else { - DurableOperationKind::Import - }; - self.persist_account_durable( - request_id, - kind, - expected_revision, - &account, - secret, - previous.as_ref(), - accounts, - app_state, - secrets, - operations, - clock, - )?; - Ok(ImportAccountReceipt { account }) - } - - fn require_revision(&self, expected_revision: u64) -> Result<(), SafeError> { - if self.snapshot().revision().value() != expected_revision { - return Err(operation_conflict()); - } - Ok(()) - } - - #[allow(clippy::too_many_arguments)] - fn persist_account_durable( - &self, - request_id: &DurableRequestId, - kind: DurableOperationKind, - expected_revision: u64, - account: &AccountSummary, - secret: SecretKeyInput, - previous: Option<&AccountSummary>, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - operations: &(impl DurableOperationRepository + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<(), SafeError> { - let prior = OperationPriorState::new( - app_state.load_selected_account()?, - previous.map(|account| account.signer().availability()), - ); - match operations.begin_durable_operation( - request_id, - kind, - account.public_key(), - Some(expected_revision), - prior, - clock.now(), - )? { - DurableOperationStart::Started(_) => {} - DurableOperationStart::Existing(operation) => { - return if operation - .terminal() - .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) - { - Ok(()) - } else { - Err(recovery_required()) - }; - } - } - secrets.put(account.public_key(), secret)?; - operations.advance_durable_operation( - request_id, - DurableOperationPhase::IntentRecorded, - DurableOperationPhase::CredentialWritten, - clock.now(), - None, - )?; - previous.map_or_else( - || accounts.insert_account(account), - |_| accounts.update_account(account), - )?; - operations.advance_durable_operation( - request_id, - DurableOperationPhase::CredentialWritten, - DurableOperationPhase::MetadataCommitted, - clock.now(), - None, - )?; - app_state.save_selected_account(Some(account.public_key()))?; - operations.advance_durable_operation( - request_id, - DurableOperationPhase::MetadataCommitted, - DurableOperationPhase::SelectionCommitted, - clock.now(), - None, - )?; - let snapshot = self.apply_transition(StateTransition::ReplaceRegistry { - accounts: accounts.list_accounts()?, - selected: Some(account.public_key()), - })?; - operations.finalize_durable_operation( - request_id, - DurableOperationPhase::SelectionCommitted, - DurableTerminalOutcome::Completed, - Some(snapshot.revision().value()), - clock.now(), - )?; - Ok(()) - } - - /// Issues a single-use confirmation bound to the target and current revision. - /// - /// # Errors - /// - /// Returns a safe account or application-state error. - pub fn request_account_removal( - &self, - public_key: PublicKey, - clock: &(impl Clock + ?Sized), - ) -> Result<RemovalConfirmationToken, SafeError> { - self.issue_removal_token(public_key, clock.now()) - } - - pub fn cancel_account_removal(&self, token: RemovalConfirmationToken) -> bool { - self.cancel_removal_token(token) - } - - /// Permanently removes a confirmed account and selects a deterministic fallback. - /// - /// # Errors - /// - /// Returns a safe confirmation, credential, persistence, recovery, or state error. - pub fn confirm_account_removal( - &self, - token: RemovalConfirmationToken, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - journal: &(impl OperationJournal + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<crate::AppSnapshot, SafeError> { - let public_key = self.consume_removal_token(token, clock.now())?; - let registry = accounts.list_accounts()?; - let index = registry - .iter() - .position(|account| account.public_key() == public_key) - .ok_or_else(account_not_found)?; - let selected = if self.snapshot().selected_account() == Some(public_key) { - registry - .get(index + 1) - .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before))) - .map(AccountSummary::public_key) - } else { - self.snapshot().selected_account() - }; - let operation = - journal.begin_operation(AccountOperationKind::Remove, public_key, clock.now())?; - let was_active = self - .snapshot() - .active_account() - .is_some_and(|active| active.account().public_key() == public_key); - if was_active { - self.sign_out()?; - } - let account = ®istry[index]; - match secrets.delete(public_key) { - Ok(()) => {} - Err(error) - if error.code() == SafeErrorCode::CredentialMissing - && account.signer().availability() - == BindingAvailability::CredentialMissing => {} - Err(error) => return Err(error), - } - journal.update_operation( - operation, - AccountOperationPhase::CredentialDeleted, - clock.now(), - None, - )?; - accounts.remove_account(public_key)?; - app_state.save_selected_account(selected)?; - journal.update_operation( - operation, - AccountOperationPhase::MetadataDeleted, - clock.now(), - None, - )?; - journal.finalize_operation(operation)?; - self.apply_transition(StateTransition::ReplaceRegistryPreservingSession { - accounts: accounts.list_accounts()?, - selected, - }) - } - - /// Confirms and executes an expiring removal plan as a durable request. - /// - /// # Errors - /// - /// Returns a safe expiry, conflict, credential, persistence, or recovery error. - #[allow(clippy::too_many_arguments)] - pub fn confirm_account_removal_durable( - &self, - request_id: &DurableRequestId, - token: RemovalConfirmationToken, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - operations: &(impl DurableOperationRepository + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<crate::AppSnapshot, SafeError> { - let expected_revision = token.revision().value(); - let public_key = self.consume_removal_token(token, clock.now())?; - self.require_revision(expected_revision)?; - let registry = accounts.list_accounts()?; - let index = registry - .iter() - .position(|account| account.public_key() == public_key) - .ok_or_else(account_not_found)?; - let selected = if self.snapshot().selected_account() == Some(public_key) { - registry - .get(index + 1) - .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before))) - .map(AccountSummary::public_key) - } else { - self.snapshot().selected_account() - }; - let account = ®istry[index]; - match operations.begin_durable_operation( - request_id, - DurableOperationKind::Remove, - public_key, - Some(expected_revision), - OperationPriorState::new(selected, Some(account.signer().availability())), - clock.now(), - )? { - DurableOperationStart::Started(_) => {} - DurableOperationStart::Existing(operation) => { - return if operation - .terminal() - .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) - { - Ok(self.snapshot()) - } else { - Err(recovery_required()) - }; - } - } - if self - .snapshot() - .active_account() - .is_some_and(|active| active.account().public_key() == public_key) - { - self.sign_out()?; - } - match secrets.delete(public_key) { - Ok(()) => {} - Err(error) - if error.code() == SafeErrorCode::CredentialMissing - && account.signer().availability() - == BindingAvailability::CredentialMissing => {} - Err(error) => return Err(error), - } - operations.advance_durable_operation( - request_id, - DurableOperationPhase::IntentRecorded, - DurableOperationPhase::CredentialDeleted, - clock.now(), - None, - )?; - accounts.remove_account(public_key)?; - operations.advance_durable_operation( - request_id, - DurableOperationPhase::CredentialDeleted, - DurableOperationPhase::MetadataDeleted, - clock.now(), - None, - )?; - app_state.save_selected_account(selected)?; - operations.advance_durable_operation( - request_id, - DurableOperationPhase::MetadataDeleted, - DurableOperationPhase::SelectionCommitted, - clock.now(), - None, - )?; - let snapshot = - self.apply_transition(StateTransition::ReplaceRegistryPreservingSession { - accounts: accounts.list_accounts()?, - selected, - })?; - operations.finalize_durable_operation( - request_id, - DurableOperationPhase::SelectionCommitted, - DurableTerminalOutcome::Completed, - Some(snapshot.revision().value()), - clock.now(), - )?; - Ok(snapshot) - } - - /// Persists and publishes a saved account selection without activating it. - /// - /// # Errors - /// - /// Returns a safe account, persistence, or application-state error. - pub fn select_account( - &self, - public_key: PublicKey, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - ) -> Result<crate::AppSnapshot, SafeError> { - if accounts.find_account(public_key)?.is_none() { - return Err(account_not_found()); - } - app_state.save_selected_account(Some(public_key))?; - self.apply_transition(StateTransition::Select(public_key)) - } - - /// Generates, stores, and selects one local Nostr account without activating it. - /// - /// # Errors - /// - /// Returns a safe key, credential, persistence, or application-state error. - pub fn generate_account( - &self, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - journal: &(impl OperationJournal + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<GenerateAccountReceipt, SafeError> { - let generated = self.key_material().generate()?; - let (public_key, npub, secret, nsec) = generated.into_parts(); - let account = AccountSummary::new( - AccountIdentity::verify(public_key, npub.as_str().to_owned())?, - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(clock.now()), - None, - )?; - Self::persist_account_transaction( - AccountOperationKind::Add, - &account, - secret, - None, - accounts, - app_state, - secrets, - journal, - clock, - )?; - let registry = accounts.list_accounts()?; - self.apply_transition(StateTransition::ReplaceRegistry { - accounts: registry, - selected: Some(public_key), - })?; - Ok(GenerateAccountReceipt { - account, - generated_nsec: nsec, - }) - } - - /// Imports, stores, and selects one local Nostr account without activating it. - /// - /// # Errors - /// - /// Returns a safe key, credential, persistence, or application-state error. - pub fn import_secret_key( - &self, - input: SecretKeyInput, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - journal: &(impl OperationJournal + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<ImportAccountReceipt, SafeError> { - let imported = self.key_material().import(input)?; - let (public_key, npub, secret) = imported.into_parts(); - if let Some(existing) = accounts.find_account(public_key)? { - if existing.signer().availability() != BindingAvailability::CredentialMissing - || secrets.contains(public_key)? - { - return Err(account_exists()); - } - let repaired = existing.with_binding_availability(BindingAvailability::Available); - Self::persist_account_transaction( - AccountOperationKind::Import, - &repaired, - secret, - Some(&existing), - accounts, - app_state, - secrets, - journal, - clock, - )?; - self.apply_transition(StateTransition::ReplaceRegistry { - accounts: accounts.list_accounts()?, - selected: Some(public_key), - })?; - return Ok(ImportAccountReceipt { account: repaired }); - } - if secrets.contains(public_key)? { - return Err(account_exists()); - } - let account = AccountSummary::new( - AccountIdentity::verify(public_key, npub.as_str().to_owned())?, - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(clock.now()), - None, - )?; - Self::persist_account_transaction( - AccountOperationKind::Import, - &account, - secret, - None, - accounts, - app_state, - secrets, - journal, - clock, - )?; - self.apply_transition(StateTransition::ReplaceRegistry { - accounts: accounts.list_accounts()?, - selected: Some(public_key), - })?; - Ok(ImportAccountReceipt { account }) - } - - #[allow(clippy::too_many_arguments)] - fn persist_account_transaction( - kind: AccountOperationKind, - account: &AccountSummary, - secret: SecretKeyInput, - previous: Option<&AccountSummary>, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - journal: &(impl OperationJournal + ?Sized), - clock: &(impl Clock + ?Sized), - ) -> Result<(), SafeError> { - let public_key = account.public_key(); - let previous_selection = app_state.load_selected_account()?; - let operation = journal.begin_operation(kind, public_key, clock.now())?; - if let Err(error) = secrets.put(public_key, secret) { - let _ = journal.finalize_operation(operation); - return Err(error); - } - if let Err(error) = journal.update_operation( - operation, - AccountOperationPhase::CredentialWritten, - clock.now(), - None, - ) { - return compensate_account_write( - operation, - public_key, - error, - None, - previous_selection, - accounts, - app_state, - secrets, - journal, - clock, - ); - } - let metadata_result = previous.map_or_else( - || accounts.insert_account(account), - |_| accounts.update_account(account), - ); - if let Err(error) = metadata_result { - return compensate_account_write( - operation, - public_key, - error, - previous, - previous_selection, - accounts, - app_state, - secrets, - journal, - clock, - ); - } - if let Err(error) = app_state.save_selected_account(Some(public_key)) { - return compensate_account_write( - operation, - public_key, - error, - previous, - previous_selection, - accounts, - app_state, - secrets, - journal, - clock, - ); - } - journal.update_operation( - operation, - AccountOperationPhase::MetadataCommitted, - clock.now(), - None, - )?; - journal.finalize_operation(operation) - } -} - -#[allow(clippy::too_many_arguments)] -fn compensate_account_write( - operation: OperationId, - public_key: PublicKey, - original_error: SafeError, - previous: Option<&AccountSummary>, - previous_selection: Option<PublicKey>, - accounts: &(impl AccountRepository + ?Sized), - app_state: &(impl AppStateRepository + ?Sized), - secrets: &(impl SecretStore + ?Sized), - journal: &(impl OperationJournal + ?Sized), - clock: &(impl Clock + ?Sized), -) -> Result<(), SafeError> { - let metadata_rollback = if let Some(previous) = previous { - accounts.update_account(previous) - } else { - accounts.remove_account(public_key) - }; - let selection_rollback = app_state.save_selected_account(previous_selection); - let credential_rollback = secrets.delete(public_key); - if metadata_rollback.is_err() || selection_rollback.is_err() || credential_rollback.is_err() { - let _ = journal.update_operation( - operation, - AccountOperationPhase::CompensationPending, - clock.now(), - Some(OperationDiagnostic::CompensationFailed), - ); - return Err(recovery_required()); - } - let _ = journal.finalize_operation(operation); - Err(original_error) -} - -#[derive(Default)] -pub struct InMemoryOperationJournal { - state: Mutex<InMemoryJournalState>, -} - -#[derive(Default)] -struct InMemoryJournalState { - next_id: u64, - pending: Vec<PendingAccountOperation>, -} - -impl OperationJournal for InMemoryOperationJournal { - fn begin_operation( - &self, - kind: AccountOperationKind, - subject: PublicKey, - updated_at: harvestcircle_domain::UnixTimestamp, - ) -> Result<OperationId, SafeError> { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - state.next_id = state.next_id.checked_add(1).ok_or_else(recovery_required)?; - let id = OperationId::from_raw(state.next_id); - state.pending.push(PendingAccountOperation::new( - id, - kind, - subject, - AccountOperationPhase::IntentRecorded, - updated_at, - None, - )); - Ok(id) - } - - fn update_operation( - &self, - id: OperationId, - phase: AccountOperationPhase, - updated_at: harvestcircle_domain::UnixTimestamp, - diagnostic: Option<OperationDiagnostic>, - ) -> Result<(), SafeError> { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let operation = state - .pending - .iter_mut() - .find(|operation| operation.id() == id) - .ok_or_else(recovery_required)?; - *operation = PendingAccountOperation::new( - id, - operation.kind(), - operation.subject(), - phase, - updated_at, - diagnostic, - ); - Ok(()) - } - - fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> { - Ok(self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .pending - .clone()) - } - - fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError> { - self.state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .pending - .retain(|operation| operation.id() != id); - Ok(()) - } -} - -#[derive(Default)] -pub struct InMemoryAccountRepository { - state: Mutex<InMemoryAccountState>, -} - -#[derive(Default)] -struct InMemoryAccountState { - accounts: Vec<AccountSummary>, - selected: Option<PublicKey>, -} - -impl InMemoryAccountRepository { - fn state(&self) -> MutexGuard<'_, InMemoryAccountState> { - self.state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } -} - -impl AccountRepository for InMemoryAccountRepository { - fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { - Ok(self.state().accounts.clone()) - } - - fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { - Ok(self - .state() - .accounts - .iter() - .find(|account| account.public_key() == public_key) - .cloned()) - } - - fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> { - let mut state = self.state(); - if state - .accounts - .iter() - .any(|saved| saved.public_key() == account.public_key()) - { - return Err(account_exists()); - } - state.accounts.push(account.clone()); - state - .accounts - .sort_by_key(|saved| (saved.created_at().timestamp(), saved.public_key())); - Ok(()) - } - - fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { - let mut state = self.state(); - let saved = state - .accounts - .iter_mut() - .find(|saved| saved.public_key() == account.public_key()) - .ok_or_else(account_not_found)?; - *saved = account.clone(); - Ok(()) - } - - fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { - let mut state = self.state(); - state - .accounts - .retain(|account| account.public_key() != public_key); - if state.selected == Some(public_key) { - state.selected = None; - } - Ok(()) - } -} - -impl AppStateRepository for InMemoryAccountRepository { - fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { - Ok(self.state().selected) - } - - fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { - let mut state = self.state(); - if public_key.is_some_and(|key| { - !state - .accounts - .iter() - .any(|account| account.public_key() == key) - }) { - return Err(account_not_found()); - } - state.selected = public_key; - Ok(()) - } -} - -const fn account_exists() -> SafeError { - SafeError::new( - SafeErrorCode::AccountAlreadyExists, - SafeMessage::new("The Nostr account is already saved."), - ) -} - -const fn account_not_found() -> SafeError { - SafeError::new( - SafeErrorCode::AccountNotFound, - SafeMessage::new("The account was not found."), - ) -} - -const fn recovery_required() -> SafeError { - SafeError::new( - SafeErrorCode::PendingOperationRecoveryRequired, - SafeMessage::new("Account recovery is required before this operation can continue."), - ) -} - -const fn operation_conflict() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The account operation conflicts with the current application state."), - ) -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::{AtomicBool, Ordering}; - - use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, - }; - - use super::InMemoryAccountRepository; - use crate::{ - AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, Clock, - DurableOperationKind, DurableOperationPhase, FailureSecretStore, InMemoryOperationJournal, - InMemorySecretStore, OperationJournal, ProfileRefreshStatus, ProfileRepository, - RelayConfiguration, SecretStore, SecretStoreOperation, SessionState, StateTransition, - recovery::tests::{TestDurableRepository, operation as durable_operation}, - }; - - struct FixedClock; - - impl Clock for FixedClock { - fn now(&self) -> UnixTimestamp { - UnixTimestamp::from_seconds(10).expect("time") - } - } - - struct LateClock; - - impl Clock for LateClock { - fn now(&self) -> UnixTimestamp { - UnixTimestamp::from_seconds(311).expect("time") - } - } - - struct EmptyProfiles; - - impl ProfileRepository for EmptyProfiles { - fn load_profile( - &self, - _public_key: PublicKey, - ) -> Result<Option<crate::CachedProfile>, SafeError> { - Ok(None) - } - - fn save_profile(&self, _profile: &crate::CachedProfile) -> Result<(), SafeError> { - Ok(()) - } - - fn record_refresh_status( - &self, - _public_key: PublicKey, - _refreshed_at: UnixTimestamp, - _status: ProfileRefreshStatus, - ) -> Result<(), SafeError> { - Ok(()) - } - - fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> { - Ok(()) - } - } - - #[derive(Default)] - struct FailingUpdateJournal(InMemoryOperationJournal); - - impl OperationJournal for FailingUpdateJournal { - fn begin_operation( - &self, - kind: crate::AccountOperationKind, - subject: PublicKey, - updated_at: UnixTimestamp, - ) -> Result<crate::OperationId, SafeError> { - self.0.begin_operation(kind, subject, updated_at) - } - - fn update_operation( - &self, - _id: crate::OperationId, - _phase: AccountOperationPhase, - _updated_at: UnixTimestamp, - _diagnostic: Option<crate::OperationDiagnostic>, - ) -> Result<(), SafeError> { - Err(SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The test journal is unavailable."), - )) - } - - fn list_pending_operations( - &self, - ) -> Result<Vec<crate::PendingAccountOperation>, SafeError> { - self.0.list_pending_operations() - } - - fn finalize_operation(&self, id: crate::OperationId) -> Result<(), SafeError> { - self.0.finalize_operation(id) - } - } - - #[derive(Default)] - struct FailingInsertRepository { - inner: InMemoryAccountRepository, - } - - #[derive(Default)] - struct FailingSelectionRepository { - inner: InMemoryAccountRepository, - fail_next_selection: AtomicBool, - } - - impl AccountRepository for FailingSelectionRepository { - fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { - self.inner.list_accounts() - } - - fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { - self.inner.find_account(public_key) - } - - fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> { - self.inner.insert_account(account) - } - - fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { - self.inner.update_account(account) - } - - fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { - self.inner.remove_account(public_key) - } - } - - impl AppStateRepository for FailingSelectionRepository { - fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { - self.inner.load_selected_account() - } - - fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { - if self.fail_next_selection.swap(false, Ordering::SeqCst) { - return Err(SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The test selection repository is unavailable."), - )); - } - self.inner.save_selected_account(public_key) - } - } - - impl AccountRepository for FailingInsertRepository { - fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { - self.inner.list_accounts() - } - - fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { - self.inner.find_account(public_key) - } - - fn insert_account(&self, _account: &AccountSummary) -> Result<(), SafeError> { - Err(SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The test account repository is unavailable."), - )) - } - - fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { - self.inner.update_account(account) - } - - fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { - self.inner.remove_account(public_key) - } - } - - impl AppStateRepository for FailingInsertRepository { - fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { - self.inner.load_selected_account() - } - - fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { - self.inner.save_selected_account(public_key) - } - } - - #[test] - fn generate_account_stores_selects_and_returns_one_time_nsec_without_activation() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - - let receipt = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("generate"); - let public_key = receipt.account().public_key(); - assert_eq!(public_key.to_hex().len(), 64); - assert!(secrets.contains(public_key).expect("credential")); - assert_eq!( - accounts.load_selected_account().expect("selection"), - Some(public_key) - ); - assert_eq!(core.snapshot().selected_account(), Some(public_key)); - assert_eq!(core.snapshot().session(), SessionState::SignedOut); - assert!(core.snapshot().active_account().is_none()); - assert_eq!(receipt.generated_nsec().with_exposed_secret(str::len), 63); - assert!(!format!("{:?}", core.snapshot()).contains("nsec1")); - } - - #[test] - fn import_secret_key_accepts_nsec_and_hex_without_exposing_or_activating() { - for input in [ - "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - ] { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let receipt = core - .import_secret_key( - SecretKeyInput::parse(input.to_owned()).expect("input"), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect("import"); - let public_key = receipt.account().public_key(); - assert!(secrets.contains(public_key).expect("credential")); - assert_eq!(core.snapshot().selected_account(), Some(public_key)); - assert_eq!(core.snapshot().session(), SessionState::SignedOut); - assert!(!format!("{:?}", core.snapshot()).contains(input)); - } - } - - #[test] - fn import_secret_key_rejects_invalid_nsec_checksum_before_persistence() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let input = SecretKeyInput::parse( - "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(), - ) - .expect("domain shape"); - let error = core - .import_secret_key(input, &accounts, &accounts, &secrets, &journal, &FixedClock) - .expect_err("invalid import"); - assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); - assert!(core.snapshot().accounts().is_empty()); - } - - #[test] - fn duplicate_import_preserves_existing_credential_and_snapshot() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let import = || { - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), - ) - .expect("input") - }; - core.import_secret_key( - import(), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect("first import"); - let before = core.snapshot(); - let error = core - .import_secret_key( - import(), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect_err("duplicate"); - assert_eq!(error.code(), SafeErrorCode::AccountAlreadyExists); - assert_eq!(core.snapshot(), before); - assert_eq!(core.snapshot().accounts().len(), 1); - } - - #[test] - fn duplicate_import_repairs_only_explicit_missing_credential_account() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let input = || { - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), - ) - .expect("input") - }; - let imported = core.key_material().import(input()).expect("derive"); - let (public_key, npub, _) = imported.into_parts(); - let missing = AccountSummary::new( - AccountIdentity::verify(public_key, npub.as_str().to_owned()).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), - None, - AccountCreatedAt::new(FixedClock.now()), - None, - ) - .expect("missing account"); - accounts.insert_account(&missing).expect("missing metadata"); - accounts - .save_selected_account(Some(public_key)) - .expect("selection"); - core.apply_transition(StateTransition::ReplaceRegistry { - accounts: vec![missing], - selected: Some(public_key), - }) - .expect("registry"); - - let receipt = core - .import_secret_key( - input(), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect("repair"); - assert_eq!( - receipt.account().signer().availability(), - BindingAvailability::Available - ); - assert!(secrets.contains(public_key).expect("credential")); - assert_eq!(core.snapshot().accounts().len(), 1); - } - - #[test] - fn account_transaction_publishes_nothing_when_credential_write_fails() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = FailureSecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - secrets.fail_next(SecretStoreOperation::Put); - - let error = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .err() - .expect("credential failure"); - assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); - assert!(core.snapshot().accounts().is_empty()); - assert!( - journal - .list_pending_operations() - .expect("journal") - .is_empty() - ); - } - - #[test] - fn account_transaction_removes_written_credential_when_metadata_fails() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = FailingInsertRepository::default(); - let secrets = FailureSecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - - let error = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .err() - .expect("metadata failure"); - assert_eq!(error.code(), SafeErrorCode::StorageUnavailable); - let calls = secrets.calls(); - assert_eq!(calls[0].operation(), SecretStoreOperation::Put); - assert_eq!(calls[1].operation(), SecretStoreOperation::Delete); - assert_eq!(calls[0].public_key(), calls[1].public_key()); - assert!(core.snapshot().accounts().is_empty()); - assert!( - journal - .list_pending_operations() - .expect("journal") - .is_empty() - ); - } - - #[test] - fn account_transaction_rolls_back_metadata_and_credential_when_selection_fails() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = FailingSelectionRepository::default(); - let secrets = FailureSecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - accounts.fail_next_selection.store(true, Ordering::SeqCst); - - let error = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .err() - .expect("selection failure"); - - assert_eq!(error.code(), SafeErrorCode::StorageUnavailable); - assert!(accounts.list_accounts().expect("accounts").is_empty()); - assert_eq!(accounts.load_selected_account().expect("selection"), None); - let calls = secrets.calls(); - assert_eq!(calls[0].operation(), SecretStoreOperation::Put); - assert_eq!(calls[1].operation(), SecretStoreOperation::Delete); - assert_eq!(calls[0].public_key(), calls[1].public_key()); - assert!(core.snapshot().accounts().is_empty()); - assert!( - journal - .list_pending_operations() - .expect("journal") - .is_empty() - ); - } - - #[test] - fn account_transaction_retains_non_secret_journal_when_compensation_fails() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = FailingInsertRepository::default(); - let secrets = FailureSecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - secrets.fail_next(SecretStoreOperation::Delete); - - let error = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .err() - .expect("recovery required"); - assert_eq!( - error.code(), - SafeErrorCode::PendingOperationRecoveryRequired - ); - let pending = journal.list_pending_operations().expect("journal"); - assert_eq!(pending.len(), 1); - assert_eq!( - pending[0].phase(), - AccountOperationPhase::CompensationPending - ); - assert!(!format!("{pending:?}").contains("nsec1")); - assert!(core.snapshot().accounts().is_empty()); - } - - #[test] - fn select_account_persists_existing_choice_without_activating() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let first = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("first") - .account() - .public_key(); - core.generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("second"); - - let selected = core - .select_account(first, &accounts, &accounts) - .expect("select first"); - assert_eq!(selected.selected_account(), Some(first)); - assert_eq!(selected.session(), SessionState::SignedOut); - assert!(selected.active_account().is_none()); - assert_eq!( - accounts.load_selected_account().expect("saved"), - Some(first) - ); - let missing = core - .select_account( - PublicKey::from_hex( - "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", - ) - .expect("unknown public key"), - &accounts, - &accounts, - ) - .expect_err("missing account"); - assert_eq!(missing.code(), SafeErrorCode::AccountNotFound); - assert_eq!(core.snapshot(), selected); - } - - #[test] - fn remove_account_requires_fresh_single_use_confirmation_and_selects_next_fallback() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let first = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("first") - .account() - .public_key(); - let second = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("second") - .account() - .public_key(); - core.select_account(first, &accounts, &accounts) - .expect("select first"); - let stale = core - .request_account_removal(first, &FixedClock) - .expect("stale token"); - core.select_account(second, &accounts, &accounts) - .expect("change revision"); - let stale_error = core - .confirm_account_removal(stale, &accounts, &accounts, &secrets, &journal, &FixedClock) - .expect_err("stale token"); - assert_eq!(stale_error.code(), SafeErrorCode::InvalidApplicationState); - assert_eq!(core.snapshot().accounts().len(), 2); - - core.select_account(first, &accounts, &accounts) - .expect("reselect first"); - let token = core - .request_account_removal(first, &FixedClock) - .expect("token"); - let removed = core - .confirm_account_removal(token, &accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("remove"); - assert_eq!(removed.accounts().len(), 1); - assert_eq!(removed.selected_account(), Some(second)); - assert!(!secrets.contains(first).expect("credential removed")); - assert_eq!(removed.session(), SessionState::SignedOut); - } - - #[test] - fn removal_preflight_reports_impact_expires_and_can_be_cancelled() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let account = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("account") - .account() - .public_key(); - let expired = core - .request_account_removal(account, &FixedClock) - .expect("plan"); - assert!(expired.impact().deletes_local_credential()); - assert!(!expired.impact().signs_out()); - assert!( - core.confirm_account_removal( - expired, &accounts, &accounts, &secrets, &journal, &LateClock, - ) - .is_err() - ); - let cancelled = core - .request_account_removal(account, &FixedClock) - .expect("replacement plan"); - assert!(core.cancel_account_removal(cancelled)); - assert_eq!(core.snapshot().accounts().len(), 1); - } - - #[test] - fn import_rejects_orphan_credentials_and_durable_nonterminal_replays() { - const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let material = core - .key_material() - .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret")) - .expect("key material"); - let (public_key, _npub, secret) = material.into_parts(); - secrets.put(public_key, secret).expect("orphan credential"); - - assert_eq!( - core.import_secret_key( - SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect_err("orphan credential must fail") - .code(), - SafeErrorCode::AccountAlreadyExists - ); - - let pending = durable_operation( - DurableOperationKind::Import, - DurableOperationPhase::IntentRecorded, - public_key, - None, - ); - let request_id = pending.request_id().clone(); - let operations = TestDurableRepository::new(pending); - assert_eq!( - core.import_secret_key_durable( - &request_id, - core.snapshot().revision().value(), - SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), - &accounts, - &accounts, - &secrets, - &operations, - &FixedClock, - ) - .expect_err("unfinished replay must require recovery") - .code(), - SafeErrorCode::PendingOperationRecoveryRequired - ); - } - - #[test] - fn durable_import_covers_new_and_missing_credential_repair_paths() { - const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; - for repair in [false, true] { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let material = core - .key_material() - .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret")) - .expect("key material"); - let (public_key, npub, secret) = material.into_parts(); - drop(secret); - if repair { - let account = AccountSummary::new( - AccountIdentity::verify(public_key, npub.as_str().to_owned()) - .expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), - None, - AccountCreatedAt::new(FixedClock.now()), - None, - ) - .expect("account"); - accounts.insert_account(&account).expect("insert account"); - accounts - .save_selected_account(Some(public_key)) - .expect("selection"); - core.apply_transition(StateTransition::BootstrapRegistry { - accounts: vec![account], - selected: Some(public_key), - }) - .expect("registry"); - } else { - core.bootstrap().expect("bootstrap"); - } - let kind = if repair { - DurableOperationKind::Repair - } else { - DurableOperationKind::Import - }; - let pending = durable_operation( - kind, - DurableOperationPhase::IntentRecorded, - public_key, - repair.then_some(BindingAvailability::CredentialMissing), - ); - let request_id = pending.request_id().clone(); - let operations = TestDurableRepository::fresh(pending); - let receipt = core - .import_secret_key_durable( - &request_id, - core.snapshot().revision().value(), - SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), - &accounts, - &accounts, - &secrets, - &operations, - &FixedClock, - ) - .expect("durable import"); - assert_eq!(receipt.account().public_key(), public_key); - assert_eq!( - operations.operation().phase(), - DurableOperationPhase::Finalized - ); - } - } - - #[test] - fn removal_of_unselected_account_preserves_the_current_selection() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let first = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("first") - .account() - .public_key(); - let second = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("second") - .account() - .public_key(); - let token = core - .request_account_removal(first, &FixedClock) - .expect("removal token"); - let snapshot = core - .confirm_account_removal(token, &accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("remove unselected account"); - assert_eq!(snapshot.selected_account(), Some(second)); - - let missing = crate::test_support::valid_test_public_key(99).expect("missing key"); - assert!(accounts.insert_account(&snapshot.accounts()[0]).is_err()); - assert!(accounts.save_selected_account(Some(missing)).is_err()); - - let third = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("third") - .account() - .public_key(); - let token = core - .request_account_removal(second, &FixedClock) - .expect("durable removal token"); - let pending = durable_operation( - DurableOperationKind::Remove, - DurableOperationPhase::IntentRecorded, - second, - Some(BindingAvailability::Available), - ); - let request_id = pending.request_id().clone(); - let operations = TestDurableRepository::fresh(pending); - let snapshot = core - .confirm_account_removal_durable( - &request_id, - token, - &accounts, - &accounts, - &secrets, - &operations, - &FixedClock, - ) - .expect("durable unselected removal"); - assert_eq!(snapshot.selected_account(), Some(third)); - } - - #[test] - fn duplicate_missing_binding_with_orphan_credential_fails_closed() { - const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - let material = core - .key_material() - .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret")) - .expect("key material"); - let (public_key, npub, secret) = material.into_parts(); - let account = AccountSummary::new( - AccountIdentity::verify(public_key, npub.as_str().to_owned()).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), - None, - AccountCreatedAt::new(FixedClock.now()), - None, - ) - .expect("account"); - accounts.insert_account(&account).expect("insert account"); - accounts - .save_selected_account(Some(public_key)) - .expect("selection"); - secrets.put(public_key, secret).expect("credential"); - core.apply_transition(StateTransition::BootstrapRegistry { - accounts: vec![account], - selected: Some(public_key), - }) - .expect("registry"); - - assert_eq!( - core.import_secret_key( - SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect_err("orphan credential must fail") - .code(), - SafeErrorCode::AccountAlreadyExists - ); - let pending = durable_operation( - DurableOperationKind::Repair, - DurableOperationPhase::IntentRecorded, - public_key, - Some(BindingAvailability::CredentialMissing), - ); - let request_id = pending.request_id().clone(); - let operations = TestDurableRepository::fresh(pending); - assert_eq!( - core.import_secret_key_durable( - &request_id, - core.snapshot().revision().value(), - SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), - &accounts, - &accounts, - &secrets, - &operations, - &FixedClock, - ) - .expect_err("orphan durable credential must fail") - .code(), - SafeErrorCode::AccountAlreadyExists - ); - } - - #[test] - fn removing_an_active_account_signs_out_for_legacy_and_durable_requests() { - for durable in [false, true] { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = InMemoryOperationJournal::default(); - core.bootstrap().expect("bootstrap"); - let public_key = core - .import_secret_key( - SecretKeyInput::parse( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" - .to_owned(), - ) - .expect("secret"), - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect("account") - .account() - .public_key(); - core.activate_account( - public_key, - &accounts, - &accounts, - &EmptyProfiles, - &secrets, - &FixedClock, - ) - .expect("activate account"); - let token = core - .request_account_removal(public_key, &FixedClock) - .expect("removal token"); - let snapshot = if durable { - let pending = durable_operation( - DurableOperationKind::Remove, - DurableOperationPhase::IntentRecorded, - public_key, - Some(BindingAvailability::Available), - ); - let request_id = pending.request_id().clone(); - let operations = TestDurableRepository::fresh(pending); - core.confirm_account_removal_durable( - &request_id, - token, - &accounts, - &accounts, - &secrets, - &operations, - &FixedClock, - ) - .expect("durable removal") - } else { - core.confirm_account_removal( - token, - &accounts, - &accounts, - &secrets, - &journal, - &FixedClock, - ) - .expect("removal") - }; - assert_eq!(snapshot.session(), SessionState::SignedOut); - } - } - - #[test] - fn account_transaction_compensates_a_journal_phase_failure() { - let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); - let secrets = InMemorySecretStore::default(); - let journal = FailingUpdateJournal::default(); - core.bootstrap().expect("bootstrap"); - - assert_eq!( - core.generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .err() - .expect("journal failure must be returned") - .code(), - SafeErrorCode::StorageUnavailable - ); - assert!(accounts.list_accounts().unwrap().is_empty()); - } -} diff --git a/core/crates/harvestcircle_application/src/actor.rs b/core/crates/harvestcircle_application/src/actor.rs @@ -2,8 +2,8 @@ use std::num::{NonZeroU64, NonZeroUsize}; use std::time::Instant; use harvestcircle_domain::{ - AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, - SafeMessage, + NostrIdentityReference, PublicKey, SafeError, SafeErrorCode, SafeMessage, SignerAvailability, + SignerBinding, }; use tokio::sync::{mpsc, oneshot}; @@ -38,44 +38,45 @@ impl SessionGeneration { } #[derive(Clone, Debug, Eq, PartialEq)] -pub struct ForegroundSessionBinding { - identity: AccountIdentity, - signer: LocalSignerBinding, +pub struct ActiveSessionBinding { + identity: NostrIdentityReference, + signer_binding: SignerBinding, generation: SessionGeneration, } -impl ForegroundSessionBinding { +impl ActiveSessionBinding { /// Binds one foreground session to a ready local signer and generation. /// /// # Errors /// - /// Returns a safe state error when account and binding differ or when the + /// Returns a safe state error when identity and binding differ or when the /// signer is unavailable. pub fn new( - identity: AccountIdentity, - signer: LocalSignerBinding, + identity: NostrIdentityReference, + signer_binding: impl Into<SignerBinding>, generation: SessionGeneration, ) -> Result<Self, SafeError> { - if identity.public_key() != signer.account() - || signer.availability() != BindingAvailability::Available + let signer_binding = signer_binding.into(); + if identity.public_key() != signer_binding.identity() + || signer_binding.availability() != SignerAvailability::Available { return Err(invalid_foreground_session()); } Ok(Self { identity, - signer, + signer_binding, generation, }) } #[must_use] - pub const fn identity(&self) -> &AccountIdentity { + pub const fn identity(&self) -> &NostrIdentityReference { &self.identity } #[must_use] - pub const fn signer(&self) -> LocalSignerBinding { - self.signer + pub const fn signer_binding(&self) -> SignerBinding { + self.signer_binding } #[must_use] @@ -94,8 +95,8 @@ const fn invalid_foreground_session() -> SafeError { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct TaskCorrelation { request_id: RequestId, - account: PublicKey, - binding: LocalSignerBinding, + identity: PublicKey, + binding: SignerBinding, expected_revision: SnapshotRevision, session_generation: SessionGeneration, } @@ -104,14 +105,14 @@ impl TaskCorrelation { #[must_use] pub const fn new( request_id: RequestId, - account: PublicKey, - binding: LocalSignerBinding, + identity: PublicKey, + binding: SignerBinding, expected_revision: SnapshotRevision, session_generation: SessionGeneration, ) -> Self { Self { request_id, - account, + identity, binding, expected_revision, session_generation, @@ -124,12 +125,12 @@ impl TaskCorrelation { } #[must_use] - pub const fn account(self) -> PublicKey { - self.account + pub const fn identity(self) -> PublicKey { + self.identity } #[must_use] - pub const fn binding(self) -> LocalSignerBinding { + pub const fn binding(self) -> SignerBinding { self.binding } @@ -572,11 +573,11 @@ mod tests { use std::num::NonZeroUsize; use std::time::{Duration, Instant}; - use harvestcircle_domain::{AccountIdentity, BindingAvailability, LocalSignerBinding}; + use harvestcircle_domain::{LocalKeyringBinding, NostrIdentityReference, SignerAvailability}; use crate::{ - ActorMailbox, CommandContext, CommandReceipt, CommandRejection, CommandResult, - CommandSubmission, ForegroundSessionBinding, LifecycleGate, RequestId, RuntimeCommandClass, + ActiveSessionBinding, ActorMailbox, CommandContext, CommandReceipt, CommandRejection, + CommandResult, CommandSubmission, LifecycleGate, RequestId, RuntimeCommandClass, RuntimeLifecycle, SessionGeneration, }; @@ -591,49 +592,49 @@ mod tests { #[test] fn foreground_session_requires_matching_available_binding_and_generation() { let public_key = crate::test_support::valid_test_public_key(3).expect("valid public key"); - let identity = AccountIdentity::derive(public_key).expect("identity"); + let identity = NostrIdentityReference::derive(public_key).expect("identity"); let generation = SessionGeneration::from_value(4); - let session = ForegroundSessionBinding::new( + let session = ActiveSessionBinding::new( identity.clone(), - LocalSignerBinding::new(public_key, BindingAvailability::Available), + LocalKeyringBinding::new(public_key, SignerAvailability::Available), generation, ) .expect("session"); assert_eq!(session.identity(), &identity); - assert_eq!(session.signer().account(), public_key); + assert_eq!(session.signer_binding().identity(), public_key); assert_eq!(session.generation(), generation); let correlation = super::TaskCorrelation::new( RequestId::new(8).expect("request"), public_key, - session.signer(), + session.signer_binding(), crate::SnapshotRevision::from_value(9), generation, ); assert_eq!(correlation.request_id().get(), 8); - assert_eq!(correlation.account(), public_key); - assert_eq!(correlation.binding(), session.signer()); + assert_eq!(correlation.identity(), public_key); + assert_eq!(correlation.binding(), session.signer_binding()); assert_eq!(correlation.expected_revision().value(), 9); assert_eq!(correlation.session_generation(), generation); assert!( - ForegroundSessionBinding::new( + ActiveSessionBinding::new( identity.clone(), - LocalSignerBinding::new( + LocalKeyringBinding::new( harvestcircle_domain::PublicKey::from_hex( "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", ) .expect("different valid public key"), - BindingAvailability::Available, + SignerAvailability::Available, ), generation, ) .is_err() ); assert!( - ForegroundSessionBinding::new( + ActiveSessionBinding::new( identity, - LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), + LocalKeyringBinding::new(public_key, SignerAvailability::CredentialMissing), generation, ) .is_err() diff --git a/core/crates/harvestcircle_application/src/app_core.rs b/core/crates/harvestcircle_application/src/app_core.rs @@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex, MutexGuard}; use harvestcircle_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp}; use crate::{ - AccountRepository, AppSnapshot, AppStateRepository, KeyMaterialProvider, RelayConfiguration, + AppSnapshot, AppStateRepository, IdentityRepository, KeyMaterialProvider, RelayConfiguration, SnapshotRevision, StateMachine, StateTransition, }; @@ -120,17 +120,20 @@ impl AppCore { /// durable state cannot be read or violates application invariants. pub fn bootstrap_from( &self, - accounts: &(impl AccountRepository + ?Sized), + identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), ) -> Result<AppSnapshot, SafeError> { - let loaded = accounts.list_accounts().and_then(|accounts| { + let loaded = identities.list_identities().and_then(|identities| { app_state - .load_selected_account() - .map(|selected| (accounts, selected)) + .load_selected_identity() + .map(|selected| (identities, selected)) }); match loaded { - Ok((accounts, selected)) => { - self.apply_transition(StateTransition::BootstrapRegistry { accounts, selected }) + Ok((identities, selected)) => { + self.apply_transition(StateTransition::BootstrapRegistry { + identities, + selected, + }) } Err(error) => { self.apply_transition(StateTransition::Fatal(error))?; @@ -159,17 +162,17 @@ impl AppCore { now: UnixTimestamp, ) -> Result<RemovalConfirmationToken, SafeError> { let mut state = self.lock_state(); - let Some(account) = state + let Some(identity) = state .state_machine .snapshot() - .accounts() + .identities() .iter() - .find(|account| account.public_key() == public_key) + .find(|identity| identity.public_key() == public_key) else { - return Err(account_not_found()); + return Err(identity_not_found()); }; - let deletes_local_credential = account.signer().availability() - != harvestcircle_domain::BindingAvailability::CredentialMissing; + let deletes_local_credential = identity.signer_binding().availability() + != harvestcircle_domain::SignerAvailability::CredentialMissing; let id = state.next_removal_token; state.next_removal_token = id.checked_add(1).ok_or_else(invalid_application_state)?; let revision = state.state_machine.snapshot().revision(); @@ -184,8 +187,8 @@ impl AppCore { signs_out: state .state_machine .snapshot() - .active_account() - .is_some_and(|active| active.account().public_key() == public_key), + .active_identity() + .is_some_and(|active| active.identity().public_key() == public_key), }; state.removal_tokens.insert( id, @@ -248,22 +251,22 @@ impl AppCore { const fn invalid_application_state() -> SafeError { SafeError::new( SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The account removal confirmation is no longer valid."), + SafeMessage::new("The identity removal confirmation is no longer valid."), ) } -const fn account_not_found() -> SafeError { +const fn identity_not_found() -> SafeError { SafeError::new( - SafeErrorCode::AccountNotFound, - SafeMessage::new("The account was not found."), + SafeErrorCode::IdentityNotFound, + SafeMessage::new("The identity was not found."), ) } #[cfg(test)] mod tests { use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - UnixTimestamp, + IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, + SignerAvailability, UnixTimestamp, }; use crate::{AppCore, AppLifecycle, RelayConfiguration, StateTransition}; @@ -294,16 +297,16 @@ mod tests { fn removal_impact_matches_missing_local_binding() { let core = AppCore::in_memory(RelayConfiguration::default()); let public_key = crate::test_support::valid_test_public_key(9).expect("valid public key"); - let account = AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), + let identity = NostrIdentity::new( + NostrIdentityReference::derive(public_key).expect("identity"), + LocalKeyringBinding::new(public_key, SignerAvailability::CredentialMissing), None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + IdentityCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), None, ) - .expect("account"); + .expect("identity"); core.apply_transition(StateTransition::BootstrapRegistry { - accounts: vec![account], + identities: vec![identity], selected: Some(public_key), }) .expect("registry"); @@ -321,16 +324,16 @@ mod tests { let core = AppCore::in_memory(RelayConfiguration::default()); let public_key = crate::test_support::valid_test_public_key(7).expect("public key"); let other_key = crate::test_support::valid_test_public_key(8).expect("other key"); - let account = AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), + let identity = NostrIdentity::new( + NostrIdentityReference::derive(public_key).expect("identity"), + LocalKeyringBinding::new(public_key, SignerAvailability::Available), None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + IdentityCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), None, ) - .expect("account"); + .expect("identity"); core.apply_transition(StateTransition::BootstrapRegistry { - accounts: vec![account], + identities: vec![identity], selected: Some(public_key), }) .expect("registry"); diff --git a/core/crates/harvestcircle_application/src/custody.rs b/core/crates/harvestcircle_application/src/custody.rs @@ -4,8 +4,8 @@ use std::time::Duration; use crate::KeyMaterialProvider; use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - Nsec, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, + IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, Nsec, SafeError, + SafeErrorCode, SafeMessage, SecretKeyInput, SignerAvailability, UnixTimestamp, }; pub const GENERATED_KEY_STAGE_TTL: Duration = Duration::from_mins(5); @@ -26,14 +26,14 @@ impl RecoveryStageId { #[derive(Clone, Debug, Eq, PartialEq)] pub struct GeneratedKeyStageView { - account: AccountSummary, + identity: NostrIdentity, expires_at: UnixTimestamp, } impl GeneratedKeyStageView { #[must_use] - pub const fn account(&self) -> &AccountSummary { - &self.account + pub const fn identity(&self) -> &NostrIdentity { + &self.identity } #[must_use] @@ -44,7 +44,7 @@ impl GeneratedKeyStageView { pub struct StagedGeneratedKey { id: RecoveryStageId, - account: AccountSummary, + identity: NostrIdentity, secret: SecretKeyInput, expected_revision: u64, expires_at: UnixTimestamp, @@ -54,7 +54,7 @@ impl StagedGeneratedKey { #[must_use] pub fn view(&self) -> GeneratedKeyStageView { GeneratedKeyStageView { - account: self.account.clone(), + identity: self.identity.clone(), expires_at: self.expires_at, } } @@ -70,13 +70,13 @@ impl StagedGeneratedKey { } #[must_use] - pub const fn account(&self) -> &AccountSummary { - &self.account + pub const fn identity(&self) -> &NostrIdentity { + &self.identity } #[must_use] - pub fn into_commit_parts(self) -> (AccountSummary, SecretKeyInput) { - (self.account, self.secret) + pub fn into_commit_parts(self) -> (NostrIdentity, SecretKeyInput) { + (self.identity, self.secret) } } @@ -143,11 +143,11 @@ impl GeneratedKeyStage { } let generated = key_material.generate()?; let (public_key, npub, secret, recovery_nsec) = generated.into_parts(); - let account = AccountSummary::new( - AccountIdentity::verify(public_key, npub.as_str().to_owned())?, - LocalSignerBinding::new(public_key, BindingAvailability::Available), + let identity = NostrIdentity::new( + NostrIdentityReference::verify(public_key, npub.as_str().to_owned())?, + LocalKeyringBinding::new(public_key, SignerAvailability::Available), None, - AccountCreatedAt::new(now), + IdentityCreatedAt::new(now), None, )?; let ttl = @@ -159,7 +159,7 @@ impl GeneratedKeyStage { .ok_or_else(invalid_stage_expiry)?; let pending = StagedGeneratedKey { id, - account, + identity, secret, expected_revision, expires_at, @@ -263,7 +263,7 @@ mod tests { assert!(handle.take_recovery_nsec().is_err()); assert!(stage.cancel()); assert!(!stage.cancel()); - assert!(format!("{view:?}").contains(view.account().npub().as_str())); + assert!(format!("{view:?}").contains(view.identity().npub().as_str())); assert!(!format!("{view:?}").contains("nsec1")); } diff --git a/core/crates/harvestcircle_application/src/identities.rs b/core/crates/harvestcircle_application/src/identities.rs @@ -0,0 +1,1868 @@ +use std::sync::{Mutex, MutexGuard}; + +use crate::{ + AppCore, AppStateRepository, Clock, DurableOperationKind, DurableOperationPhase, + DurableOperationRepository, DurableOperationStart, DurableRequestId, DurableTerminalOutcome, + IdentityOperationKind, IdentityOperationPhase, IdentityRepository, OperationDiagnostic, + OperationId, OperationJournal, OperationPriorState, PendingIdentityOperation, + RemovalConfirmationToken, SecretStore, StagedGeneratedKey, StateTransition, +}; +use harvestcircle_domain::{ + IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, Nsec, PublicKey, + SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, SignerAvailability, +}; + +pub struct GenerateIdentityReceipt { + identity: NostrIdentity, + generated_nsec: Nsec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ImportIdentityReceipt { + identity: NostrIdentity, +} + +impl ImportIdentityReceipt { + #[must_use] + pub const fn identity(&self) -> &NostrIdentity { + &self.identity + } +} + +impl GenerateIdentityReceipt { + #[must_use] + pub const fn identity(&self) -> &NostrIdentity { + &self.identity + } + + #[must_use] + pub const fn generated_nsec(&self) -> &Nsec { + &self.generated_nsec + } +} + +impl AppCore { + /// Commits a staged generated key only after its recovery acknowledgement. + /// + /// # Errors + /// + /// Returns a safe conflict, keyring, persistence, or recovery error. + #[allow(clippy::too_many_arguments)] + pub fn commit_staged_generated_key( + &self, + request_id: &DurableRequestId, + staged: StagedGeneratedKey, + identities: &(impl IdentityRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportIdentityReceipt, SafeError> { + let expected_revision = staged.expected_revision(); + self.require_revision(expected_revision)?; + let (identity, secret) = staged.into_commit_parts(); + self.persist_identity_durable( + request_id, + DurableOperationKind::Create, + expected_revision, + &identity, + secret, + None, + identities, + app_state, + secrets, + operations, + clock, + )?; + Ok(ImportIdentityReceipt { identity }) + } + + /// Generates and commits one identity under a durable caller request. + /// + /// # Errors + /// + /// Returns a safe conflict, keyring, persistence, or state error. Staged recovery transport + /// replaces this transitional generated-secret receipt in the custody phase. + #[allow(clippy::too_many_arguments)] + pub fn generate_identity_durable( + &self, + request_id: &DurableRequestId, + expected_revision: u64, + identities: &(impl IdentityRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<GenerateIdentityReceipt, SafeError> { + self.require_revision(expected_revision)?; + let generated = self.key_material().generate()?; + let (public_key, npub, secret, nsec) = generated.into_parts(); + let identity = NostrIdentity::new( + NostrIdentityReference::verify(public_key, npub.as_str().to_owned())?, + LocalKeyringBinding::new(public_key, SignerAvailability::Available), + None, + IdentityCreatedAt::new(clock.now()), + None, + )?; + self.persist_identity_durable( + request_id, + DurableOperationKind::Create, + expected_revision, + &identity, + secret, + None, + identities, + app_state, + secrets, + operations, + clock, + )?; + Ok(GenerateIdentityReceipt { + identity, + generated_nsec: nsec, + }) + } + + /// Imports or explicitly repairs one local identity under a durable caller request. + /// + /// # Errors + /// + /// Returns a safe conflict, validation, keyring, persistence, or state error. + #[allow(clippy::too_many_arguments)] + pub fn import_secret_key_durable( + &self, + request_id: &DurableRequestId, + expected_revision: u64, + input: SecretKeyInput, + identities: &(impl IdentityRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportIdentityReceipt, SafeError> { + if let Some(existing) = operations.load_durable_operation(request_id)? { + return if existing + .terminal() + .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) + { + identities + .find_identity(existing.identity())? + .map(|identity| ImportIdentityReceipt { identity }) + .ok_or_else(recovery_required) + } else { + Err(recovery_required()) + }; + } + self.require_revision(expected_revision)?; + let imported = self.key_material().import(input)?; + let (public_key, npub, secret) = imported.into_parts(); + let previous = identities.find_identity(public_key)?; + if let Some(existing) = &previous + && (existing.signer_binding().availability() != SignerAvailability::CredentialMissing + || secrets.contains(public_key)?) + { + return Err(identity_exists()); + } + if previous.is_none() && secrets.contains(public_key)? { + return Err(identity_exists()); + } + let identity = if let Some(existing) = &previous { + existing.with_binding_availability(SignerAvailability::Available) + } else { + NostrIdentity::new( + NostrIdentityReference::verify(public_key, npub.as_str().to_owned())?, + LocalKeyringBinding::new(public_key, SignerAvailability::Available), + None, + IdentityCreatedAt::new(clock.now()), + None, + )? + }; + let kind = if previous.is_some() { + DurableOperationKind::Repair + } else { + DurableOperationKind::Import + }; + self.persist_identity_durable( + request_id, + kind, + expected_revision, + &identity, + secret, + previous.as_ref(), + identities, + app_state, + secrets, + operations, + clock, + )?; + Ok(ImportIdentityReceipt { identity }) + } + + fn require_revision(&self, expected_revision: u64) -> Result<(), SafeError> { + if self.snapshot().revision().value() != expected_revision { + return Err(operation_conflict()); + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn persist_identity_durable( + &self, + request_id: &DurableRequestId, + kind: DurableOperationKind, + expected_revision: u64, + identity: &NostrIdentity, + secret: SecretKeyInput, + previous: Option<&NostrIdentity>, + identities: &(impl IdentityRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<(), SafeError> { + let prior = OperationPriorState::new( + app_state.load_selected_identity()?, + previous.map(|identity| identity.signer_binding().availability()), + ); + match operations.begin_durable_operation( + request_id, + kind, + identity.public_key(), + Some(expected_revision), + prior, + clock.now(), + )? { + DurableOperationStart::Started(_) => {} + DurableOperationStart::Existing(operation) => { + return if operation + .terminal() + .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) + { + Ok(()) + } else { + Err(recovery_required()) + }; + } + } + secrets.put(identity.public_key(), secret)?; + operations.advance_durable_operation( + request_id, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialWritten, + clock.now(), + None, + )?; + previous.map_or_else( + || identities.insert_identity(identity), + |_| identities.update_identity(identity), + )?; + operations.advance_durable_operation( + request_id, + DurableOperationPhase::CredentialWritten, + DurableOperationPhase::MetadataCommitted, + clock.now(), + None, + )?; + app_state.save_selected_identity(Some(identity.public_key()))?; + operations.advance_durable_operation( + request_id, + DurableOperationPhase::MetadataCommitted, + DurableOperationPhase::SelectionCommitted, + clock.now(), + None, + )?; + let snapshot = self.apply_transition(StateTransition::ReplaceRegistry { + identities: identities.list_identities()?, + selected: Some(identity.public_key()), + })?; + operations.finalize_durable_operation( + request_id, + DurableOperationPhase::SelectionCommitted, + DurableTerminalOutcome::Completed, + Some(snapshot.revision().value()), + clock.now(), + )?; + Ok(()) + } + + /// Issues a single-use confirmation bound to the target and current revision. + /// + /// # Errors + /// + /// Returns a safe identity or application-state error. + pub fn request_identity_removal( + &self, + public_key: PublicKey, + clock: &(impl Clock + ?Sized), + ) -> Result<RemovalConfirmationToken, SafeError> { + self.issue_removal_token(public_key, clock.now()) + } + + pub fn cancel_identity_removal(&self, token: RemovalConfirmationToken) -> bool { + self.cancel_removal_token(token) + } + + /// Permanently removes a confirmed identity and selects a deterministic fallback. + /// + /// # Errors + /// + /// Returns a safe confirmation, credential, persistence, recovery, or state error. + pub fn confirm_identity_removal( + &self, + token: RemovalConfirmationToken, + identities: &(impl IdentityRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<crate::AppSnapshot, SafeError> { + let public_key = self.consume_removal_token(token, clock.now())?; + let registry = identities.list_identities()?; + let index = registry + .iter() + .position(|identity| identity.public_key() == public_key) + .ok_or_else(identity_not_found)?; + let selected = if self.snapshot().selected_identity() == Some(public_key) { + registry + .get(index + 1) + .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before))) + .map(NostrIdentity::public_key) + } else { + self.snapshot().selected_identity() + }; + let operation = + journal.begin_operation(IdentityOperationKind::Remove, public_key, clock.now())?; + let was_active = self + .snapshot() + .active_identity() + .is_some_and(|active| active.identity().public_key() == public_key); + if was_active { + self.sign_out()?; + } + let identity = ®istry[index]; + match secrets.delete(public_key) { + Ok(()) => {} + Err(error) + if error.code() == SafeErrorCode::CredentialMissing + && identity.signer_binding().availability() + == SignerAvailability::CredentialMissing => {} + Err(error) => return Err(error), + } + journal.update_operation( + operation, + IdentityOperationPhase::CredentialDeleted, + clock.now(), + None, + )?; + identities.remove_identity(public_key)?; + app_state.save_selected_identity(selected)?; + journal.update_operation( + operation, + IdentityOperationPhase::MetadataDeleted, + clock.now(), + None, + )?; + journal.finalize_operation(operation)?; + self.apply_transition(StateTransition::ReplaceRegistryPreservingSession { + identities: identities.list_identities()?, + selected, + }) + } + + /// Confirms and executes an expiring removal plan as a durable request. + /// + /// # Errors + /// + /// Returns a safe expiry, conflict, credential, persistence, or recovery error. + #[allow(clippy::too_many_arguments)] + pub fn confirm_identity_removal_durable( + &self, + request_id: &DurableRequestId, + token: RemovalConfirmationToken, + identities: &(impl IdentityRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<crate::AppSnapshot, SafeError> { + let expected_revision = token.revision().value(); + let public_key = self.consume_removal_token(token, clock.now())?; + self.require_revision(expected_revision)?; + let registry = identities.list_identities()?; + let index = registry + .iter() + .position(|identity| identity.public_key() == public_key) + .ok_or_else(identity_not_found)?; + let selected = if self.snapshot().selected_identity() == Some(public_key) { + registry + .get(index + 1) + .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before))) + .map(NostrIdentity::public_key) + } else { + self.snapshot().selected_identity() + }; + let identity = ®istry[index]; + match operations.begin_durable_operation( + request_id, + DurableOperationKind::Remove, + public_key, + Some(expected_revision), + OperationPriorState::new(selected, Some(identity.signer_binding().availability())), + clock.now(), + )? { + DurableOperationStart::Started(_) => {} + DurableOperationStart::Existing(operation) => { + return if operation + .terminal() + .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) + { + Ok(self.snapshot()) + } else { + Err(recovery_required()) + }; + } + } + if self + .snapshot() + .active_identity() + .is_some_and(|active| active.identity().public_key() == public_key) + { + self.sign_out()?; + } + match secrets.delete(public_key) { + Ok(()) => {} + Err(error) + if error.code() == SafeErrorCode::CredentialMissing + && identity.signer_binding().availability() + == SignerAvailability::CredentialMissing => {} + Err(error) => return Err(error), + } + operations.advance_durable_operation( + request_id, + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialDeleted, + clock.now(), + None, + )?; + identities.remove_identity(public_key)?; + operations.advance_durable_operation( + request_id, + DurableOperationPhase::CredentialDeleted, + DurableOperationPhase::MetadataDeleted, + clock.now(), + None, + )?; + app_state.save_selected_identity(selected)?; + operations.advance_durable_operation( + request_id, + DurableOperationPhase::MetadataDeleted, + DurableOperationPhase::SelectionCommitted, + clock.now(), + None, + )?; + let snapshot = + self.apply_transition(StateTransition::ReplaceRegistryPreservingSession { + identities: identities.list_identities()?, + selected, + })?; + operations.finalize_durable_operation( + request_id, + DurableOperationPhase::SelectionCommitted, + DurableTerminalOutcome::Completed, + Some(snapshot.revision().value()), + clock.now(), + )?; + Ok(snapshot) + } + + /// Persists and publishes a saved identity selection without activating it. + /// + /// # Errors + /// + /// Returns a safe identity, persistence, or application-state error. + pub fn select_identity( + &self, + public_key: PublicKey, + identities: &(impl IdentityRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + ) -> Result<crate::AppSnapshot, SafeError> { + if identities.find_identity(public_key)?.is_none() { + return Err(identity_not_found()); + } + app_state.save_selected_identity(Some(public_key))?; + self.apply_transition(StateTransition::Select(public_key)) + } + + /// Generates, stores, and selects one local Nostr identity without activating it. + /// + /// # Errors + /// + /// Returns a safe key, credential, persistence, or application-state error. + pub fn generate_identity( + &self, + identities: &(impl IdentityRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<GenerateIdentityReceipt, SafeError> { + let generated = self.key_material().generate()?; + let (public_key, npub, secret, nsec) = generated.into_parts(); + let identity = NostrIdentity::new( + NostrIdentityReference::verify(public_key, npub.as_str().to_owned())?, + LocalKeyringBinding::new(public_key, SignerAvailability::Available), + None, + IdentityCreatedAt::new(clock.now()), + None, + )?; + Self::persist_identity_transaction( + IdentityOperationKind::Add, + &identity, + secret, + None, + identities, + app_state, + secrets, + journal, + clock, + )?; + let registry = identities.list_identities()?; + self.apply_transition(StateTransition::ReplaceRegistry { + identities: registry, + selected: Some(public_key), + })?; + Ok(GenerateIdentityReceipt { + identity, + generated_nsec: nsec, + }) + } + + /// Imports, stores, and selects one local Nostr identity without activating it. + /// + /// # Errors + /// + /// Returns a safe key, credential, persistence, or application-state error. + pub fn import_secret_key( + &self, + input: SecretKeyInput, + identities: &(impl IdentityRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportIdentityReceipt, SafeError> { + let imported = self.key_material().import(input)?; + let (public_key, npub, secret) = imported.into_parts(); + if let Some(existing) = identities.find_identity(public_key)? { + if existing.signer_binding().availability() != SignerAvailability::CredentialMissing + || secrets.contains(public_key)? + { + return Err(identity_exists()); + } + let repaired = existing.with_binding_availability(SignerAvailability::Available); + Self::persist_identity_transaction( + IdentityOperationKind::Import, + &repaired, + secret, + Some(&existing), + identities, + app_state, + secrets, + journal, + clock, + )?; + self.apply_transition(StateTransition::ReplaceRegistry { + identities: identities.list_identities()?, + selected: Some(public_key), + })?; + return Ok(ImportIdentityReceipt { identity: repaired }); + } + if secrets.contains(public_key)? { + return Err(identity_exists()); + } + let identity = NostrIdentity::new( + NostrIdentityReference::verify(public_key, npub.as_str().to_owned())?, + LocalKeyringBinding::new(public_key, SignerAvailability::Available), + None, + IdentityCreatedAt::new(clock.now()), + None, + )?; + Self::persist_identity_transaction( + IdentityOperationKind::Import, + &identity, + secret, + None, + identities, + app_state, + secrets, + journal, + clock, + )?; + self.apply_transition(StateTransition::ReplaceRegistry { + identities: identities.list_identities()?, + selected: Some(public_key), + })?; + Ok(ImportIdentityReceipt { identity }) + } + + #[allow(clippy::too_many_arguments)] + fn persist_identity_transaction( + kind: IdentityOperationKind, + identity: &NostrIdentity, + secret: SecretKeyInput, + previous: Option<&NostrIdentity>, + identities: &(impl IdentityRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<(), SafeError> { + let public_key = identity.public_key(); + let previous_selection = app_state.load_selected_identity()?; + let operation = journal.begin_operation(kind, public_key, clock.now())?; + if let Err(error) = secrets.put(public_key, secret) { + let _ = journal.finalize_operation(operation); + return Err(error); + } + if let Err(error) = journal.update_operation( + operation, + IdentityOperationPhase::CredentialWritten, + clock.now(), + None, + ) { + return compensate_identity_write( + operation, + public_key, + error, + None, + previous_selection, + identities, + app_state, + secrets, + journal, + clock, + ); + } + let metadata_result = previous.map_or_else( + || identities.insert_identity(identity), + |_| identities.update_identity(identity), + ); + if let Err(error) = metadata_result { + return compensate_identity_write( + operation, + public_key, + error, + previous, + previous_selection, + identities, + app_state, + secrets, + journal, + clock, + ); + } + if let Err(error) = app_state.save_selected_identity(Some(public_key)) { + return compensate_identity_write( + operation, + public_key, + error, + previous, + previous_selection, + identities, + app_state, + secrets, + journal, + clock, + ); + } + journal.update_operation( + operation, + IdentityOperationPhase::MetadataCommitted, + clock.now(), + None, + )?; + journal.finalize_operation(operation) + } +} + +#[allow(clippy::too_many_arguments)] +fn compensate_identity_write( + operation: OperationId, + public_key: PublicKey, + original_error: SafeError, + previous: Option<&NostrIdentity>, + previous_selection: Option<PublicKey>, + identities: &(impl IdentityRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), + clock: &(impl Clock + ?Sized), +) -> Result<(), SafeError> { + let metadata_rollback = if let Some(previous) = previous { + identities.update_identity(previous) + } else { + identities.remove_identity(public_key) + }; + let selection_rollback = app_state.save_selected_identity(previous_selection); + let credential_rollback = secrets.delete(public_key); + if metadata_rollback.is_err() || selection_rollback.is_err() || credential_rollback.is_err() { + let _ = journal.update_operation( + operation, + IdentityOperationPhase::CompensationPending, + clock.now(), + Some(OperationDiagnostic::CompensationFailed), + ); + return Err(recovery_required()); + } + let _ = journal.finalize_operation(operation); + Err(original_error) +} + +#[derive(Default)] +pub struct InMemoryOperationJournal { + state: Mutex<InMemoryJournalState>, +} + +#[derive(Default)] +struct InMemoryJournalState { + next_id: u64, + pending: Vec<PendingIdentityOperation>, +} + +impl OperationJournal for InMemoryOperationJournal { + fn begin_operation( + &self, + kind: IdentityOperationKind, + subject: PublicKey, + updated_at: harvestcircle_domain::UnixTimestamp, + ) -> Result<OperationId, SafeError> { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.next_id = state.next_id.checked_add(1).ok_or_else(recovery_required)?; + let id = OperationId::from_raw(state.next_id); + state.pending.push(PendingIdentityOperation::new( + id, + kind, + subject, + IdentityOperationPhase::IntentRecorded, + updated_at, + None, + )); + Ok(id) + } + + fn update_operation( + &self, + id: OperationId, + phase: IdentityOperationPhase, + updated_at: harvestcircle_domain::UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, + ) -> Result<(), SafeError> { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let operation = state + .pending + .iter_mut() + .find(|operation| operation.id() == id) + .ok_or_else(recovery_required)?; + *operation = PendingIdentityOperation::new( + id, + operation.kind(), + operation.subject(), + phase, + updated_at, + diagnostic, + ); + Ok(()) + } + + fn list_pending_operations(&self) -> Result<Vec<PendingIdentityOperation>, SafeError> { + Ok(self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pending + .clone()) + } + + fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pending + .retain(|operation| operation.id() != id); + Ok(()) + } +} + +#[derive(Default)] +pub struct InMemoryIdentityRepository { + state: Mutex<InMemoryIdentityState>, +} + +#[derive(Default)] +struct InMemoryIdentityState { + identities: Vec<NostrIdentity>, + selected: Option<PublicKey>, +} + +impl InMemoryIdentityRepository { + fn state(&self) -> MutexGuard<'_, InMemoryIdentityState> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl IdentityRepository for InMemoryIdentityRepository { + fn list_identities(&self) -> Result<Vec<NostrIdentity>, SafeError> { + Ok(self.state().identities.clone()) + } + + fn find_identity(&self, public_key: PublicKey) -> Result<Option<NostrIdentity>, SafeError> { + Ok(self + .state() + .identities + .iter() + .find(|identity| identity.public_key() == public_key) + .cloned()) + } + + fn insert_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError> { + let mut state = self.state(); + if state + .identities + .iter() + .any(|saved| saved.public_key() == identity.public_key()) + { + return Err(identity_exists()); + } + state.identities.push(identity.clone()); + state + .identities + .sort_by_key(|saved| (saved.created_at().timestamp(), saved.public_key())); + Ok(()) + } + + fn update_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError> { + let mut state = self.state(); + let saved = state + .identities + .iter_mut() + .find(|saved| saved.public_key() == identity.public_key()) + .ok_or_else(identity_not_found)?; + *saved = identity.clone(); + Ok(()) + } + + fn remove_identity(&self, public_key: PublicKey) -> Result<(), SafeError> { + let mut state = self.state(); + state + .identities + .retain(|identity| identity.public_key() != public_key); + if state.selected == Some(public_key) { + state.selected = None; + } + Ok(()) + } +} + +impl AppStateRepository for InMemoryIdentityRepository { + fn load_selected_identity(&self) -> Result<Option<PublicKey>, SafeError> { + Ok(self.state().selected) + } + + fn save_selected_identity(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { + let mut state = self.state(); + if public_key.is_some_and(|key| { + !state + .identities + .iter() + .any(|identity| identity.public_key() == key) + }) { + return Err(identity_not_found()); + } + state.selected = public_key; + Ok(()) + } +} + +const fn identity_exists() -> SafeError { + SafeError::new( + SafeErrorCode::IdentityAlreadyExists, + SafeMessage::new("The Nostr identity is already saved."), + ) +} + +const fn identity_not_found() -> SafeError { + SafeError::new( + SafeErrorCode::IdentityNotFound, + SafeMessage::new("The identity was not found."), + ) +} + +const fn recovery_required() -> SafeError { + SafeError::new( + SafeErrorCode::PendingOperationRecoveryRequired, + SafeMessage::new("Identity recovery is required before this operation can continue."), + ) +} + +const fn operation_conflict() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The identity operation conflicts with the current application state."), + ) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + + use harvestcircle_domain::{ + IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, PublicKey, + SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, SignerAvailability, UnixTimestamp, + }; + + use super::InMemoryIdentityRepository; + use crate::{ + AppCore, AppStateRepository, Clock, DurableOperationKind, DurableOperationPhase, + FailureSecretStore, IdentityOperationPhase, IdentityRepository, InMemoryOperationJournal, + InMemorySecretStore, OperationJournal, ProfileRefreshStatus, ProfileRepository, + RelayConfiguration, SecretStore, SecretStoreOperation, SessionState, StateTransition, + recovery::tests::{TestDurableRepository, operation as durable_operation}, + }; + + struct FixedClock; + + impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(10).expect("time") + } + } + + struct LateClock; + + impl Clock for LateClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(311).expect("time") + } + } + + struct EmptyProfiles; + + impl ProfileRepository for EmptyProfiles { + fn load_profile( + &self, + _public_key: PublicKey, + ) -> Result<Option<crate::CachedProfile>, SafeError> { + Ok(None) + } + + fn save_profile(&self, _profile: &crate::CachedProfile) -> Result<(), SafeError> { + Ok(()) + } + + fn record_refresh_status( + &self, + _public_key: PublicKey, + _refreshed_at: UnixTimestamp, + _status: ProfileRefreshStatus, + ) -> Result<(), SafeError> { + Ok(()) + } + + fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> { + Ok(()) + } + } + + #[derive(Default)] + struct FailingUpdateJournal(InMemoryOperationJournal); + + impl OperationJournal for FailingUpdateJournal { + fn begin_operation( + &self, + kind: crate::IdentityOperationKind, + subject: PublicKey, + updated_at: UnixTimestamp, + ) -> Result<crate::OperationId, SafeError> { + self.0.begin_operation(kind, subject, updated_at) + } + + fn update_operation( + &self, + _id: crate::OperationId, + _phase: IdentityOperationPhase, + _updated_at: UnixTimestamp, + _diagnostic: Option<crate::OperationDiagnostic>, + ) -> Result<(), SafeError> { + Err(SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The test journal is unavailable."), + )) + } + + fn list_pending_operations( + &self, + ) -> Result<Vec<crate::PendingIdentityOperation>, SafeError> { + self.0.list_pending_operations() + } + + fn finalize_operation(&self, id: crate::OperationId) -> Result<(), SafeError> { + self.0.finalize_operation(id) + } + } + + #[derive(Default)] + struct FailingInsertRepository { + inner: InMemoryIdentityRepository, + } + + #[derive(Default)] + struct FailingSelectionRepository { + inner: InMemoryIdentityRepository, + fail_next_selection: AtomicBool, + } + + impl IdentityRepository for FailingSelectionRepository { + fn list_identities(&self) -> Result<Vec<NostrIdentity>, SafeError> { + self.inner.list_identities() + } + + fn find_identity(&self, public_key: PublicKey) -> Result<Option<NostrIdentity>, SafeError> { + self.inner.find_identity(public_key) + } + + fn insert_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError> { + self.inner.insert_identity(identity) + } + + fn update_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError> { + self.inner.update_identity(identity) + } + + fn remove_identity(&self, public_key: PublicKey) -> Result<(), SafeError> { + self.inner.remove_identity(public_key) + } + } + + impl AppStateRepository for FailingSelectionRepository { + fn load_selected_identity(&self) -> Result<Option<PublicKey>, SafeError> { + self.inner.load_selected_identity() + } + + fn save_selected_identity(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { + if self.fail_next_selection.swap(false, Ordering::SeqCst) { + return Err(SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The test selection repository is unavailable."), + )); + } + self.inner.save_selected_identity(public_key) + } + } + + impl IdentityRepository for FailingInsertRepository { + fn list_identities(&self) -> Result<Vec<NostrIdentity>, SafeError> { + self.inner.list_identities() + } + + fn find_identity(&self, public_key: PublicKey) -> Result<Option<NostrIdentity>, SafeError> { + self.inner.find_identity(public_key) + } + + fn insert_identity(&self, _identity: &NostrIdentity) -> Result<(), SafeError> { + Err(SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The test identity repository is unavailable."), + )) + } + + fn update_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError> { + self.inner.update_identity(identity) + } + + fn remove_identity(&self, public_key: PublicKey) -> Result<(), SafeError> { + self.inner.remove_identity(public_key) + } + } + + impl AppStateRepository for FailingInsertRepository { + fn load_selected_identity(&self) -> Result<Option<PublicKey>, SafeError> { + self.inner.load_selected_identity() + } + + fn save_selected_identity(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { + self.inner.save_selected_identity(public_key) + } + } + + #[test] + fn generate_identity_stores_selects_and_returns_one_time_nsec_without_activation() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = InMemoryIdentityRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + + let receipt = core + .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .expect("generate"); + let public_key = receipt.identity().public_key(); + assert_eq!(public_key.to_hex().len(), 64); + assert!(secrets.contains(public_key).expect("credential")); + assert_eq!( + identities.load_selected_identity().expect("selection"), + Some(public_key) + ); + assert_eq!(core.snapshot().selected_identity(), Some(public_key)); + assert_eq!(core.snapshot().session(), SessionState::SignedOut); + assert!(core.snapshot().active_identity().is_none()); + assert_eq!(receipt.generated_nsec().with_exposed_secret(str::len), 63); + assert!(!format!("{:?}", core.snapshot()).contains("nsec1")); + } + + #[test] + fn import_secret_key_accepts_nsec_and_hex_without_exposing_or_activating() { + for input in [ + "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + ] { + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = InMemoryIdentityRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let receipt = core + .import_secret_key( + SecretKeyInput::parse(input.to_owned()).expect("input"), + &identities, + &identities, + &secrets, + &journal, + &FixedClock, + ) + .expect("import"); + let public_key = receipt.identity().public_key(); + assert!(secrets.contains(public_key).expect("credential")); + assert_eq!(core.snapshot().selected_identity(), Some(public_key)); + assert_eq!(core.snapshot().session(), SessionState::SignedOut); + assert!(!format!("{:?}", core.snapshot()).contains(input)); + } + } + + #[test] + fn import_secret_key_rejects_invalid_nsec_checksum_before_persistence() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = InMemoryIdentityRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let input = SecretKeyInput::parse( + "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(), + ) + .expect("domain shape"); + let error = core + .import_secret_key( + input, + &identities, + &identities, + &secrets, + &journal, + &FixedClock, + ) + .expect_err("invalid import"); + assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); + assert!(core.snapshot().identities().is_empty()); + } + + #[test] + fn duplicate_import_preserves_existing_credential_and_snapshot() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = InMemoryIdentityRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let import = || { + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("input") + }; + core.import_secret_key( + import(), + &identities, + &identities, + &secrets, + &journal, + &FixedClock, + ) + .expect("first import"); + let before = core.snapshot(); + let error = core + .import_secret_key( + import(), + &identities, + &identities, + &secrets, + &journal, + &FixedClock, + ) + .expect_err("duplicate"); + assert_eq!(error.code(), SafeErrorCode::IdentityAlreadyExists); + assert_eq!(core.snapshot(), before); + assert_eq!(core.snapshot().identities().len(), 1); + } + + #[test] + fn duplicate_import_repairs_only_explicit_missing_credential_identity() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = InMemoryIdentityRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let input = || { + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("input") + }; + let imported = core.key_material().import(input()).expect("derive"); + let (public_key, npub, _) = imported.into_parts(); + let missing = NostrIdentity::new( + NostrIdentityReference::verify(public_key, npub.as_str().to_owned()).expect("identity"), + LocalKeyringBinding::new(public_key, SignerAvailability::CredentialMissing), + None, + IdentityCreatedAt::new(FixedClock.now()), + None, + ) + .expect("missing identity"); + identities + .insert_identity(&missing) + .expect("missing metadata"); + identities + .save_selected_identity(Some(public_key)) + .expect("selection"); + core.apply_transition(StateTransition::ReplaceRegistry { + identities: vec![missing], + selected: Some(public_key), + }) + .expect("registry"); + + let receipt = core + .import_secret_key( + input(), + &identities, + &identities, + &secrets, + &journal, + &FixedClock, + ) + .expect("repair"); + assert_eq!( + receipt.identity().signer_binding().availability(), + SignerAvailability::Available + ); + assert!(secrets.contains(public_key).expect("credential")); + assert_eq!(core.snapshot().identities().len(), 1); + } + + #[test] + fn identity_transaction_publishes_nothing_when_credential_write_fails() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = InMemoryIdentityRepository::default(); + let secrets = FailureSecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + secrets.fail_next(SecretStoreOperation::Put); + + let error = core + .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .err() + .expect("credential failure"); + assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); + assert!(core.snapshot().identities().is_empty()); + assert!( + journal + .list_pending_operations() + .expect("journal") + .is_empty() + ); + } + + #[test] + fn identity_transaction_removes_written_credential_when_metadata_fails() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = FailingInsertRepository::default(); + let secrets = FailureSecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + + let error = core + .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .err() + .expect("metadata failure"); + assert_eq!(error.code(), SafeErrorCode::StorageUnavailable); + let calls = secrets.calls(); + assert_eq!(calls[0].operation(), SecretStoreOperation::Put); + assert_eq!(calls[1].operation(), SecretStoreOperation::Delete); + assert_eq!(calls[0].public_key(), calls[1].public_key()); + assert!(core.snapshot().identities().is_empty()); + assert!( + journal + .list_pending_operations() + .expect("journal") + .is_empty() + ); + } + + #[test] + fn identity_transaction_rolls_back_metadata_and_credential_when_selection_fails() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = FailingSelectionRepository::default(); + let secrets = FailureSecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + identities.fail_next_selection.store(true, Ordering::SeqCst); + + let error = core + .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .err() + .expect("selection failure"); + + assert_eq!(error.code(), SafeErrorCode::StorageUnavailable); + assert!(identities.list_identities().expect("identities").is_empty()); + assert_eq!( + identities.load_selected_identity().expect("selection"), + None + ); + let calls = secrets.calls(); + assert_eq!(calls[0].operation(), SecretStoreOperation::Put); + assert_eq!(calls[1].operation(), SecretStoreOperation::Delete); + assert_eq!(calls[0].public_key(), calls[1].public_key()); + assert!(core.snapshot().identities().is_empty()); + assert!( + journal + .list_pending_operations() + .expect("journal") + .is_empty() + ); + } + + #[test] + fn identity_transaction_retains_non_secret_journal_when_compensation_fails() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = FailingInsertRepository::default(); + let secrets = FailureSecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + secrets.fail_next(SecretStoreOperation::Delete); + + let error = core + .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .err() + .expect("recovery required"); + assert_eq!( + error.code(), + SafeErrorCode::PendingOperationRecoveryRequired + ); + let pending = journal.list_pending_operations().expect("journal"); + assert_eq!(pending.len(), 1); + assert_eq!( + pending[0].phase(), + IdentityOperationPhase::CompensationPending + ); + assert!(!format!("{pending:?}").contains("nsec1")); + assert!(core.snapshot().identities().is_empty()); + } + + #[test] + fn select_identity_persists_existing_choice_without_activating() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = InMemoryIdentityRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let first = core + .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .expect("first") + .identity() + .public_key(); + core.generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .expect("second"); + + let selected = core + .select_identity(first, &identities, &identities) + .expect("select first"); + assert_eq!(selected.selected_identity(), Some(first)); + assert_eq!(selected.session(), SessionState::SignedOut); + assert!(selected.active_identity().is_none()); + assert_eq!( + identities.load_selected_identity().expect("saved"), + Some(first) + ); + let missing = core + .select_identity( + PublicKey::from_hex( + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + ) + .expect("unknown public key"), + &identities, + &identities, + ) + .expect_err("missing identity"); + assert_eq!(missing.code(), SafeErrorCode::IdentityNotFound); + assert_eq!(core.snapshot(), selected); + } + + #[test] + fn remove_identity_requires_fresh_single_use_confirmation_and_selects_next_fallback() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = InMemoryIdentityRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let first = core + .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .expect("first") + .identity() + .public_key(); + let second = core + .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .expect("second") + .identity() + .public_key(); + core.select_identity(first, &identities, &identities) + .expect("select first"); + let stale = core + .request_identity_removal(first, &FixedClock) + .expect("stale token"); + core.select_identity(second, &identities, &identities) + .expect("change revision"); + let stale_error = core + .confirm_identity_removal( + stale, + &identities, + &identities, + &secrets, + &journal, + &FixedClock, + ) + .expect_err("stale token"); + assert_eq!(stale_error.code(), SafeErrorCode::InvalidApplicationState); + assert_eq!(core.snapshot().identities().len(), 2); + + core.select_identity(first, &identities, &identities) + .expect("reselect first"); + let token = core + .request_identity_removal(first, &FixedClock) + .expect("token"); + let removed = core + .confirm_identity_removal( + token, + &identities, + &identities, + &secrets, + &journal, + &FixedClock, + ) + .expect("remove"); + assert_eq!(removed.identities().len(), 1); + assert_eq!(removed.selected_identity(), Some(second)); + assert!(!secrets.contains(first).expect("credential removed")); + assert_eq!(removed.session(), SessionState::SignedOut); + } + + #[test] + fn removal_preflight_reports_impact_expires_and_can_be_cancelled() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = InMemoryIdentityRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let identity = core + .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .expect("identity") + .identity() + .public_key(); + let expired = core + .request_identity_removal(identity, &FixedClock) + .expect("plan"); + assert!(expired.impact().deletes_local_credential()); + assert!(!expired.impact().signs_out()); + assert!( + core.confirm_identity_removal( + expired, + &identities, + &identities, + &secrets, + &journal, + &LateClock, + ) + .is_err() + ); + let cancelled = core + .request_identity_removal(identity, &FixedClock) + .expect("replacement plan"); + assert!(core.cancel_identity_removal(cancelled)); + assert_eq!(core.snapshot().identities().len(), 1); + } + + #[test] + fn import_rejects_orphan_credentials_and_durable_nonterminal_replays() { + const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = InMemoryIdentityRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let material = core + .key_material() + .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret")) + .expect("key material"); + let (public_key, _npub, secret) = material.into_parts(); + secrets.put(public_key, secret).expect("orphan credential"); + + assert_eq!( + core.import_secret_key( + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + &identities, + &identities, + &secrets, + &journal, + &FixedClock, + ) + .expect_err("orphan credential must fail") + .code(), + SafeErrorCode::IdentityAlreadyExists + ); + + let pending = durable_operation( + DurableOperationKind::Import, + DurableOperationPhase::IntentRecorded, + public_key, + None, + ); + let request_id = pending.request_id().clone(); + let operations = TestDurableRepository::new(pending); + assert_eq!( + core.import_secret_key_durable( + &request_id, + core.snapshot().revision().value(), + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + &identities, + &identities, + &secrets, + &operations, + &FixedClock, + ) + .expect_err("unfinished replay must require recovery") + .code(), + SafeErrorCode::PendingOperationRecoveryRequired + ); + } + + #[test] + fn durable_import_covers_new_and_missing_credential_repair_paths() { + const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + for repair in [false, true] { + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = InMemoryIdentityRepository::default(); + let secrets = InMemorySecretStore::default(); + let material = core + .key_material() + .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret")) + .expect("key material"); + let (public_key, npub, secret) = material.into_parts(); + drop(secret); + if repair { + let identity = NostrIdentity::new( + NostrIdentityReference::verify(public_key, npub.as_str().to_owned()) + .expect("identity"), + LocalKeyringBinding::new(public_key, SignerAvailability::CredentialMissing), + None, + IdentityCreatedAt::new(FixedClock.now()), + None, + ) + .expect("identity"); + identities + .insert_identity(&identity) + .expect("insert identity"); + identities + .save_selected_identity(Some(public_key)) + .expect("selection"); + core.apply_transition(StateTransition::BootstrapRegistry { + identities: vec![identity], + selected: Some(public_key), + }) + .expect("registry"); + } else { + core.bootstrap().expect("bootstrap"); + } + let kind = if repair { + DurableOperationKind::Repair + } else { + DurableOperationKind::Import + }; + let pending = durable_operation( + kind, + DurableOperationPhase::IntentRecorded, + public_key, + repair.then_some(SignerAvailability::CredentialMissing), + ); + let request_id = pending.request_id().clone(); + let operations = TestDurableRepository::fresh(pending); + let receipt = core + .import_secret_key_durable( + &request_id, + core.snapshot().revision().value(), + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + &identities, + &identities, + &secrets, + &operations, + &FixedClock, + ) + .expect("durable import"); + assert_eq!(receipt.identity().public_key(), public_key); + assert_eq!( + operations.operation().phase(), + DurableOperationPhase::Finalized + ); + } + } + + #[test] + fn removal_of_unselected_identity_preserves_the_current_selection() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = InMemoryIdentityRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let first = core + .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .expect("first") + .identity() + .public_key(); + let second = core + .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .expect("second") + .identity() + .public_key(); + let token = core + .request_identity_removal(first, &FixedClock) + .expect("removal token"); + let snapshot = core + .confirm_identity_removal( + token, + &identities, + &identities, + &secrets, + &journal, + &FixedClock, + ) + .expect("remove unselected identity"); + assert_eq!(snapshot.selected_identity(), Some(second)); + + let missing = crate::test_support::valid_test_public_key(99).expect("missing key"); + assert!( + identities + .insert_identity(&snapshot.identities()[0]) + .is_err() + ); + assert!(identities.save_selected_identity(Some(missing)).is_err()); + + let third = core + .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .expect("third") + .identity() + .public_key(); + let token = core + .request_identity_removal(second, &FixedClock) + .expect("durable removal token"); + let pending = durable_operation( + DurableOperationKind::Remove, + DurableOperationPhase::IntentRecorded, + second, + Some(SignerAvailability::Available), + ); + let request_id = pending.request_id().clone(); + let operations = TestDurableRepository::fresh(pending); + let snapshot = core + .confirm_identity_removal_durable( + &request_id, + token, + &identities, + &identities, + &secrets, + &operations, + &FixedClock, + ) + .expect("durable unselected removal"); + assert_eq!(snapshot.selected_identity(), Some(third)); + } + + #[test] + fn duplicate_missing_binding_with_orphan_credential_fails_closed() { + const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = InMemoryIdentityRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + let material = core + .key_material() + .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret")) + .expect("key material"); + let (public_key, npub, secret) = material.into_parts(); + let identity = NostrIdentity::new( + NostrIdentityReference::verify(public_key, npub.as_str().to_owned()).expect("identity"), + LocalKeyringBinding::new(public_key, SignerAvailability::CredentialMissing), + None, + IdentityCreatedAt::new(FixedClock.now()), + None, + ) + .expect("identity"); + identities + .insert_identity(&identity) + .expect("insert identity"); + identities + .save_selected_identity(Some(public_key)) + .expect("selection"); + secrets.put(public_key, secret).expect("credential"); + core.apply_transition(StateTransition::BootstrapRegistry { + identities: vec![identity], + selected: Some(public_key), + }) + .expect("registry"); + + assert_eq!( + core.import_secret_key( + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + &identities, + &identities, + &secrets, + &journal, + &FixedClock, + ) + .expect_err("orphan credential must fail") + .code(), + SafeErrorCode::IdentityAlreadyExists + ); + let pending = durable_operation( + DurableOperationKind::Repair, + DurableOperationPhase::IntentRecorded, + public_key, + Some(SignerAvailability::CredentialMissing), + ); + let request_id = pending.request_id().clone(); + let operations = TestDurableRepository::fresh(pending); + assert_eq!( + core.import_secret_key_durable( + &request_id, + core.snapshot().revision().value(), + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + &identities, + &identities, + &secrets, + &operations, + &FixedClock, + ) + .expect_err("orphan durable credential must fail") + .code(), + SafeErrorCode::IdentityAlreadyExists + ); + } + + #[test] + fn removing_an_active_identity_signs_out_for_legacy_and_durable_requests() { + for durable in [false, true] { + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = InMemoryIdentityRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let public_key = core + .import_secret_key( + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" + .to_owned(), + ) + .expect("secret"), + &identities, + &identities, + &secrets, + &journal, + &FixedClock, + ) + .expect("identity") + .identity() + .public_key(); + core.activate_identity( + public_key, + &identities, + &identities, + &EmptyProfiles, + &secrets, + &FixedClock, + ) + .expect("activate identity"); + let token = core + .request_identity_removal(public_key, &FixedClock) + .expect("removal token"); + let snapshot = if durable { + let pending = durable_operation( + DurableOperationKind::Remove, + DurableOperationPhase::IntentRecorded, + public_key, + Some(SignerAvailability::Available), + ); + let request_id = pending.request_id().clone(); + let operations = TestDurableRepository::fresh(pending); + core.confirm_identity_removal_durable( + &request_id, + token, + &identities, + &identities, + &secrets, + &operations, + &FixedClock, + ) + .expect("durable removal") + } else { + core.confirm_identity_removal( + token, + &identities, + &identities, + &secrets, + &journal, + &FixedClock, + ) + .expect("removal") + }; + assert_eq!(snapshot.session(), SessionState::SignedOut); + } + } + + #[test] + fn identity_transaction_compensates_a_journal_phase_failure() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let identities = InMemoryIdentityRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = FailingUpdateJournal::default(); + core.bootstrap().expect("bootstrap"); + + assert_eq!( + core.generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .err() + .expect("journal failure must be returned") + .code(), + SafeErrorCode::StorageUnavailable + ); + assert!(identities.list_identities().unwrap().is_empty()); + } +} diff --git a/core/crates/harvestcircle_application/src/lib.rs b/core/crates/harvestcircle_application/src/lib.rs @@ -1,11 +1,11 @@ #![doc = "HarvestCircle application runtime."] -pub mod accounts; pub mod actor; pub mod app_core; mod change_stream; pub mod config; pub mod custody; +pub mod identities; pub mod ports; mod profile_refresh; pub mod recovery; @@ -17,13 +17,9 @@ pub mod state_machine; #[cfg(test)] mod test_support; -pub use accounts::{ - GenerateAccountReceipt, ImportAccountReceipt, InMemoryAccountRepository, - InMemoryOperationJournal, -}; pub use actor::{ - ActorMailbox, CommandContext, CommandEnvelope, CommandReceipt, CommandRejection, CommandResult, - CommandSubmission, CommandTicket, ForegroundSessionBinding, LifecycleGate, RequestId, + ActiveSessionBinding, ActorMailbox, CommandContext, CommandEnvelope, CommandReceipt, + CommandRejection, CommandResult, CommandSubmission, CommandTicket, LifecycleGate, RequestId, RuntimeCommandClass, RuntimeLifecycle, SessionGeneration, TaskCorrelation, }; pub use app_core::{AppCore, RemovalConfirmationToken, RemovalImpact}; @@ -37,22 +33,26 @@ pub use custody::{ GENERATED_KEY_STAGE_TTL, GeneratedKeyRecoveryHandle, GeneratedKeyStage, GeneratedKeyStageView, RecoveryStageId, StagedGeneratedKey, }; +pub use identities::{ + GenerateIdentityReceipt, ImportIdentityReceipt, InMemoryIdentityRepository, + InMemoryOperationJournal, +}; pub use ports::{ - AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey, - AccountRepository, AppStateRepository, BoxFuture, CachedProfile, Clock, - DurableAccountOperation, DurableOperationKind, DurableOperationPhase, DurableOperationReceipt, + AppStateRepository, BoxFuture, CachedProfile, Clock, DurableIdentityOperation, + DurableOperationKind, DurableOperationPhase, DurableOperationReceipt, DurableOperationRepository, DurableOperationStart, DurableRequestId, DurableTerminalOutcome, - GeneratedKeyMaterial, ImportedKeyMaterial, KeyMaterialProvider, NostrClient, - OperationDiagnostic, OperationId, OperationJournal, OperationPriorState, - PendingAccountOperation, ProfileFetchResult, ProfileRefreshStatus, ProfileRepository, - RelayFetchCompleteness, + GeneratedKeyMaterial, IdentityNamespaceRepository, IdentityOperationKind, + IdentityOperationPhase, IdentityPreferenceKey, IdentityRepository, ImportedKeyMaterial, + KeyMaterialProvider, NostrClient, OperationDiagnostic, OperationId, OperationJournal, + OperationPriorState, PendingIdentityOperation, ProfileFetchResult, ProfileRefreshStatus, + ProfileRepository, RelayFetchCompleteness, }; pub use profile_refresh::ProfileRefreshPlan; pub use secrets::{ FailureSecretStore, InMemorySecretStore, SecretStore, SecretStoreCall, SecretStoreOperation, }; pub use snapshot::{ - ActiveAccountSnapshot, AppLifecycle, AppSnapshot, MAX_CONFIGURED_RELAYS, ProfileLoadState, + ActiveIdentitySnapshot, AppLifecycle, AppSnapshot, MAX_CONFIGURED_RELAYS, ProfileLoadState, RelayConfiguration, RelayConnectionState, SessionState, SnapshotRevision, }; pub use state_machine::{StateMachine, StateTransition}; diff --git a/core/crates/harvestcircle_application/src/ports.rs b/core/crates/harvestcircle_application/src/ports.rs @@ -3,8 +3,8 @@ use std::pin::Pin; use std::time::Instant; use harvestcircle_domain::{ - AccountSummary, BindingAvailability, Kind0ProfileCandidate, Npub, Nsec, PublicKey, RelayUrl, - SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, + Kind0ProfileCandidate, NostrIdentity, Npub, Nsec, PublicKey, RelayUrl, SafeError, + SafeErrorCode, SafeMessage, SecretKeyInput, SignerAvailability, UnixTimestamp, }; const MAX_DURABLE_REQUEST_ID_BYTES: usize = 128; @@ -65,29 +65,29 @@ pub enum DurableTerminalOutcome { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct OperationPriorState { - selected_account: Option<PublicKey>, - binding_availability: Option<BindingAvailability>, + selected_identity: Option<PublicKey>, + binding_availability: Option<SignerAvailability>, } impl OperationPriorState { #[must_use] pub const fn new( - selected_account: Option<PublicKey>, - binding_availability: Option<BindingAvailability>, + selected_identity: Option<PublicKey>, + binding_availability: Option<SignerAvailability>, ) -> Self { Self { - selected_account, + selected_identity, binding_availability, } } #[must_use] - pub const fn selected_account(self) -> Option<PublicKey> { - self.selected_account + pub const fn selected_identity(self) -> Option<PublicKey> { + self.selected_identity } #[must_use] - pub const fn binding_availability(self) -> Option<BindingAvailability> { + pub const fn binding_availability(self) -> Option<SignerAvailability> { self.binding_availability } } @@ -95,7 +95,7 @@ impl OperationPriorState { #[derive(Clone, Debug, Eq, PartialEq)] pub struct DurableOperationReceipt { request_id: DurableRequestId, - account: PublicKey, + identity: PublicKey, outcome: DurableTerminalOutcome, resulting_revision: Option<u64>, } @@ -104,13 +104,13 @@ impl DurableOperationReceipt { #[must_use] pub const fn new( request_id: DurableRequestId, - account: PublicKey, + identity: PublicKey, outcome: DurableTerminalOutcome, resulting_revision: Option<u64>, ) -> Self { Self { request_id, - account, + identity, outcome, resulting_revision, } @@ -122,8 +122,8 @@ impl DurableOperationReceipt { } #[must_use] - pub const fn account(&self) -> PublicKey { - self.account + pub const fn identity(&self) -> PublicKey { + self.identity } #[must_use] @@ -138,10 +138,10 @@ impl DurableOperationReceipt { } #[derive(Clone, Debug, Eq, PartialEq)] -pub struct DurableAccountOperation { +pub struct DurableIdentityOperation { request_id: DurableRequestId, kind: DurableOperationKind, - account: PublicKey, + identity: PublicKey, expected_revision: Option<u64>, phase: DurableOperationPhase, prior: OperationPriorState, @@ -150,13 +150,13 @@ pub struct DurableAccountOperation { terminal: Option<DurableOperationReceipt>, } -impl DurableAccountOperation { +impl DurableIdentityOperation { #[allow(clippy::too_many_arguments)] #[must_use] pub const fn new( request_id: DurableRequestId, kind: DurableOperationKind, - account: PublicKey, + identity: PublicKey, expected_revision: Option<u64>, phase: DurableOperationPhase, prior: OperationPriorState, @@ -167,7 +167,7 @@ impl DurableAccountOperation { Self { request_id, kind, - account, + identity, expected_revision, phase, prior, @@ -186,8 +186,8 @@ impl DurableAccountOperation { self.kind } #[must_use] - pub const fn account(&self) -> PublicKey { - self.account + pub const fn identity(&self) -> PublicKey { + self.identity } #[must_use] pub const fn expected_revision(&self) -> Option<u64> { @@ -217,8 +217,8 @@ impl DurableAccountOperation { #[derive(Clone, Debug, Eq, PartialEq)] pub enum DurableOperationStart { - Started(DurableAccountOperation), - Existing(DurableAccountOperation), + Started(DurableIdentityOperation), + Existing(DurableIdentityOperation), } const fn invalid_request_id() -> SafeError { @@ -280,19 +280,19 @@ pub struct CachedProfile { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum AccountPreferenceKey { +pub enum IdentityPreferenceKey { NamespaceProbe, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum AccountOperationKind { +pub enum IdentityOperationKind { Add, Import, Remove, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum AccountOperationPhase { +pub enum IdentityOperationPhase { IntentRecorded, CredentialWritten, MetadataCommitted, @@ -327,22 +327,22 @@ impl OperationId { } #[derive(Clone, Debug, Eq, PartialEq)] -pub struct PendingAccountOperation { +pub struct PendingIdentityOperation { id: OperationId, - kind: AccountOperationKind, + kind: IdentityOperationKind, subject: PublicKey, - phase: AccountOperationPhase, + phase: IdentityOperationPhase, updated_at: UnixTimestamp, diagnostic: Option<OperationDiagnostic>, } -impl PendingAccountOperation { +impl PendingIdentityOperation { #[must_use] pub const fn new( id: OperationId, - kind: AccountOperationKind, + kind: IdentityOperationKind, subject: PublicKey, - phase: AccountOperationPhase, + phase: IdentityOperationPhase, updated_at: UnixTimestamp, diagnostic: Option<OperationDiagnostic>, ) -> Self { @@ -361,7 +361,7 @@ impl PendingAccountOperation { self.id } #[must_use] - pub const fn kind(&self) -> AccountOperationKind { + pub const fn kind(&self) -> IdentityOperationKind { self.kind } #[must_use] @@ -369,7 +369,7 @@ impl PendingAccountOperation { self.subject } #[must_use] - pub const fn phase(&self) -> AccountOperationPhase { + pub const fn phase(&self) -> IdentityOperationPhase { self.phase } #[must_use] @@ -412,38 +412,38 @@ impl CachedProfile { } } -pub trait AccountRepository: Send + Sync { - /// Lists saved public account records in deterministic order. +pub trait IdentityRepository: Send + Sync { + /// Lists saved public identity records in deterministic order. /// /// # Errors /// /// Returns a safe storage error when records cannot be read. - fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError>; - /// Finds one saved public account record. + fn list_identities(&self) -> Result<Vec<NostrIdentity>, SafeError>; + /// Finds one saved public identity record. /// /// # Errors /// /// Returns a safe storage error when the lookup cannot complete. - fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError>; - /// Inserts one public account record. + fn find_identity(&self, public_key: PublicKey) -> Result<Option<NostrIdentity>, SafeError>; + /// Inserts one public identity record. /// /// # Errors /// /// Returns a safe storage error when the durable write fails. - fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError>; - /// Updates one existing public account record. + fn insert_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError>; + /// Updates one existing public identity record. /// /// # Errors /// - /// Returns a safe storage or account-not-found error when the durable + /// Returns a safe storage or identity-not-found error when the durable /// update cannot complete. - fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError>; - /// Removes one public account record. + fn update_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError>; + /// Removes one public identity record. /// /// # Errors /// /// Returns a safe storage error when the durable delete fails. - fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError>; + fn remove_identity(&self, public_key: PublicKey) -> Result<(), SafeError>; } pub trait ProfileRepository: Send + Sync { @@ -470,7 +470,7 @@ pub trait ProfileRepository: Send + Sync { refreshed_at: UnixTimestamp, status: ProfileRefreshStatus, ) -> Result<(), SafeError>; - /// Removes cached profile metadata for an account. + /// Removes cached profile metadata for an identity. /// /// # Errors /// @@ -478,8 +478,8 @@ pub trait ProfileRepository: Send + Sync { fn remove_profile(&self, public_key: PublicKey) -> Result<(), SafeError>; } -pub trait AccountNamespaceRepository: Send + Sync { - /// Reads one internal non-secret account-scoped value. +pub trait IdentityNamespaceRepository: Send + Sync { + /// Reads one internal non-secret identity-scoped value. /// /// # Errors /// @@ -487,9 +487,9 @@ pub trait AccountNamespaceRepository: Send + Sync { fn get_value( &self, owner: PublicKey, - key: AccountPreferenceKey, + key: IdentityPreferenceKey, ) -> Result<Option<String>, SafeError>; - /// Writes one internal non-secret account-scoped value. + /// Writes one internal non-secret identity-scoped value. /// /// # Errors /// @@ -497,10 +497,10 @@ pub trait AccountNamespaceRepository: Send + Sync { fn set_value( &self, owner: PublicKey, - key: AccountPreferenceKey, + key: IdentityPreferenceKey, value: &str, ) -> Result<(), SafeError>; - /// Removes all internal values owned by an account. + /// Removes all internal values owned by an identity. /// /// # Errors /// @@ -509,29 +509,29 @@ pub trait AccountNamespaceRepository: Send + Sync { } pub trait AppStateRepository: Send + Sync { - /// Loads the persisted selected account. + /// Loads the persisted selected identity. /// /// # Errors /// /// Returns a safe storage error when application state cannot be read. - fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError>; - /// Persists the selected account or the empty selection. + fn load_selected_identity(&self) -> Result<Option<PublicKey>, SafeError>; + /// Persists the selected identity or the empty selection. /// /// # Errors /// /// Returns a safe storage error when application state cannot be committed. - fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError>; + fn save_selected_identity(&self, public_key: Option<PublicKey>) -> Result<(), SafeError>; } pub trait OperationJournal: Send + Sync { - /// Records one cross-resource account operation intent. + /// Records one cross-resource identity operation intent. /// /// # Errors /// /// Returns a safe storage error when the entry cannot be committed. fn begin_operation( &self, - kind: AccountOperationKind, + kind: IdentityOperationKind, subject: PublicKey, updated_at: UnixTimestamp, ) -> Result<OperationId, SafeError>; @@ -543,7 +543,7 @@ pub trait OperationJournal: Send + Sync { fn update_operation( &self, id: OperationId, - phase: AccountOperationPhase, + phase: IdentityOperationPhase, updated_at: UnixTimestamp, diagnostic: Option<OperationDiagnostic>, ) -> Result<(), SafeError>; @@ -552,7 +552,7 @@ pub trait OperationJournal: Send + Sync { /// # Errors /// /// Returns a safe storage error when entries cannot be read. - fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError>; + fn list_pending_operations(&self) -> Result<Vec<PendingIdentityOperation>, SafeError>; /// Deletes one fully reconciled operation entry. /// /// # Errors @@ -572,7 +572,7 @@ pub trait DurableOperationRepository: Send + Sync { &self, request_id: &DurableRequestId, kind: DurableOperationKind, - account: PublicKey, + identity: PublicKey, expected_revision: Option<u64>, prior: OperationPriorState, updated_at: UnixTimestamp, @@ -585,7 +585,7 @@ pub trait DurableOperationRepository: Send + Sync { fn load_durable_operation( &self, request_id: &DurableRequestId, - ) -> Result<Option<DurableAccountOperation>, SafeError>; + ) -> Result<Option<DurableIdentityOperation>, SafeError>; /// Advances one operation only from the caller's expected phase. /// /// # Errors @@ -598,7 +598,7 @@ pub trait DurableOperationRepository: Send + Sync { next_phase: DurableOperationPhase, updated_at: UnixTimestamp, diagnostic: Option<OperationDiagnostic>, - ) -> Result<DurableAccountOperation, SafeError>; + ) -> Result<DurableIdentityOperation, SafeError>; /// Finalizes one operation and durably retains its recoverable receipt. /// /// # Errors @@ -617,8 +617,9 @@ pub trait DurableOperationRepository: Send + Sync { /// # Errors /// /// Returns a safe storage error when operations cannot be read. - fn list_unfinished_durable_operations(&self) - -> Result<Vec<DurableAccountOperation>, SafeError>; + fn list_unfinished_durable_operations( + &self, + ) -> Result<Vec<DurableIdentityOperation>, SafeError>; } pub trait NostrClient: Send + Sync { @@ -707,13 +708,13 @@ mod tests { use std::sync::Mutex; - use harvestcircle_domain::{AccountSummary, PublicKey, RelayUrl, SafeError, UnixTimestamp}; + use harvestcircle_domain::{NostrIdentity, PublicKey, RelayUrl, SafeError, UnixTimestamp}; use super::{ - AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, - AccountPreferenceKey, AccountRepository, AppStateRepository, BoxFuture, CachedProfile, - Clock, DurableOperationReceipt, DurableRequestId, DurableTerminalOutcome, NostrClient, - OperationDiagnostic, OperationId, OperationJournal, PendingAccountOperation, + AppStateRepository, BoxFuture, CachedProfile, Clock, DurableOperationReceipt, + DurableRequestId, DurableTerminalOutcome, IdentityNamespaceRepository, + IdentityOperationKind, IdentityOperationPhase, IdentityPreferenceKey, IdentityRepository, + NostrClient, OperationDiagnostic, OperationId, OperationJournal, PendingIdentityOperation, ProfileFetchResult, ProfileRefreshStatus, ProfileRepository, }; @@ -738,27 +739,27 @@ mod tests { selected: Mutex<Option<PublicKey>>, } - impl AccountRepository for FakePorts { - fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { + impl IdentityRepository for FakePorts { + fn list_identities(&self) -> Result<Vec<NostrIdentity>, SafeError> { Ok(Vec::new()) } - fn find_account( + fn find_identity( &self, _public_key: PublicKey, - ) -> Result<Option<AccountSummary>, SafeError> { + ) -> Result<Option<NostrIdentity>, SafeError> { Ok(None) } - fn insert_account(&self, _account: &AccountSummary) -> Result<(), SafeError> { + fn insert_identity(&self, _identity: &NostrIdentity) -> Result<(), SafeError> { Ok(()) } - fn update_account(&self, _account: &AccountSummary) -> Result<(), SafeError> { + fn update_identity(&self, _identity: &NostrIdentity) -> Result<(), SafeError> { Ok(()) } - fn remove_account(&self, _public_key: PublicKey) -> Result<(), SafeError> { + fn remove_identity(&self, _public_key: PublicKey) -> Result<(), SafeError> { Ok(()) } } @@ -786,11 +787,11 @@ mod tests { } } - impl AccountNamespaceRepository for FakePorts { + impl IdentityNamespaceRepository for FakePorts { fn get_value( &self, _owner: PublicKey, - _key: AccountPreferenceKey, + _key: IdentityPreferenceKey, ) -> Result<Option<String>, SafeError> { Ok(None) } @@ -798,7 +799,7 @@ mod tests { fn set_value( &self, _owner: PublicKey, - _key: AccountPreferenceKey, + _key: IdentityPreferenceKey, _value: &str, ) -> Result<(), SafeError> { Ok(()) @@ -810,11 +811,11 @@ mod tests { } impl AppStateRepository for FakePorts { - fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { + fn load_selected_identity(&self) -> Result<Option<PublicKey>, SafeError> { Ok(*self.selected.lock().expect("selected lock")) } - fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { + fn save_selected_identity(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { *self.selected.lock().expect("selected lock") = public_key; Ok(()) } @@ -823,7 +824,7 @@ mod tests { impl OperationJournal for FakePorts { fn begin_operation( &self, - _kind: AccountOperationKind, + _kind: IdentityOperationKind, _subject: PublicKey, _updated_at: UnixTimestamp, ) -> Result<OperationId, SafeError> { @@ -833,14 +834,14 @@ mod tests { fn update_operation( &self, _id: OperationId, - _phase: AccountOperationPhase, + _phase: IdentityOperationPhase, _updated_at: UnixTimestamp, _diagnostic: Option<OperationDiagnostic>, ) -> Result<(), SafeError> { Ok(()) } - fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> { + fn list_pending_operations(&self) -> Result<Vec<PendingIdentityOperation>, SafeError> { Ok(Vec::new()) } @@ -874,12 +875,12 @@ mod tests { let ports = FakePorts::default(); ports - .save_selected_account(Some( + .save_selected_identity(Some( PublicKey::from_bytes([7_u8; 32]).expect("valid public key"), )) .expect("save selection"); assert_eq!( - ports.load_selected_account().expect("load selection"), + ports.load_selected_identity().expect("load selection"), Some(PublicKey::from_bytes([7_u8; 32]).expect("valid public key")) ); assert_eq!(ports.now().as_seconds(), 1); diff --git a/core/crates/harvestcircle_application/src/profile_refresh.rs b/core/crates/harvestcircle_application/src/profile_refresh.rs @@ -2,7 +2,7 @@ use harvestcircle_domain::{PublicKey, RelayUrl, SafeError, SafeErrorCode}; use std::time::Instant; use crate::{ - ActiveAccountSnapshot, AppCore, AppSnapshot, CachedProfile, Clock, NostrClient, + ActiveIdentitySnapshot, AppCore, AppSnapshot, CachedProfile, Clock, NostrClient, ProfileFetchResult, ProfileLoadState, ProfileRefreshStatus, ProfileRepository, RelayConnectionState, RelayFetchCompleteness, SnapshotRevision, StateTransition, }; @@ -10,7 +10,7 @@ use crate::{ #[derive(Clone, Debug, Eq, PartialEq)] pub struct ProfileRefreshPlan { public_key: PublicKey, - active_account: ActiveAccountSnapshot, + active_identity: ActiveIdentitySnapshot, relays: Vec<RelayUrl>, expected_revision: SnapshotRevision, } @@ -22,8 +22,8 @@ impl ProfileRefreshPlan { } #[must_use] - pub const fn active_account(&self) -> &ActiveAccountSnapshot { - &self.active_account + pub const fn active_identity(&self) -> &ActiveIdentitySnapshot { + &self.active_identity } #[must_use] @@ -38,7 +38,7 @@ impl ProfileRefreshPlan { } impl AppCore { - /// Manually refreshes the active account's Nostr kind-0 profile. + /// Manually refreshes the active identity's Nostr kind-0 profile. /// /// Cached public metadata remains visible while the asynchronous request is /// running. Calling this command while signed out is an idempotent no-op. @@ -54,19 +54,19 @@ impl AppCore { clock: &(impl Clock + ?Sized), deadline: Instant, ) -> Result<AppSnapshot, SafeError> { - self.refresh_profile_for_active_account(profiles, client, clock, deadline) + self.refresh_profile_for_active_identity(profiles, client, clock, deadline) .await } - /// Refreshes the current active account while retaining any cached profile. + /// Refreshes the current active identity while retaining any cached profile. /// - /// Stale results are discarded when the account is replaced or signed out. + /// Stale results are discarded when the identity is replaced or signed out. /// /// # Errors /// /// Returns a safe storage or application-state error. Relay and invalid-data /// failures are represented as nonfatal snapshot state. - async fn refresh_profile_for_active_account( + async fn refresh_profile_for_active_identity( &self, profiles: &(impl ProfileRepository + ?Sized), client: &(impl NostrClient + ?Sized), @@ -88,14 +88,14 @@ impl AppCore { /// /// Returns a safe state error when the loading transition is invalid. pub fn begin_profile_refresh(&self) -> Result<Option<ProfileRefreshPlan>, SafeError> { - let Some(active) = self.snapshot().active_account().cloned() else { + let Some(active) = self.snapshot().active_identity().cloned() else { return Ok(None); }; - let public_key = active.account().public_key(); - let loading = self.apply_transition(StateTransition::UpdateActiveAccount { + let public_key = active.identity().public_key(); + let loading = self.apply_transition(StateTransition::UpdateActiveIdentity { expected: public_key, - active_account: Box::new(ActiveAccountSnapshot::new( - active.account().clone(), + active_identity: Box::new(ActiveIdentitySnapshot::new( + active.identity().clone(), RelayConnectionState::Connecting, ProfileLoadState::Loading, active.profile().cloned(), @@ -104,7 +104,7 @@ impl AppCore { })?; Ok(Some(ProfileRefreshPlan { public_key, - active_account: active, + active_identity: active, relays: loading.relay_configuration().relays().to_vec(), expected_revision: loading.revision(), })) @@ -129,7 +129,7 @@ impl AppCore { let current_active = self .snapshot() - .active_account() + .active_identity() .cloned() .ok_or_else(invalid_profile_completion)?; @@ -148,10 +148,10 @@ impl AppCore { Err(error) => { let status = refresh_status(error); profiles.record_refresh_status(plan.public_key(), clock.now(), status)?; - self.apply_transition(StateTransition::UpdateActiveAccount { + self.apply_transition(StateTransition::UpdateActiveIdentity { expected: plan.public_key(), - active_account: Box::new(ActiveAccountSnapshot::new( - current_active.account().clone(), + active_identity: Box::new(ActiveIdentitySnapshot::new( + current_active.identity().clone(), RelayConnectionState::Degraded, ProfileLoadState::Error(error), current_active.profile().cloned(), @@ -165,7 +165,7 @@ impl AppCore { fn complete_successful_profile_fetch( &self, plan: &ProfileRefreshPlan, - current_active: ActiveAccountSnapshot, + current_active: ActiveIdentitySnapshot, candidate: Option<harvestcircle_domain::Kind0ProfileCandidate>, completeness: RelayFetchCompleteness, profiles: &(impl ProfileRepository + ?Sized), @@ -191,10 +191,10 @@ impl AppCore { || candidate.metadata().clone(), |profile| profile.candidate().metadata().clone(), ); - self.apply_transition(StateTransition::UpdateActiveAccount { + self.apply_transition(StateTransition::UpdateActiveIdentity { expected: plan.public_key(), - active_account: Box::new(ActiveAccountSnapshot::new( - current_active.account().clone(), + active_identity: Box::new(ActiveIdentitySnapshot::new( + current_active.identity().clone(), relay_state, ProfileLoadState::Fresh, Some(winning_profile), @@ -202,10 +202,10 @@ impl AppCore { problem, }) } - None => self.apply_transition(StateTransition::UpdateActiveAccount { + None => self.apply_transition(StateTransition::UpdateActiveIdentity { expected: plan.public_key(), - active_account: Box::new(ActiveAccountSnapshot::new( - current_active.account().clone(), + active_identity: Box::new(ActiveIdentitySnapshot::new( + current_active.identity().clone(), relay_state, if current_active.profile().is_some() { ProfileLoadState::Cached @@ -236,8 +236,8 @@ const fn invalid_profile_completion() -> SafeError { fn is_current_active(core: &AppCore, public_key: PublicKey) -> bool { core.snapshot() - .active_account() - .is_some_and(|active| active.account().public_key() == public_key) + .active_identity() + .is_some_and(|active| active.identity().public_key() == public_key) } const fn refresh_status(error: SafeError) -> ProfileRefreshStatus { @@ -261,10 +261,10 @@ mod tests { }; use crate::{ - ActiveAccountSnapshot, AppCore, BoxFuture, CachedProfile, Clock, InMemoryAccountRepository, - InMemoryOperationJournal, InMemorySecretStore, NostrClient, ProfileFetchResult, - ProfileLoadState, ProfileRefreshStatus, ProfileRepository, RelayConfiguration, - RelayConnectionState, + ActiveIdentitySnapshot, AppCore, BoxFuture, CachedProfile, Clock, + InMemoryIdentityRepository, InMemoryOperationJournal, InMemorySecretStore, NostrClient, + ProfileFetchResult, ProfileLoadState, ProfileRefreshStatus, ProfileRepository, + RelayConfiguration, RelayConnectionState, }; #[derive(Default)] @@ -378,7 +378,7 @@ mod tests { ]) .expect("relay configuration"); let core = AppCore::in_memory(relays); - let accounts = InMemoryAccountRepository::default(); + let identities = InMemoryIdentityRepository::default(); let secrets = InMemorySecretStore::default(); let journal = InMemoryOperationJournal::default(); core.bootstrap().expect("bootstrap"); @@ -388,14 +388,14 @@ mod tests { "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), ) .expect("secret"), - &accounts, - &accounts, + &identities, + &identities, &secrets, &journal, &FixedClock, ) .expect("import") - .account() + .identity() .public_key(); if let Some(name) = cached_name { profiles @@ -406,10 +406,10 @@ mod tests { )) .expect("cache"); } - core.activate_account( + core.activate_identity( public_key, - &accounts, - &accounts, + &identities, + &identities, profiles, &secrets, &FixedClock, @@ -424,8 +424,8 @@ mod tests { let (core, public_key) = active_core(&profiles, Some("Cached")); assert_eq!( core.snapshot() - .active_account() - .map(crate::ActiveAccountSnapshot::profile_state), + .active_identity() + .map(crate::ActiveIdentitySnapshot::profile_state), Some(ProfileLoadState::Cached) ); let plan = core @@ -435,14 +435,14 @@ mod tests { let loading = core.snapshot(); assert_eq!( loading - .active_account() - .map(crate::ActiveAccountSnapshot::profile_state), + .active_identity() + .map(crate::ActiveIdentitySnapshot::profile_state), Some(ProfileLoadState::Loading) ); assert_eq!( loading - .active_account() - .map(crate::ActiveAccountSnapshot::relay_state), + .active_identity() + .map(crate::ActiveIdentitySnapshot::relay_state), Some(RelayConnectionState::Connecting) ); let client = FixedClient(Ok(Some(profile(public_key, "Fresh", 20)))); @@ -453,7 +453,7 @@ mod tests { .expect("complete refresh"); assert_eq!( core.snapshot() - .active_account() + .active_identity() .and_then(|active| active.profile()) .and_then(ProfileMetadata::name), Some("Fresh") @@ -471,7 +471,7 @@ mod tests { ); let snapshot = core - .refresh_profile_for_active_account( + .refresh_profile_for_active_identity( &profiles, &FixedClient(Err(error)), &FixedClock, @@ -483,8 +483,8 @@ mod tests { assert_eq!(snapshot.recoverable_problem(), Some(error)); assert_eq!( snapshot - .active_account() - .map(crate::ActiveAccountSnapshot::relay_state), + .active_identity() + .map(crate::ActiveIdentitySnapshot::relay_state), Some(RelayConnectionState::Degraded) ); assert_eq!( @@ -504,7 +504,7 @@ mod tests { let client = BlockingClient::new(Ok(Some(profile(public_key, "Stale", 20)))); let refresh = - core.refresh_profile_for_active_account(&profiles, &client, &FixedClock, deadline()); + core.refresh_profile_for_active_identity(&profiles, &client, &FixedClock, deadline()); let sign_out = async { let permit = client.started.acquire().await.expect("refresh starts"); permit.forget(); @@ -516,7 +516,7 @@ mod tests { assert!( result .expect("stale result is harmless") - .active_account() + .active_identity() .is_none() ); assert_eq!( @@ -557,7 +557,7 @@ mod tests { assert!(second.revision() > first.revision()); assert_eq!( second - .active_account() + .active_identity() .and_then(|active| active.profile()) .and_then(ProfileMetadata::name), Some("Second") @@ -592,7 +592,7 @@ mod tests { &FixedClock, ) .expect("partial completion"); - let active = snapshot.active_account().expect("active account"); + let active = snapshot.active_identity().expect("active identity"); assert_eq!(active.relay_state(), RelayConnectionState::Degraded); assert_eq!(active.profile_state(), ProfileLoadState::Fresh); assert_eq!( @@ -640,8 +640,8 @@ mod tests { assert_eq!( final_snapshot - .active_account() - .and_then(ActiveAccountSnapshot::profile) + .active_identity() + .and_then(ActiveIdentitySnapshot::profile) .and_then(ProfileMetadata::name), Some("Newest") ); diff --git a/core/crates/harvestcircle_application/src/recovery.rs b/core/crates/harvestcircle_application/src/recovery.rs @@ -1,9 +1,10 @@ use harvestcircle_domain::{PublicKey, SafeError}; use crate::{ - AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, - Clock, DurableAccountOperation, DurableOperationKind, DurableOperationPhase, - DurableOperationRepository, DurableTerminalOutcome, OperationJournal, SecretStore, + AppCore, AppStateRepository, Clock, DurableIdentityOperation, DurableOperationKind, + DurableOperationPhase, DurableOperationRepository, DurableTerminalOutcome, + IdentityOperationKind, IdentityOperationPhase, IdentityRepository, OperationJournal, + SecretStore, }; impl AppCore { @@ -15,7 +16,7 @@ impl AppCore { /// at its last durable phase for a later retry. pub fn recover_durable_operations( &self, - accounts: &(impl AccountRepository + ?Sized), + identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), secrets: &(impl SecretStore + ?Sized), operations: &(impl DurableOperationRepository + ?Sized), @@ -26,10 +27,10 @@ impl AppCore { DurableOperationKind::Create | DurableOperationKind::Import | DurableOperationKind::Repair => recover_durable_addition( - &operation, accounts, app_state, secrets, operations, clock, + &operation, identities, app_state, secrets, operations, clock, )?, DurableOperationKind::Remove => recover_durable_removal( - &operation, accounts, app_state, secrets, operations, clock, + &operation, identities, app_state, secrets, operations, clock, )?, } } @@ -46,7 +47,7 @@ impl AppCore { /// the unfinished journal entry for a later retry. pub fn recover_pending_operations( &self, - accounts: &(impl AccountRepository + ?Sized), + identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), secrets: &(impl SecretStore + ?Sized), journal: &(impl OperationJournal + ?Sized), @@ -54,11 +55,11 @@ impl AppCore { ) -> Result<(), SafeError> { for operation in journal.list_pending_operations()? { match operation.kind() { - AccountOperationKind::Remove => { - recover_removal(&operation, accounts, app_state, secrets, journal, clock)?; + IdentityOperationKind::Remove => { + recover_removal(&operation, identities, app_state, secrets, journal, clock)?; } - AccountOperationKind::Add | AccountOperationKind::Import => { - recover_addition(&operation, accounts, secrets, journal, clock)?; + IdentityOperationKind::Add | IdentityOperationKind::Import => { + recover_addition(&operation, identities, secrets, journal, clock)?; } } } @@ -67,19 +68,19 @@ impl AppCore { } fn recover_durable_removal( - operation: &DurableAccountOperation, - accounts: &(impl AccountRepository + ?Sized), + operation: &DurableIdentityOperation, + identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), secrets: &(impl SecretStore + ?Sized), operations: &(impl DurableOperationRepository + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<(), SafeError> { let request = operation.request_id(); - let account = operation.account(); + let identity = operation.identity(); let mut phase = operation.phase(); if phase == DurableOperationPhase::IntentRecorded { - if secrets.contains(account)? { - secrets.delete(account)?; + if secrets.contains(identity)? { + secrets.delete(identity)?; } operations.advance_durable_operation( request, @@ -91,8 +92,8 @@ fn recover_durable_removal( phase = DurableOperationPhase::CredentialDeleted; } if phase == DurableOperationPhase::CredentialDeleted { - if accounts.find_account(account)?.is_some() { - accounts.remove_account(account)?; + if identities.find_identity(identity)?.is_some() { + identities.remove_identity(identity)?; } operations.advance_durable_operation( request, @@ -104,7 +105,7 @@ fn recover_durable_removal( phase = DurableOperationPhase::MetadataDeleted; } if phase == DurableOperationPhase::MetadataDeleted { - app_state.save_selected_account(operation.prior().selected_account())?; + app_state.save_selected_identity(operation.prior().selected_identity())?; operations.advance_durable_operation( request, phase, @@ -127,19 +128,19 @@ fn recover_durable_removal( } fn recover_durable_addition( - operation: &DurableAccountOperation, - accounts: &(impl AccountRepository + ?Sized), + operation: &DurableIdentityOperation, + identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), secrets: &(impl SecretStore + ?Sized), operations: &(impl DurableOperationRepository + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<(), SafeError> { let request = operation.request_id(); - let account = operation.account(); + let identity = operation.identity(); match operation.phase() { DurableOperationPhase::IntentRecorded => { - if secrets.contains(account)? { - secrets.delete(account)?; + if secrets.contains(identity)? { + secrets.delete(identity)?; } operations.finalize_durable_operation( request, @@ -150,10 +151,10 @@ fn recover_durable_addition( )?; } DurableOperationPhase::CredentialWritten => { - let metadata = accounts.find_account(account)?; + let metadata = identities.find_identity(identity)?; let committed = metadata.as_ref().is_some_and(|saved| { - saved.signer().availability() - == harvestcircle_domain::BindingAvailability::Available + saved.signer_binding().availability() + == harvestcircle_domain::SignerAvailability::Available }); if committed { operations.advance_durable_operation( @@ -173,7 +174,7 @@ fn recover_durable_addition( None, )?; compensate_durable_addition( - operation, accounts, app_state, secrets, operations, clock, + operation, identities, app_state, secrets, operations, clock, )?; } } @@ -191,7 +192,7 @@ fn recover_durable_addition( } DurableOperationPhase::CompensationPending => { compensate_durable_addition( - operation, accounts, app_state, secrets, operations, clock, + operation, identities, app_state, secrets, operations, clock, )?; } DurableOperationPhase::CredentialDeleted | DurableOperationPhase::MetadataDeleted => { @@ -209,12 +210,12 @@ fn recover_durable_addition( } fn finish_durable_selection( - operation: &DurableAccountOperation, + operation: &DurableIdentityOperation, app_state: &(impl AppStateRepository + ?Sized), operations: &(impl DurableOperationRepository + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<(), SafeError> { - app_state.save_selected_account(Some(operation.account()))?; + app_state.save_selected_identity(Some(operation.identity()))?; operations.advance_durable_operation( operation.request_id(), DurableOperationPhase::MetadataCommitted, @@ -233,24 +234,24 @@ fn finish_durable_selection( } fn compensate_durable_addition( - operation: &DurableAccountOperation, - accounts: &(impl AccountRepository + ?Sized), + operation: &DurableIdentityOperation, + identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), secrets: &(impl SecretStore + ?Sized), operations: &(impl DurableOperationRepository + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<(), SafeError> { - if secrets.contains(operation.account())? { - secrets.delete(operation.account())?; + if secrets.contains(operation.identity())? { + secrets.delete(operation.identity())?; } if let Some(availability) = operation.prior().binding_availability() { - if let Some(previous) = accounts.find_account(operation.account())? { - accounts.update_account(&previous.with_binding_availability(availability))?; + if let Some(previous) = identities.find_identity(operation.identity())? { + identities.update_identity(&previous.with_binding_availability(availability))?; } - } else if accounts.find_account(operation.account())?.is_some() { - accounts.remove_account(operation.account())?; + } else if identities.find_identity(operation.identity())?.is_some() { + identities.remove_identity(operation.identity())?; } - app_state.save_selected_account(operation.prior().selected_account())?; + app_state.save_selected_identity(operation.prior().selected_identity())?; operations.advance_durable_operation( operation.request_id(), DurableOperationPhase::CompensationPending, @@ -269,15 +270,15 @@ fn compensate_durable_addition( } fn recover_removal( - operation: &crate::PendingAccountOperation, - accounts: &(impl AccountRepository + ?Sized), + operation: &crate::PendingIdentityOperation, + identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), secrets: &(impl SecretStore + ?Sized), journal: &(impl OperationJournal + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<(), SafeError> { let public_key = operation.subject(); - if operation.phase() == AccountOperationPhase::IntentRecorded { + if operation.phase() == IdentityOperationPhase::IntentRecorded { match secrets.delete(public_key) { Ok(()) => {} Err(error) @@ -286,22 +287,22 @@ fn recover_removal( } journal.update_operation( operation.id(), - AccountOperationPhase::CredentialDeleted, + IdentityOperationPhase::CredentialDeleted, clock.now(), None, )?; } if matches!( operation.phase(), - AccountOperationPhase::IntentRecorded | AccountOperationPhase::CredentialDeleted + IdentityOperationPhase::IntentRecorded | IdentityOperationPhase::CredentialDeleted ) { - let registry = accounts.list_accounts()?; - let selected = removal_fallback(®istry, app_state.load_selected_account()?, public_key); - accounts.remove_account(public_key)?; - app_state.save_selected_account(selected)?; + let registry = identities.list_identities()?; + let selected = removal_fallback(®istry, app_state.load_selected_identity()?, public_key); + identities.remove_identity(public_key)?; + app_state.save_selected_identity(selected)?; journal.update_operation( operation.id(), - AccountOperationPhase::MetadataDeleted, + IdentityOperationPhase::MetadataDeleted, clock.now(), None, )?; @@ -310,15 +311,15 @@ fn recover_removal( } fn recover_addition( - operation: &crate::PendingAccountOperation, - accounts: &(impl AccountRepository + ?Sized), + operation: &crate::PendingIdentityOperation, + identities: &(impl IdentityRepository + ?Sized), secrets: &(impl SecretStore + ?Sized), journal: &(impl OperationJournal + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<(), SafeError> { - let has_metadata = accounts.find_account(operation.subject())?.is_some(); + let has_metadata = identities.find_identity(operation.subject())?.is_some(); match operation.phase() { - AccountOperationPhase::CredentialWritten | AccountOperationPhase::CompensationPending + IdentityOperationPhase::CredentialWritten | IdentityOperationPhase::CompensationPending if !has_metadata => { match secrets.delete(operation.subject()) { @@ -329,7 +330,7 @@ fn recover_addition( } journal.update_operation( operation.id(), - AccountOperationPhase::MetadataDeleted, + IdentityOperationPhase::MetadataDeleted, clock.now(), None, )?; @@ -340,7 +341,7 @@ fn recover_addition( } fn removal_fallback( - registry: &[harvestcircle_domain::AccountSummary], + registry: &[harvestcircle_domain::NostrIdentity], selected: Option<PublicKey>, removed: PublicKey, ) -> Option<PublicKey> { @@ -349,11 +350,11 @@ fn removal_fallback( } let index = registry .iter() - .position(|account| account.public_key() == removed)?; + .position(|identity| identity.public_key() == removed)?; registry .get(index + 1) .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before))) - .map(harvestcircle_domain::AccountSummary::public_key) + .map(harvestcircle_domain::NostrIdentity::public_key) } #[cfg(test)] @@ -361,14 +362,14 @@ pub(crate) mod tests { use std::sync::{Mutex, MutexGuard}; use harvestcircle_domain::{ - BindingAvailability, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, + PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, SignerAvailability, UnixTimestamp, }; use super::*; use crate::{ DurableOperationReceipt, DurableOperationStart, DurableRequestId, FailureSecretStore, - InMemoryAccountRepository, InMemoryOperationJournal, InMemorySecretStore, + InMemoryIdentityRepository, InMemoryOperationJournal, InMemorySecretStore, RelayConfiguration, SecretStore, SecretStoreOperation, }; @@ -381,41 +382,41 @@ pub(crate) mod tests { } pub(crate) struct TestDurableRepository { - operation: Mutex<DurableAccountOperation>, + operation: Mutex<DurableIdentityOperation>, return_existing: bool, } impl TestDurableRepository { - pub(crate) fn new(operation: DurableAccountOperation) -> Self { + pub(crate) fn new(operation: DurableIdentityOperation) -> Self { Self { operation: Mutex::new(operation), return_existing: true, } } - pub(crate) fn fresh(operation: DurableAccountOperation) -> Self { + pub(crate) fn fresh(operation: DurableIdentityOperation) -> Self { Self { operation: Mutex::new(operation), return_existing: false, } } - pub(crate) fn operation(&self) -> MutexGuard<'_, DurableAccountOperation> { + pub(crate) fn operation(&self) -> MutexGuard<'_, DurableIdentityOperation> { self.operation .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } fn replace( - current: &DurableAccountOperation, + current: &DurableIdentityOperation, phase: DurableOperationPhase, diagnostic: Option<crate::OperationDiagnostic>, terminal: Option<DurableOperationReceipt>, - ) -> DurableAccountOperation { - DurableAccountOperation::new( + ) -> DurableIdentityOperation { + DurableIdentityOperation::new( current.request_id().clone(), current.kind(), - current.account(), + current.identity(), current.expected_revision(), phase, current.prior(), @@ -431,7 +432,7 @@ pub(crate) mod tests { &self, _request_id: &DurableRequestId, _kind: DurableOperationKind, - _account: PublicKey, + _identity: PublicKey, _expected_revision: Option<u64>, _prior: crate::OperationPriorState, _updated_at: UnixTimestamp, @@ -447,7 +448,7 @@ pub(crate) mod tests { fn load_durable_operation( &self, request_id: &DurableRequestId, - ) -> Result<Option<DurableAccountOperation>, SafeError> { + ) -> Result<Option<DurableIdentityOperation>, SafeError> { if !self.return_existing { return Ok(None); } @@ -462,7 +463,7 @@ pub(crate) mod tests { next_phase: DurableOperationPhase, _updated_at: UnixTimestamp, diagnostic: Option<crate::OperationDiagnostic>, - ) -> Result<DurableAccountOperation, SafeError> { + ) -> Result<DurableIdentityOperation, SafeError> { let mut operation = self.operation(); if operation.request_id() != request_id || operation.phase() != expected_phase { return Err(conflict()); @@ -485,7 +486,7 @@ pub(crate) mod tests { } let receipt = DurableOperationReceipt::new( request_id.clone(), - operation.account(), + operation.identity(), outcome, resulting_revision, ); @@ -500,7 +501,7 @@ pub(crate) mod tests { fn list_unfinished_durable_operations( &self, - ) -> Result<Vec<DurableAccountOperation>, SafeError> { + ) -> Result<Vec<DurableIdentityOperation>, SafeError> { Ok(vec![self.operation().clone()]) } } @@ -514,38 +515,38 @@ pub(crate) mod tests { fn seeded() -> ( AppCore, - InMemoryAccountRepository, + InMemoryIdentityRepository, InMemorySecretStore, InMemoryOperationJournal, PublicKey, ) { let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); + let identities = InMemoryIdentityRepository::default(); let secrets = InMemorySecretStore::default(); let journal = InMemoryOperationJournal::default(); core.bootstrap().expect("bootstrap"); let receipt = core - .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("seed account"); + .generate_identity(&identities, &identities, &secrets, &journal, &FixedClock) + .expect("seed identity"); ( core, - accounts, + identities, secrets, journal, - receipt.account().public_key(), + receipt.identity().public_key(), ) } pub(crate) fn operation( kind: DurableOperationKind, phase: DurableOperationPhase, - account: PublicKey, - prior_availability: Option<BindingAvailability>, - ) -> DurableAccountOperation { - DurableAccountOperation::new( + identity: PublicKey, + prior_availability: Option<SignerAvailability>, + ) -> DurableIdentityOperation { + DurableIdentityOperation::new( DurableRequestId::parse(format!("{kind:?}-{phase:?}")).expect("durable request ID"), kind, - account, + identity, Some(1), phase, crate::OperationPriorState::new(None, prior_availability), @@ -557,12 +558,12 @@ pub(crate) mod tests { fn run_durable( core: &AppCore, - accounts: &InMemoryAccountRepository, + identities: &InMemoryIdentityRepository, secrets: &InMemorySecretStore, - operation: DurableAccountOperation, - ) -> DurableAccountOperation { + operation: DurableIdentityOperation, + ) -> DurableIdentityOperation { let repository = TestDurableRepository::new(operation); - core.recover_durable_operations(accounts, accounts, secrets, &repository, &FixedClock) + core.recover_durable_operations(identities, identities, secrets, &repository, &FixedClock) .expect("durable recovery"); repository.operation().clone() } @@ -576,7 +577,7 @@ pub(crate) mod tests { DurableOperationPhase::SelectionCommitted, DurableOperationPhase::Finalized, ] { - let (core, accounts, secrets, _journal, public_key) = seeded(); + let (core, identities, secrets, _journal, public_key) = seeded(); if phase != DurableOperationPhase::IntentRecorded { secrets.delete(public_key).expect("delete credential"); } @@ -586,23 +587,27 @@ pub(crate) mod tests { | DurableOperationPhase::SelectionCommitted | DurableOperationPhase::Finalized ) { - accounts.remove_account(public_key).expect("remove account"); + identities + .remove_identity(public_key) + .expect("remove identity"); } let recovered = run_durable( &core, - &accounts, + &identities, &secrets, operation(DurableOperationKind::Remove, phase, public_key, None), ); assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); } - let (core, accounts, secrets, _journal, public_key) = seeded(); + let (core, identities, secrets, _journal, public_key) = seeded(); secrets.delete(public_key).expect("delete credential"); - accounts.remove_account(public_key).expect("remove account"); + identities + .remove_identity(public_key) + .expect("remove identity"); let recovered = run_durable( &core, - &accounts, + &identities, &secrets, operation( DurableOperationKind::Remove, @@ -625,15 +630,15 @@ pub(crate) mod tests { DurableOperationPhase::MetadataDeleted, DurableOperationPhase::Finalized, ] { - let (core, accounts, secrets, _journal, public_key) = seeded(); + let (core, identities, secrets, _journal, public_key) = seeded(); if phase == DurableOperationPhase::IntentRecorded { - accounts - .remove_account(public_key) + identities + .remove_identity(public_key) .expect("remove metadata"); } let recovered = run_durable( &core, - &accounts, + &identities, &secrets, operation(DurableOperationKind::Create, phase, public_key, None), ); @@ -641,15 +646,15 @@ pub(crate) mod tests { } for (prior, retain_metadata, retain_secret) in [ - (Some(BindingAvailability::CredentialMissing), true, true), - (Some(BindingAvailability::CredentialMissing), false, true), + (Some(SignerAvailability::CredentialMissing), true, true), + (Some(SignerAvailability::CredentialMissing), false, true), (None, true, true), (None, false, false), ] { - let (core, accounts, secrets, _journal, public_key) = seeded(); + let (core, identities, secrets, _journal, public_key) = seeded(); if !retain_metadata { - accounts - .remove_account(public_key) + identities + .remove_identity(public_key) .expect("remove metadata"); } if !retain_secret { @@ -657,7 +662,7 @@ pub(crate) mod tests { } let recovered = run_durable( &core, - &accounts, + &identities, &secrets, operation( DurableOperationKind::Repair, @@ -669,13 +674,13 @@ pub(crate) mod tests { assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); } - let (core, accounts, secrets, _journal, public_key) = seeded(); - accounts - .remove_account(public_key) + let (core, identities, secrets, _journal, public_key) = seeded(); + identities + .remove_identity(public_key) .expect("remove metadata"); let recovered = run_durable( &core, - &accounts, + &identities, &secrets, operation( DurableOperationKind::Import, @@ -686,14 +691,14 @@ pub(crate) mod tests { ); assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); - let (core, accounts, secrets, _journal, public_key) = seeded(); - accounts - .remove_account(public_key) + let (core, identities, secrets, _journal, public_key) = seeded(); + identities + .remove_identity(public_key) .expect("remove metadata"); secrets.delete(public_key).expect("delete credential"); let recovered = run_durable( &core, - &accounts, + &identities, &secrets, operation( DurableOperationKind::Create, @@ -708,30 +713,36 @@ pub(crate) mod tests { #[test] fn pending_recovery_exercises_removal_and_addition_presence_branches() { for credential_present in [true, false] { - let (core, accounts, secrets, journal, public_key) = seeded(); + let (core, identities, secrets, journal, public_key) = seeded(); if !credential_present { secrets.delete(public_key).expect("delete credential"); - accounts - .save_selected_account(None) + identities + .save_selected_identity(None) .expect("clear selection"); } journal - .begin_operation(AccountOperationKind::Remove, public_key, FixedClock.now()) + .begin_operation(IdentityOperationKind::Remove, public_key, FixedClock.now()) .expect("removal intent"); - core.recover_pending_operations(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("removal recovery"); + core.recover_pending_operations( + &identities, + &identities, + &secrets, + &journal, + &FixedClock, + ) + .expect("removal recovery"); assert!(journal.list_pending_operations().unwrap().is_empty()); } for (kind, metadata_present, credential_present) in [ - (AccountOperationKind::Add, false, true), - (AccountOperationKind::Import, false, false), - (AccountOperationKind::Add, true, true), + (IdentityOperationKind::Add, false, true), + (IdentityOperationKind::Import, false, false), + (IdentityOperationKind::Add, true, true), ] { - let (core, accounts, secrets, journal, public_key) = seeded(); + let (core, identities, secrets, journal, public_key) = seeded(); if !metadata_present { - accounts - .remove_account(public_key) + identities + .remove_identity(public_key) .expect("remove metadata"); } if !credential_present { @@ -743,19 +754,25 @@ pub(crate) mod tests { journal .update_operation( id, - AccountOperationPhase::CredentialWritten, + IdentityOperationPhase::CredentialWritten, FixedClock.now(), None, ) .expect("credential phase"); - core.recover_pending_operations(&accounts, &accounts, &secrets, &journal, &FixedClock) - .expect("addition recovery"); + core.recover_pending_operations( + &identities, + &identities, + &secrets, + &journal, + &FixedClock, + ) + .expect("addition recovery"); assert!(journal.list_pending_operations().unwrap().is_empty()); } - let (core, accounts, _secrets, journal, public_key) = seeded(); - accounts - .remove_account(public_key) + let (core, identities, _secrets, journal, public_key) = seeded(); + identities + .remove_identity(public_key) .expect("remove metadata"); let secrets = FailureSecretStore::default(); secrets @@ -769,19 +786,25 @@ pub(crate) mod tests { .expect("store credential"); secrets.fail_next(SecretStoreOperation::Delete); let id = journal - .begin_operation(AccountOperationKind::Add, public_key, FixedClock.now()) + .begin_operation(IdentityOperationKind::Add, public_key, FixedClock.now()) .expect("addition intent"); journal .update_operation( id, - AccountOperationPhase::CredentialWritten, + IdentityOperationPhase::CredentialWritten, FixedClock.now(), None, ) .expect("credential phase"); assert!( - core.recover_pending_operations(&accounts, &accounts, &secrets, &journal, &FixedClock,) - .is_err() + core.recover_pending_operations( + &identities, + &identities, + &secrets, + &journal, + &FixedClock, + ) + .is_err() ); } } diff --git a/core/crates/harvestcircle_application/src/secrets.rs b/core/crates/harvestcircle_application/src/secrets.rs @@ -23,7 +23,7 @@ pub trait SecretStore: Send + Sync { /// /// Returns a safe keyring error when availability cannot be determined. fn contains(&self, public_key: PublicKey) -> Result<bool, SafeError>; - /// Deletes a credential without affecting public account metadata. + /// Deletes a credential without affecting public identity metadata. /// /// # Errors /// @@ -181,15 +181,15 @@ impl SecretStore for InMemorySecretStore { const fn credential_exists() -> SafeError { SafeError::new( - SafeErrorCode::AccountAlreadyExists, - SafeMessage::new("The Nostr account credential already exists."), + SafeErrorCode::IdentityAlreadyExists, + SafeMessage::new("The Nostr identity credential already exists."), ) } const fn credential_missing() -> SafeError { SafeError::new( SafeErrorCode::CredentialMissing, - SafeMessage::new("The Nostr account credential is missing."), + SafeMessage::new("The Nostr identity credential is missing."), ) } @@ -246,7 +246,7 @@ mod tests { SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), ) .expect_err("duplicate"); - assert_eq!(duplicate.code(), SafeErrorCode::AccountAlreadyExists); + assert_eq!(duplicate.code(), SafeErrorCode::IdentityAlreadyExists); store.delete(public_key).expect("delete"); let missing = store.delete(public_key).expect_err("missing delete"); assert_eq!(missing.code(), SafeErrorCode::CredentialMissing); diff --git a/core/crates/harvestcircle_application/src/session.rs b/core/crates/harvestcircle_application/src/session.rs @@ -1,11 +1,11 @@ use crate::{ - AccountRepository, ActiveAccountSnapshot, AppCore, AppSnapshot, AppStateRepository, Clock, + ActiveIdentitySnapshot, AppCore, AppSnapshot, AppStateRepository, Clock, IdentityRepository, ProfileLoadState, ProfileRepository, RelayConnectionState, SecretStore, StateTransition, }; use harvestcircle_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage}; impl AppCore { - /// Drops the active session while retaining accounts, selection, and credentials. + /// Drops the active session while retaining identities, selection, and credentials. /// /// # Errors /// @@ -17,24 +17,24 @@ impl AppCore { self.apply_transition(StateTransition::SignOut) } - /// Validates and prepares a saved local account before replacing the active session. + /// Validates and prepares a saved local identity before replacing the active session. /// /// # Errors /// - /// Returns a safe account, credential, profile-cache, persistence, or state + /// Returns a safe identity, credential, profile-cache, persistence, or state /// error while preserving any previously active session. - pub fn activate_account( + pub fn activate_identity( &self, public_key: PublicKey, - accounts: &(impl AccountRepository + ?Sized), + identities: &(impl IdentityRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), profiles: &(impl ProfileRepository + ?Sized), secrets: &(impl SecretStore + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<AppSnapshot, SafeError> { - let account = accounts - .find_account(public_key)? - .ok_or_else(account_not_found)?; + let identity = identities + .find_identity(public_key)? + .ok_or_else(identity_not_found)?; self.apply_transition(StateTransition::BeginActivation(public_key))?; let prepared = (|| { let credential = secrets.load(public_key)?; @@ -45,8 +45,8 @@ impl AppCore { return Err(invalid_credential()); } let cached = profiles.load_profile(public_key)?; - let active = ActiveAccountSnapshot::new( - account.with_last_used_at(clock.now()), + let active = ActiveIdentitySnapshot::new( + identity.with_last_used_at(clock.now()), RelayConnectionState::Disconnected, if cached.is_some() { ProfileLoadState::Cached @@ -55,8 +55,8 @@ impl AppCore { }, cached.map(|profile| profile.candidate().metadata().clone()), ); - accounts.update_account(active.account())?; - app_state.save_selected_account(Some(public_key))?; + identities.update_identity(active.identity())?; + app_state.save_selected_identity(Some(public_key))?; Ok(active) })(); match prepared { @@ -71,17 +71,17 @@ impl AppCore { } } -const fn account_not_found() -> SafeError { +const fn identity_not_found() -> SafeError { SafeError::new( - SafeErrorCode::AccountNotFound, - SafeMessage::new("The account was not found."), + SafeErrorCode::IdentityNotFound, + SafeMessage::new("The identity was not found."), ) } const fn invalid_credential() -> SafeError { SafeError::new( SafeErrorCode::InvalidSecretKey, - SafeMessage::new("The Nostr account credential is invalid."), + SafeMessage::new("The Nostr identity credential is invalid."), ) } @@ -90,7 +90,7 @@ mod tests { use harvestcircle_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; use crate::{ - AppCore, CachedProfile, Clock, InMemoryAccountRepository, InMemoryOperationJournal, + AppCore, CachedProfile, Clock, InMemoryIdentityRepository, InMemoryOperationJournal, InMemorySecretStore, ProfileRefreshStatus, ProfileRepository, RelayConfiguration, SecretStore, SessionState, }; @@ -134,9 +134,9 @@ mod tests { } #[test] - fn activate_account_switches_only_after_candidate_is_ready() { + fn activate_identity_switches_only_after_candidate_is_ready() { let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); + let identities = InMemoryIdentityRepository::default(); let secrets = InMemorySecretStore::default(); let journal = InMemoryOperationJournal::default(); let profiles = EmptyProfiles; @@ -144,31 +144,31 @@ mod tests { let first = core .import_secret_key( input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"), - &accounts, - &accounts, + &identities, + &identities, &secrets, &journal, &FixedClock, ) .expect("first") - .account() + .identity() .public_key(); let second = core .import_secret_key( input("1111111111111111111111111111111111111111111111111111111111111111"), - &accounts, - &accounts, + &identities, + &identities, &secrets, &journal, &FixedClock, ) .expect("second") - .account() + .identity() .public_key(); - core.activate_account( + core.activate_identity( first, - &accounts, - &accounts, + &identities, + &identities, &profiles, &secrets, &FixedClock, @@ -177,17 +177,17 @@ mod tests { assert_eq!(core.snapshot().session(), SessionState::Active); assert_eq!( core.snapshot() - .active_account() - .map(|active| active.account().public_key()), + .active_identity() + .map(|active| active.identity().public_key()), Some(first) ); secrets.delete(second).expect("remove second credential"); let error = core - .activate_account( + .activate_identity( second, - &accounts, - &accounts, + &identities, + &identities, &profiles, &secrets, &FixedClock, @@ -204,10 +204,10 @@ mod tests { ) .expect("mismatched credential"); let invalid = core - .activate_account( + .activate_identity( second, - &accounts, - &accounts, + &identities, + &identities, &profiles, &secrets, &FixedClock, @@ -220,16 +220,16 @@ mod tests { assert_eq!(core.snapshot().session(), SessionState::Active); assert_eq!( core.snapshot() - .active_account() - .map(|active| active.account().public_key()), + .active_identity() + .map(|active| active.identity().public_key()), Some(first) ); } #[test] - fn sign_out_retains_saved_account_selection_and_credential() { + fn sign_out_retains_saved_identity_selection_and_credential() { let core = AppCore::in_memory(RelayConfiguration::default()); - let accounts = InMemoryAccountRepository::default(); + let identities = InMemoryIdentityRepository::default(); let secrets = InMemorySecretStore::default(); let journal = InMemoryOperationJournal::default(); let profiles = EmptyProfiles; @@ -237,19 +237,19 @@ mod tests { let public_key = core .import_secret_key( input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"), - &accounts, - &accounts, + &identities, + &identities, &secrets, &journal, &FixedClock, ) .expect("import") - .account() + .identity() .public_key(); - core.activate_account( + core.activate_identity( public_key, - &accounts, - &accounts, + &identities, + &identities, &profiles, &secrets, &FixedClock, @@ -260,9 +260,9 @@ mod tests { let repeated = core.sign_out().expect("idempotent sign out"); assert_eq!(signed_out, repeated); assert_eq!(signed_out.session(), SessionState::SignedOut); - assert!(signed_out.active_account().is_none()); - assert_eq!(signed_out.accounts().len(), 1); - assert_eq!(signed_out.selected_account(), Some(public_key)); + assert!(signed_out.active_identity().is_none()); + assert_eq!(signed_out.identities().len(), 1); + assert_eq!(signed_out.selected_identity(), Some(public_key)); assert!(secrets.contains(public_key).expect("credential retained")); } } diff --git a/core/crates/harvestcircle_application/src/snapshot.rs b/core/crates/harvestcircle_application/src/snapshot.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; use harvestcircle_domain::{ - AccountSummary, ProfileMetadata, PublicKey, RelayUrl, SafeError, SafeErrorCode, SafeMessage, + NostrIdentity, ProfileMetadata, PublicKey, RelayUrl, SafeError, SafeErrorCode, SafeMessage, }; pub const MAX_CONFIGURED_RELAYS: usize = 16; @@ -99,23 +99,23 @@ const fn relay_limit_exceeded() -> SafeError { } #[derive(Clone, Debug, Eq, PartialEq)] -pub struct ActiveAccountSnapshot { - account: AccountSummary, +pub struct ActiveIdentitySnapshot { + identity: NostrIdentity, relay_state: RelayConnectionState, profile_state: ProfileLoadState, profile: Option<ProfileMetadata>, } -impl ActiveAccountSnapshot { +impl ActiveIdentitySnapshot { #[must_use] pub const fn new( - account: AccountSummary, + identity: NostrIdentity, relay_state: RelayConnectionState, profile_state: ProfileLoadState, profile: Option<ProfileMetadata>, ) -> Self { Self { - account, + identity, relay_state, profile_state, profile, @@ -123,8 +123,8 @@ impl ActiveAccountSnapshot { } #[must_use] - pub const fn account(&self) -> &AccountSummary { - &self.account + pub const fn identity(&self) -> &NostrIdentity { + &self.identity } #[must_use] @@ -148,10 +148,10 @@ pub struct AppSnapshot { revision: SnapshotRevision, lifecycle: AppLifecycle, relay_configuration: RelayConfiguration, - accounts: Vec<AccountSummary>, - selected_account: Option<PublicKey>, + identities: Vec<NostrIdentity>, + selected_identity: Option<PublicKey>, session: SessionState, - active_account: Option<ActiveAccountSnapshot>, + active_identity: Option<ActiveIdentitySnapshot>, recoverable_problem: Option<SafeError>, } @@ -162,10 +162,10 @@ impl AppSnapshot { revision: SnapshotRevision::initial(), lifecycle: AppLifecycle::Booting, relay_configuration: RelayConfiguration::default(), - accounts: Vec::new(), - selected_account: None, + identities: Vec::new(), + selected_identity: None, session: SessionState::SignedOut, - active_account: None, + active_identity: None, recoverable_problem: None, } } @@ -180,10 +180,10 @@ impl AppSnapshot { revision, lifecycle: AppLifecycle::Fatal(error), relay_configuration, - accounts: Vec::new(), - selected_account: None, + identities: Vec::new(), + selected_identity: None, session: SessionState::SignedOut, - active_account: None, + active_identity: None, recoverable_problem: None, } } @@ -192,31 +192,31 @@ impl AppSnapshot { /// /// # Errors /// - /// Returns a safe invalid-state error for duplicate accounts, invalid + /// Returns a safe invalid-state error for duplicate identities, invalid /// selection, or inconsistent active-session state. pub fn ready( revision: SnapshotRevision, relay_configuration: RelayConfiguration, - accounts: Vec<AccountSummary>, - selected_account: Option<PublicKey>, + identities: Vec<NostrIdentity>, + selected_identity: Option<PublicKey>, session: SessionState, - active_account: Option<ActiveAccountSnapshot>, + active_identity: Option<ActiveIdentitySnapshot>, recoverable_problem: Option<SafeError>, ) -> Result<Self, SafeError> { validate_snapshot( - &accounts, - selected_account, + &identities, + selected_identity, session, - active_account.as_ref(), + active_identity.as_ref(), )?; Ok(Self { revision, lifecycle: AppLifecycle::Ready, relay_configuration, - accounts, - selected_account, + identities, + selected_identity, session, - active_account, + active_identity, recoverable_problem, }) } @@ -237,13 +237,13 @@ impl AppSnapshot { } #[must_use] - pub fn accounts(&self) -> &[AccountSummary] { - &self.accounts + pub fn identities(&self) -> &[NostrIdentity] { + &self.identities } #[must_use] - pub const fn selected_account(&self) -> Option<PublicKey> { - self.selected_account + pub const fn selected_identity(&self) -> Option<PublicKey> { + self.selected_identity } #[must_use] @@ -252,8 +252,8 @@ impl AppSnapshot { } #[must_use] - pub const fn active_account(&self) -> Option<&ActiveAccountSnapshot> { - self.active_account.as_ref() + pub const fn active_identity(&self) -> Option<&ActiveIdentitySnapshot> { + self.active_identity.as_ref() } #[must_use] @@ -263,22 +263,22 @@ impl AppSnapshot { } fn validate_snapshot( - accounts: &[AccountSummary], - selected_account: Option<PublicKey>, + identities: &[NostrIdentity], + selected_identity: Option<PublicKey>, session: SessionState, - active_account: Option<&ActiveAccountSnapshot>, + active_identity: Option<&ActiveIdentitySnapshot>, ) -> Result<(), SafeError> { - let unique_accounts = accounts + let unique_identities = identities .iter() - .map(AccountSummary::public_key) + .map(NostrIdentity::public_key) .collect::<HashSet<_>>(); - if unique_accounts.len() != accounts.len() - || (accounts.is_empty() != selected_account.is_none()) - || selected_account.is_some_and(|key| !unique_accounts.contains(&key)) - || active_account - .is_some_and(|active| !unique_accounts.contains(&active.account().public_key())) - || (matches!(session, SessionState::Active) && active_account.is_none()) - || (matches!(session, SessionState::SignedOut) && active_account.is_some()) + if unique_identities.len() != identities.len() + || (identities.is_empty() != selected_identity.is_none()) + || selected_identity.is_some_and(|key| !unique_identities.contains(&key)) + || active_identity + .is_some_and(|active| !unique_identities.contains(&active.identity().public_key())) + || (matches!(session, SessionState::Active) && active_identity.is_none()) + || (matches!(session, SessionState::SignedOut) && active_identity.is_some()) { return Err(invalid_snapshot()); } @@ -295,26 +295,26 @@ const fn invalid_snapshot() -> SafeError { #[cfg(test)] mod tests { use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - RelayDestinationPolicy, RelayUrl, SafeErrorCode, UnixTimestamp, + IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, + RelayDestinationPolicy, RelayUrl, SafeErrorCode, SignerAvailability, UnixTimestamp, }; use super::{ - ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration, + ActiveIdentitySnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration, RelayConnectionState, SessionState, SnapshotRevision, }; - fn account(key_byte: u8) -> AccountSummary { + fn identity(key_byte: u8) -> NostrIdentity { let public_key = crate::test_support::valid_test_public_key(key_byte).expect("valid public key"); - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), + NostrIdentity::new( + NostrIdentityReference::derive(public_key).expect("identity"), + LocalKeyringBinding::new(public_key, SignerAvailability::Available), None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")), + IdentityCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")), None, ) - .expect("account") + .expect("identity") } #[test] @@ -325,9 +325,9 @@ mod tests { assert_eq!(snapshot.revision(), SnapshotRevision::initial()); assert_eq!(snapshot.lifecycle(), AppLifecycle::Booting); assert_eq!(snapshot.session(), SessionState::SignedOut); - assert!(snapshot.accounts().is_empty()); - assert!(snapshot.selected_account().is_none()); - assert!(snapshot.active_account().is_none()); + assert!(snapshot.identities().is_empty()); + assert!(snapshot.selected_identity().is_none()); + assert!(snapshot.active_identity().is_none()); assert!(snapshot.relay_configuration().relays().is_empty()); assert!(snapshot.recoverable_problem().is_none()); assert!(!debug.contains("nsec1")); @@ -362,9 +362,9 @@ mod tests { #[test] fn ready_snapshot_requires_valid_selection_and_active_session() { - let first = account(1); - let second = account(2); - let active = ActiveAccountSnapshot::new( + let first = identity(1); + let second = identity(2); + let active = ActiveIdentitySnapshot::new( second.clone(), RelayConnectionState::Disconnected, ProfileLoadState::Empty, @@ -382,11 +382,11 @@ mod tests { .expect("valid ready snapshot"); assert_eq!(valid.lifecycle(), AppLifecycle::Ready); - assert_eq!(valid.selected_account(), Some(first.public_key())); + assert_eq!(valid.selected_identity(), Some(first.public_key())); assert_eq!( valid - .active_account() - .map(|value| value.account().public_key()), + .active_identity() + .map(|value| value.identity().public_key()), Some(second.public_key()) ); @@ -415,7 +415,7 @@ mod tests { .is_err() ); - let missing = account(3); + let missing = identity(3); for result in [ AppSnapshot::ready( SnapshotRevision::initial(), @@ -450,7 +450,7 @@ mod tests { vec![second.clone()], Some(second.public_key()), SessionState::SignedOut, - Some(ActiveAccountSnapshot::new( + Some(ActiveIdentitySnapshot::new( missing, RelayConnectionState::Disconnected, ProfileLoadState::Empty, @@ -464,7 +464,7 @@ mod tests { vec![second.clone()], Some(second.public_key()), SessionState::SignedOut, - Some(ActiveAccountSnapshot::new( + Some(ActiveIdentitySnapshot::new( second, RelayConnectionState::Disconnected, ProfileLoadState::Empty, diff --git a/core/crates/harvestcircle_application/src/state_machine.rs b/core/crates/harvestcircle_application/src/state_machine.rs @@ -1,30 +1,30 @@ -use harvestcircle_domain::{AccountSummary, PublicKey, SafeError, SafeErrorCode, SafeMessage}; +use harvestcircle_domain::{NostrIdentity, PublicKey, SafeError, SafeErrorCode, SafeMessage}; -use crate::{ActiveAccountSnapshot, AppLifecycle, AppSnapshot, RelayConfiguration, SessionState}; +use crate::{ActiveIdentitySnapshot, AppLifecycle, AppSnapshot, RelayConfiguration, SessionState}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum StateTransition { Bootstrap, BootstrapRegistry { - accounts: Vec<AccountSummary>, + identities: Vec<NostrIdentity>, selected: Option<PublicKey>, }, Fatal(SafeError), ReplaceRegistry { - accounts: Vec<AccountSummary>, + identities: Vec<NostrIdentity>, selected: Option<PublicKey>, }, ReplaceRegistryPreservingSession { - accounts: Vec<AccountSummary>, + identities: Vec<NostrIdentity>, selected: Option<PublicKey>, }, Select(PublicKey), BeginActivation(PublicKey), - ActivationSucceeded(Box<ActiveAccountSnapshot>), + ActivationSucceeded(Box<ActiveIdentitySnapshot>), ActivationFailed(SafeError), - UpdateActiveAccount { + UpdateActiveIdentity { expected: PublicKey, - active_account: Box<ActiveAccountSnapshot>, + active_identity: Box<ActiveIdentitySnapshot>, problem: Option<SafeError>, }, SignOut, @@ -34,7 +34,7 @@ pub enum StateTransition { #[derive(Clone)] struct PreviousSession { session: SessionState, - active_account: Option<ActiveAccountSnapshot>, + active_identity: Option<ActiveIdentitySnapshot>, } pub struct StateMachine { @@ -60,7 +60,7 @@ impl StateMachine { /// /// # Errors /// - /// Returns a safe application error when the transition violates account, + /// Returns a safe application error when the transition violates identity, /// revision, activation, or snapshot invariants. pub fn apply( &mut self, @@ -75,39 +75,44 @@ impl StateMachine { let next = match transition { StateTransition::Bootstrap => self.bootstrap(next_revision, relay_configuration)?, - StateTransition::BootstrapRegistry { accounts, selected } => { - self.bootstrap_registry(next_revision, relay_configuration, accounts, selected)? + StateTransition::BootstrapRegistry { + identities, + selected, + } => { + self.bootstrap_registry(next_revision, relay_configuration, identities, selected)? } StateTransition::Fatal(error) => { AppSnapshot::fatal(next_revision, relay_configuration.clone(), error) } - StateTransition::ReplaceRegistry { accounts, selected } => { - self.replace_registry(next_revision, accounts, selected)? - } - StateTransition::ReplaceRegistryPreservingSession { accounts, selected } => { - self.replace_registry_preserving_session(next_revision, accounts, selected)? - } + StateTransition::ReplaceRegistry { + identities, + selected, + } => self.replace_registry(next_revision, identities, selected)?, + StateTransition::ReplaceRegistryPreservingSession { + identities, + selected, + } => self.replace_registry_preserving_session(next_revision, identities, selected)?, StateTransition::Select(public_key) => self.select(next_revision, public_key)?, StateTransition::BeginActivation(public_key) => { self.begin_activation(next_revision, public_key)? } - StateTransition::ActivationSucceeded(active_account) => { - self.activation_succeeded(next_revision, *active_account)? + StateTransition::ActivationSucceeded(active_identity) => { + self.activation_succeeded(next_revision, *active_identity)? } StateTransition::ActivationFailed(problem) => { self.activation_failed(next_revision, problem)? } - StateTransition::UpdateActiveAccount { + StateTransition::UpdateActiveIdentity { expected, - active_account, + active_identity, problem, - } => self.update_active_account(next_revision, expected, *active_account, problem)?, + } => self.update_active_identity(next_revision, expected, *active_identity, problem)?, StateTransition::SignOut => self.sign_out(next_revision)?, StateTransition::SetProblem(problem) => self.copy_ready( next_revision, - self.snapshot.selected_account(), + self.snapshot.selected_identity(), self.snapshot.session(), - self.snapshot.active_account().cloned(), + self.snapshot.active_identity().cloned(), problem, )?, }; @@ -138,7 +143,7 @@ impl StateMachine { &self, revision: crate::SnapshotRevision, relay_configuration: &RelayConfiguration, - accounts: Vec<AccountSummary>, + identities: Vec<NostrIdentity>, selected: Option<PublicKey>, ) -> Result<AppSnapshot, SafeError> { if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) { @@ -147,7 +152,7 @@ impl StateMachine { AppSnapshot::ready( revision, relay_configuration.clone(), - accounts, + identities, selected, SessionState::SignedOut, None, @@ -158,14 +163,14 @@ impl StateMachine { fn replace_registry( &mut self, revision: crate::SnapshotRevision, - accounts: Vec<AccountSummary>, + identities: Vec<NostrIdentity>, selected: Option<PublicKey>, ) -> Result<AppSnapshot, SafeError> { self.pending_activation = None; AppSnapshot::ready( revision, self.snapshot.relay_configuration().clone(), - accounts, + identities, selected, SessionState::SignedOut, None, @@ -176,17 +181,17 @@ impl StateMachine { fn replace_registry_preserving_session( &mut self, revision: crate::SnapshotRevision, - accounts: Vec<AccountSummary>, + identities: Vec<NostrIdentity>, selected: Option<PublicKey>, ) -> Result<AppSnapshot, SafeError> { self.pending_activation = None; AppSnapshot::ready( revision, self.snapshot.relay_configuration().clone(), - accounts, + identities, selected, self.snapshot.session(), - self.snapshot.active_account().cloned(), + self.snapshot.active_identity().cloned(), None, ) } @@ -196,12 +201,12 @@ impl StateMachine { revision: crate::SnapshotRevision, public_key: PublicKey, ) -> Result<AppSnapshot, SafeError> { - self.require_account(public_key)?; + self.require_identity(public_key)?; self.copy_ready( revision, Some(public_key), self.snapshot.session(), - self.snapshot.active_account().cloned(), + self.snapshot.active_identity().cloned(), None, ) } @@ -211,7 +216,7 @@ impl StateMachine { revision: crate::SnapshotRevision, public_key: PublicKey, ) -> Result<AppSnapshot, SafeError> { - self.require_account(public_key)?; + self.require_identity(public_key)?; if self.pending_activation.is_some() { return Err(invalid_application_state()); } @@ -219,14 +224,14 @@ impl StateMachine { public_key, PreviousSession { session: self.snapshot.session(), - active_account: self.snapshot.active_account().cloned(), + active_identity: self.snapshot.active_identity().cloned(), }, )); self.copy_ready( revision, - self.snapshot.selected_account(), + self.snapshot.selected_identity(), SessionState::Activating(public_key), - self.snapshot.active_account().cloned(), + self.snapshot.active_identity().cloned(), None, ) } @@ -234,12 +239,12 @@ impl StateMachine { fn activation_succeeded( &mut self, revision: crate::SnapshotRevision, - active_account: ActiveAccountSnapshot, + active_identity: ActiveIdentitySnapshot, ) -> Result<AppSnapshot, SafeError> { let Some((target, _previous)) = self.pending_activation.as_ref() else { return Err(invalid_application_state()); }; - if active_account.account().public_key() != *target { + if active_identity.identity().public_key() != *target { return Err(invalid_application_state()); } let target = *target; @@ -248,7 +253,7 @@ impl StateMachine { revision, Some(target), SessionState::Active, - Some(active_account), + Some(active_identity), None, ) } @@ -263,9 +268,9 @@ impl StateMachine { }; self.copy_ready( revision, - self.snapshot.selected_account(), + self.snapshot.selected_identity(), previous.session, - previous.active_account, + previous.active_identity, Some(problem), ) } @@ -274,67 +279,67 @@ impl StateMachine { self.pending_activation = None; self.copy_ready( revision, - self.snapshot.selected_account(), + self.snapshot.selected_identity(), SessionState::SignedOut, None, None, ) } - fn update_active_account( + fn update_active_identity( &self, revision: crate::SnapshotRevision, expected: PublicKey, - active_account: ActiveAccountSnapshot, + active_identity: ActiveIdentitySnapshot, problem: Option<SafeError>, ) -> Result<AppSnapshot, SafeError> { if !matches!(self.snapshot.session(), SessionState::Active) || self .snapshot - .active_account() - .map(|active| active.account().public_key()) + .active_identity() + .map(|active| active.identity().public_key()) != Some(expected) - || active_account.account().public_key() != expected + || active_identity.identity().public_key() != expected { return Err(invalid_application_state()); } self.copy_ready( revision, - self.snapshot.selected_account(), + self.snapshot.selected_identity(), SessionState::Active, - Some(active_account), + Some(active_identity), problem, ) } - fn require_account(&self, public_key: PublicKey) -> Result<(), SafeError> { + fn require_identity(&self, public_key: PublicKey) -> Result<(), SafeError> { if self .snapshot - .accounts() + .identities() .iter() - .any(|account| account.public_key() == public_key) + .any(|identity| identity.public_key() == public_key) { Ok(()) } else { - Err(account_not_found()) + Err(identity_not_found()) } } fn copy_ready( &self, revision: crate::SnapshotRevision, - selected_account: Option<PublicKey>, + selected_identity: Option<PublicKey>, session: SessionState, - active_account: Option<ActiveAccountSnapshot>, + active_identity: Option<ActiveIdentitySnapshot>, recoverable_problem: Option<SafeError>, ) -> Result<AppSnapshot, SafeError> { AppSnapshot::ready( revision, self.snapshot.relay_configuration().clone(), - self.snapshot.accounts().to_vec(), - selected_account, + self.snapshot.identities().to_vec(), + selected_identity, session, - active_account, + active_identity, recoverable_problem, ) } @@ -347,41 +352,41 @@ const fn invalid_application_state() -> SafeError { ) } -const fn account_not_found() -> SafeError { +const fn identity_not_found() -> SafeError { SafeError::new( - SafeErrorCode::AccountNotFound, - SafeMessage::new("The account was not found."), + SafeErrorCode::IdentityNotFound, + SafeMessage::new("The identity was not found."), ) } #[cfg(test)] mod tests { use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, + IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, SafeError, + SafeErrorCode, SafeMessage, SignerAvailability, UnixTimestamp, }; use crate::{ - ActiveAccountSnapshot, ProfileLoadState, RelayConfiguration, RelayConnectionState, + ActiveIdentitySnapshot, ProfileLoadState, RelayConfiguration, RelayConnectionState, SessionState, StateMachine, StateTransition, }; - fn account(key_byte: u8) -> AccountSummary { + fn identity(key_byte: u8) -> NostrIdentity { let public_key = crate::test_support::valid_test_public_key(key_byte).expect("valid public key"); - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), + NostrIdentity::new( + NostrIdentityReference::derive(public_key).expect("identity"), + LocalKeyringBinding::new(public_key, SignerAvailability::Available), None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")), + IdentityCreatedAt::new(UnixTimestamp::from_seconds(1).expect("valid time")), None, ) - .expect("account") + .expect("identity") } - fn active(account: AccountSummary) -> ActiveAccountSnapshot { - ActiveAccountSnapshot::new( - account, + fn active(identity: NostrIdentity) -> ActiveIdentitySnapshot { + ActiveIdentitySnapshot::new( + identity, RelayConnectionState::Disconnected, ProfileLoadState::Empty, None, @@ -390,13 +395,13 @@ mod tests { #[test] fn state_machine_command_trace_preserves_working_session_on_failed_replacement() { - let first = account(1); - let second = account(2); + let first = identity(1); + let second = identity(2); let mut machine = StateMachine::booting(); let relays = RelayConfiguration::default(); let problem = SafeError::new( SafeErrorCode::CredentialMissing, - SafeMessage::new("The account credential is missing."), + SafeMessage::new("The identity credential is missing."), ); machine @@ -405,7 +410,7 @@ mod tests { machine .apply( StateTransition::ReplaceRegistry { - accounts: vec![first.clone(), second.clone()], + identities: vec![first.clone(), second.clone()], selected: Some(first.public_key()), }, &relays, @@ -442,16 +447,16 @@ mod tests { ); assert_eq!( pending - .active_account() - .map(|value| value.account().public_key()), + .active_identity() + .map(|value| value.identity().public_key()), Some(first.public_key()) ); assert_eq!(restored.session(), SessionState::Active); - assert_eq!(restored.selected_account(), Some(second.public_key())); + assert_eq!(restored.selected_identity(), Some(second.public_key())); assert_eq!( restored - .active_account() - .map(|value| value.account().public_key()), + .active_identity() + .map(|value| value.identity().public_key()), Some(first.public_key()) ); assert_eq!(restored.recoverable_problem(), Some(problem)); @@ -460,7 +465,7 @@ mod tests { #[test] fn state_machine_rejects_missing_targets_and_signs_out_without_deleting() { - let account = account(1); + let identity = identity(1); let mut machine = StateMachine::booting(); let relays = RelayConfiguration::default(); machine @@ -469,8 +474,8 @@ mod tests { machine .apply( StateTransition::ReplaceRegistry { - accounts: vec![account.clone()], - selected: Some(account.public_key()), + identities: vec![identity.clone()], + selected: Some(identity.public_key()), }, &relays, ) @@ -483,18 +488,18 @@ mod tests { ), &relays, ) - .expect_err("missing account"); - assert_eq!(error.code(), SafeErrorCode::AccountNotFound); + .expect_err("missing identity"); + assert_eq!(error.code(), SafeErrorCode::IdentityNotFound); machine .apply( - StateTransition::BeginActivation(account.public_key()), + StateTransition::BeginActivation(identity.public_key()), &relays, ) .expect("begin activation"); machine .apply( - StateTransition::ActivationSucceeded(Box::new(active(account.clone()))), + StateTransition::ActivationSucceeded(Box::new(active(identity.clone()))), &relays, ) .expect("activate"); @@ -502,25 +507,25 @@ mod tests { .apply(StateTransition::SignOut, &relays) .expect("sign out"); - assert_eq!(signed_out.accounts(), &[account]); + assert_eq!(signed_out.identities(), &[identity]); assert_eq!(signed_out.session(), SessionState::SignedOut); - assert!(signed_out.active_account().is_none()); + assert!(signed_out.active_identity().is_none()); } #[test] fn activation_state_policy_rejects_every_stale_or_mismatched_transition() { - let first = account(1); - let second = account(2); + let first = identity(1); + let second = identity(2); let relays = RelayConfiguration::default(); let problem = SafeError::new( SafeErrorCode::CredentialMissing, - SafeMessage::new("The account credential is missing."), + SafeMessage::new("The identity credential is missing."), ); let mut machine = StateMachine::booting(); machine .apply( StateTransition::BootstrapRegistry { - accounts: vec![first.clone(), second.clone()], + identities: vec![first.clone(), second.clone()], selected: Some(first.public_key()), }, &relays, @@ -529,13 +534,13 @@ mod tests { let unchanged = machine .apply( StateTransition::BootstrapRegistry { - accounts: Vec::new(), + identities: Vec::new(), selected: None, }, &relays, ) .expect("repeated bootstrap is idempotent"); - assert_eq!(unchanged.accounts().len(), 2); + assert_eq!(unchanged.identities().len(), 2); assert!( machine @@ -586,9 +591,9 @@ mod tests { assert!( machine .apply( - StateTransition::UpdateActiveAccount { + StateTransition::UpdateActiveIdentity { expected, - active_account: Box::new(active(candidate)), + active_identity: Box::new(active(candidate)), problem: None, }, &relays, @@ -603,9 +608,9 @@ mod tests { assert!( machine .apply( - StateTransition::UpdateActiveAccount { + StateTransition::UpdateActiveIdentity { expected: first.public_key(), - active_account: Box::new(active(first)), + active_identity: Box::new(active(first)), problem: None, }, &relays, diff --git a/core/crates/harvestcircle_application/tests/redaction.rs b/core/crates/harvestcircle_application/tests/redaction.rs @@ -1,7 +1,7 @@ use harvestcircle_application::{AppSnapshot, RelayConfiguration, SessionState, SnapshotRevision}; use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, + IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, PublicKey, + SafeError, SafeErrorCode, SafeMessage, SignerAvailability, UnixTimestamp, }; const SECRET_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; @@ -14,23 +14,23 @@ fn assert_redacted(text: &str) { #[test] fn redaction_guards_public_snapshot_and_safe_error_debug() { - let account = AccountSummary::new( - AccountIdentity::derive(PublicKey::from_bytes([7; 32]).expect("valid public key")) + let identity = NostrIdentity::new( + NostrIdentityReference::derive(PublicKey::from_bytes([7; 32]).expect("valid public key")) .expect("identity"), - LocalSignerBinding::new( + LocalKeyringBinding::new( PublicKey::from_bytes([7; 32]).expect("valid public key"), - BindingAvailability::Available, + SignerAvailability::Available, ), None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + IdentityCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), None, ) - .expect("account"); + .expect("identity"); let snapshot = AppSnapshot::ready( SnapshotRevision::from_value(1), RelayConfiguration::default(), - vec![account.clone()], - Some(account.public_key()), + vec![identity.clone()], + Some(identity.public_key()), SessionState::SignedOut, None, None, diff --git a/core/crates/harvestcircle_domain/src/account.rs b/core/crates/harvestcircle_domain/src/account.rs @@ -1,430 +0,0 @@ -//! Public account metadata and lifecycle values. - -use crate::time::UnixTimestamp; -use crate::{Npub, PublicKey, SafeError, SafeErrorCode, SafeMessage}; - -const MAX_ACCOUNT_LABEL_CHARS: usize = 80; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AccountIdentity { - public_key: PublicKey, - npub: Npub, -} - -impl AccountIdentity { - /// Constructs one canonical Nostr account identity and derives its npub. - /// - /// # Errors - /// - /// Returns a safe public-key error if canonical NIP-19 encoding fails. - pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> { - Ok(Self { - public_key, - npub: Npub::derive(public_key)?, - }) - } - - /// Reconstitutes persisted identity only when its public forms agree. - /// - /// # Errors - /// - /// Returns a safe public-key error for a mismatched or malformed npub. - pub fn verify(public_key: PublicKey, npub: String) -> Result<Self, SafeError> { - Ok(Self { - public_key, - npub: Npub::verify(public_key, npub)?, - }) - } - - #[must_use] - pub const fn public_key(&self) -> PublicKey { - self.public_key - } - - #[must_use] - pub const fn npub(&self) -> &Npub { - &self.npub - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct LocalSignerBinding { - account: PublicKey, - availability: BindingAvailability, -} - -impl LocalSignerBinding { - #[must_use] - pub const fn new(account: PublicKey, availability: BindingAvailability) -> Self { - Self { - account, - availability, - } - } - - #[must_use] - pub const fn account(self) -> PublicKey { - self.account - } - - #[must_use] - pub const fn availability(self) -> BindingAvailability { - self.availability - } - - #[must_use] - pub const fn repair_action(self) -> Option<BindingRepairAction> { - match self.availability { - BindingAvailability::Available => None, - BindingAvailability::CredentialMissing => Some(BindingRepairAction::ImportCredential), - BindingAvailability::StoreUnavailable => { - Some(BindingRepairAction::RetryCredentialStore) - } - } - } - - /// Records a missing credential after a successful store lookup. - /// - /// # Errors - /// - /// Returns a safe state error unless the binding was previously available. - pub fn mark_credential_missing(&mut self) -> Result<(), SafeError> { - self.transition( - BindingAvailability::Available, - BindingAvailability::CredentialMissing, - ) - } - - pub fn mark_store_unavailable(&mut self) { - self.availability = BindingAvailability::StoreUnavailable; - } - - /// Completes an explicit credential repair. - /// - /// # Errors - /// - /// Returns a safe state error unless a credential was missing. - pub fn repair_credential(&mut self) -> Result<(), SafeError> { - self.transition( - BindingAvailability::CredentialMissing, - BindingAvailability::Available, - ) - } - - /// Resolves a recovered store lookup to its observed credential state. - /// - /// # Errors - /// - /// Returns a safe state error unless the credential store was unavailable. - pub fn resolve_store_recovery(&mut self, credential_present: bool) -> Result<(), SafeError> { - if self.availability != BindingAvailability::StoreUnavailable { - return Err(invalid_account_metadata()); - } - self.availability = if credential_present { - BindingAvailability::Available - } else { - BindingAvailability::CredentialMissing - }; - Ok(()) - } - - fn transition( - &mut self, - expected: BindingAvailability, - next: BindingAvailability, - ) -> Result<(), SafeError> { - if self.availability != expected { - return Err(invalid_account_metadata()); - } - self.availability = next; - Ok(()) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum BindingAvailability { - Available, - CredentialMissing, - StoreUnavailable, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum BindingRepairAction { - ImportCredential, - RetryCredentialStore, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AccountLabel(String); - -impl AccountLabel { - /// Trims and validates an optional human-assigned account label value. - /// - /// # Errors - /// - /// Returns a safe metadata error when the resulting label is empty, too - /// long, or contains a control character. - pub fn parse(value: &str) -> Result<Self, SafeError> { - let normalized = value.trim(); - if normalized.is_empty() - || normalized.chars().count() > MAX_ACCOUNT_LABEL_CHARS - || normalized.chars().any(char::is_control) - { - return Err(invalid_account_metadata()); - } - Ok(Self(normalized.to_owned())) - } - - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } -} - -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub struct AccountCreatedAt(UnixTimestamp); - -impl AccountCreatedAt { - #[must_use] - pub const fn new(timestamp: UnixTimestamp) -> Self { - Self(timestamp) - } - - #[must_use] - pub const fn timestamp(self) -> UnixTimestamp { - self.0 - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AccountSummary { - identity: AccountIdentity, - signer: LocalSignerBinding, - label: Option<AccountLabel>, - created_at: AccountCreatedAt, - last_used_at: Option<UnixTimestamp>, -} - -impl AccountSummary { - /// Creates an account summary whose identity and signer binding refer to the same account. - /// - /// # Errors - /// - /// Returns an invalid-account-metadata error when the signer binding belongs to a different - /// public key. - pub fn new( - identity: AccountIdentity, - signer: LocalSignerBinding, - label: Option<AccountLabel>, - created_at: AccountCreatedAt, - last_used_at: Option<UnixTimestamp>, - ) -> Result<Self, SafeError> { - if identity.public_key() != signer.account() { - return Err(invalid_account_metadata()); - } - Ok(Self { - identity, - signer, - label, - created_at, - last_used_at, - }) - } - - #[must_use] - pub const fn public_key(&self) -> PublicKey { - self.identity.public_key() - } - - #[must_use] - pub fn npub(&self) -> &Npub { - self.identity.npub() - } - - #[must_use] - pub const fn signer(&self) -> LocalSignerBinding { - self.signer - } - - #[must_use] - pub fn label(&self) -> Option<&AccountLabel> { - self.label.as_ref() - } - - #[must_use] - pub const fn created_at(&self) -> AccountCreatedAt { - self.created_at - } - - #[must_use] - pub const fn last_used_at(&self) -> Option<UnixTimestamp> { - self.last_used_at - } - - #[must_use] - pub fn with_binding_availability(&self, availability: BindingAvailability) -> Self { - Self { - identity: self.identity.clone(), - signer: LocalSignerBinding::new(self.public_key(), availability), - label: self.label.clone(), - created_at: self.created_at, - last_used_at: self.last_used_at, - } - } - - #[must_use] - pub fn with_last_used_at(&self, last_used_at: UnixTimestamp) -> Self { - Self { - identity: self.identity.clone(), - signer: self.signer, - label: self.label.clone(), - created_at: self.created_at, - last_used_at: Some(last_used_at), - } - } - - #[must_use] - pub fn display_label(&self) -> String { - self.label - .as_ref() - .map_or_else(|| self.npub().short(), |label| label.as_str().to_owned()) - } -} - -const fn invalid_account_metadata() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidAccountMetadata, - SafeMessage::new("The account metadata is invalid."), - ) -} - -#[cfg(test)] -mod tests { - use crate::PublicKey; - use crate::time::UnixTimestamp; - - use super::{ - AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, - BindingRepairAction, LocalSignerBinding, - }; - - const DERIVED_NPUB: &str = "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"; - const MISMATCHED_NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; - - fn public_key() -> PublicKey { - PublicKey::from_bytes([7_u8; 32]).expect("valid public key") - } - - fn account(label: Option<AccountLabel>) -> AccountSummary { - let public_key = public_key(); - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - label, - AccountCreatedAt::new(UnixTimestamp::from_seconds(10).expect("valid time")), - None, - ) - .expect("account") - } - - #[test] - fn account_label_is_trimmed_bounded_and_control_free() { - let label = AccountLabel::parse(" Farm account ").expect("valid label"); - assert_eq!(label.as_str(), "Farm account"); - - for invalid in ["", " ", "line\nbreak", &"x".repeat(81)] { - assert!(AccountLabel::parse(invalid).is_err()); - } - } - - #[test] - fn account_display_prefers_label_then_shortened_npub() { - let labelled = account(Some(AccountLabel::parse("Farm").expect("valid label"))); - let unlabelled = account(None); - - assert_eq!(labelled.display_label(), "Farm"); - assert_eq!(unlabelled.display_label(), "npub1qurswpc…rsnvjvl7"); - } - - #[test] - fn local_account_summary_contains_public_metadata_only() { - let account = account(None); - let debug = format!("{account:?}"); - - assert_eq!( - account.signer().availability(), - BindingAvailability::Available - ); - assert!(account.label().is_none()); - assert!(account.last_used_at().is_none()); - assert_eq!(account.created_at().timestamp().as_seconds(), 10); - assert_eq!(account.public_key(), public_key()); - assert_eq!(account.npub().as_str(), DERIVED_NPUB); - assert!(!debug.contains("nsec1")); - assert!(!debug.contains(&"11".repeat(32))); - } - - #[test] - fn account_identity_derives_npub_and_rejects_mismatched_persisted_forms() { - let public_key = public_key(); - let identity = AccountIdentity::derive(public_key).expect("identity"); - assert_eq!(identity.public_key(), public_key); - assert_eq!(identity.npub().as_str(), DERIVED_NPUB); - assert_eq!( - AccountIdentity::verify(public_key, DERIVED_NPUB.to_owned()).expect("verified"), - identity - ); - assert!(AccountIdentity::verify(public_key, MISMATCHED_NPUB.to_owned()).is_err()); - assert!( - AccountIdentity::verify( - PublicKey::from_hex( - "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - ) - .expect("second public key"), - MISMATCHED_NPUB.to_owned() - ) - .is_err() - ); - } - - #[test] - fn local_signer_binding_carries_only_canonical_account_identity() { - let public_key = public_key(); - let identity = AccountIdentity::derive(public_key).expect("identity"); - let binding = LocalSignerBinding::new(public_key, BindingAvailability::Available); - - assert_eq!(binding.account(), identity.public_key()); - assert!(!format!("{binding:?}").contains("nsec1")); - } - - #[test] - fn local_binding_repair_transitions_are_typed_and_fail_closed() { - let public_key = public_key(); - let mut binding = LocalSignerBinding::new(public_key, BindingAvailability::Available); - assert_eq!(binding.repair_action(), None); - assert!(binding.repair_credential().is_err()); - - binding - .mark_credential_missing() - .expect("missing credential"); - assert_eq!( - binding.repair_action(), - Some(BindingRepairAction::ImportCredential) - ); - binding.repair_credential().expect("repair"); - - binding.mark_store_unavailable(); - assert_eq!( - binding.repair_action(), - Some(BindingRepairAction::RetryCredentialStore) - ); - binding - .resolve_store_recovery(false) - .expect("store recovery"); - assert_eq!( - binding.availability(), - BindingAvailability::CredentialMissing - ); - assert!(binding.resolve_store_recovery(true).is_err()); - } -} diff --git a/core/crates/harvestcircle_domain/src/error.rs b/core/crates/harvestcircle_domain/src/error.rs @@ -5,11 +5,11 @@ use std::fmt::{self, Debug, Display, Formatter}; pub enum SafeErrorCode { InvalidPublicKey, InvalidSecretKey, - InvalidAccountMetadata, + InvalidIdentityMetadata, InvalidProfileMetadata, InvalidApplicationState, - AccountAlreadyExists, - AccountNotFound, + IdentityAlreadyExists, + IdentityNotFound, KeyringUnavailable, CredentialMissing, StorageUnavailable, diff --git a/core/crates/harvestcircle_domain/src/identity.rs b/core/crates/harvestcircle_domain/src/identity.rs @@ -0,0 +1,488 @@ +//! Public identity metadata and lifecycle values. + +use crate::time::UnixTimestamp; +use crate::{Npub, PublicKey, SafeError, SafeErrorCode, SafeMessage}; + +const MAX_IDENTITY_LABEL_CHARS: usize = 80; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NostrIdentityReference { + public_key: PublicKey, + npub: Npub, +} + +impl NostrIdentityReference { + /// Constructs one canonical Nostr identity reference and derives its npub. + /// + /// # Errors + /// + /// Returns a safe public-key error if canonical NIP-19 encoding fails. + pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> { + Ok(Self { + public_key, + npub: Npub::derive(public_key)?, + }) + } + + /// Reconstitutes persisted identity only when its public forms agree. + /// + /// # Errors + /// + /// Returns a safe public-key error for a mismatched or malformed npub. + pub fn verify(public_key: PublicKey, npub: String) -> Result<Self, SafeError> { + Ok(Self { + public_key, + npub: Npub::verify(public_key, npub)?, + }) + } + + #[must_use] + pub const fn public_key(&self) -> PublicKey { + self.public_key + } + + #[must_use] + pub const fn npub(&self) -> &Npub { + &self.npub + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LocalKeyringBinding { + identity: PublicKey, + availability: SignerAvailability, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum SignerBinding { + LocalKeyring(LocalKeyringBinding), +} + +impl SignerBinding { + #[must_use] + pub const fn identity(self) -> PublicKey { + match self { + Self::LocalKeyring(binding) => binding.identity(), + } + } + + #[must_use] + pub const fn availability(self) -> SignerAvailability { + match self { + Self::LocalKeyring(binding) => binding.availability(), + } + } + + #[must_use] + pub const fn local_keyring(self) -> LocalKeyringBinding { + match self { + Self::LocalKeyring(binding) => binding, + } + } +} + +impl From<LocalKeyringBinding> for SignerBinding { + fn from(binding: LocalKeyringBinding) -> Self { + Self::LocalKeyring(binding) + } +} + +impl LocalKeyringBinding { + #[must_use] + pub const fn new(identity: PublicKey, availability: SignerAvailability) -> Self { + Self { + identity, + availability, + } + } + + #[must_use] + pub const fn identity(self) -> PublicKey { + self.identity + } + + #[must_use] + pub const fn availability(self) -> SignerAvailability { + self.availability + } + + #[must_use] + pub const fn repair_action(self) -> Option<SignerRepairAction> { + match self.availability { + SignerAvailability::Available => None, + SignerAvailability::CredentialMissing => Some(SignerRepairAction::ImportCredential), + SignerAvailability::StoreUnavailable => Some(SignerRepairAction::RetryCredentialStore), + } + } + + /// Records a missing credential after a successful store lookup. + /// + /// # Errors + /// + /// Returns a safe state error unless the binding was previously available. + pub fn mark_credential_missing(&mut self) -> Result<(), SafeError> { + self.transition( + SignerAvailability::Available, + SignerAvailability::CredentialMissing, + ) + } + + pub fn mark_store_unavailable(&mut self) { + self.availability = SignerAvailability::StoreUnavailable; + } + + /// Completes an explicit credential repair. + /// + /// # Errors + /// + /// Returns a safe state error unless a credential was missing. + pub fn repair_credential(&mut self) -> Result<(), SafeError> { + self.transition( + SignerAvailability::CredentialMissing, + SignerAvailability::Available, + ) + } + + /// Resolves a recovered store lookup to its observed credential state. + /// + /// # Errors + /// + /// Returns a safe state error unless the credential store was unavailable. + pub fn resolve_store_recovery(&mut self, credential_present: bool) -> Result<(), SafeError> { + if self.availability != SignerAvailability::StoreUnavailable { + return Err(invalid_identity_metadata()); + } + self.availability = if credential_present { + SignerAvailability::Available + } else { + SignerAvailability::CredentialMissing + }; + Ok(()) + } + + fn transition( + &mut self, + expected: SignerAvailability, + next: SignerAvailability, + ) -> Result<(), SafeError> { + if self.availability != expected { + return Err(invalid_identity_metadata()); + } + self.availability = next; + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SignerAvailability { + Available, + CredentialMissing, + StoreUnavailable, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SignerRepairAction { + ImportCredential, + RetryCredentialStore, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IdentityLabel(String); + +impl IdentityLabel { + /// Trims and validates an optional human-assigned identity label value. + /// + /// # Errors + /// + /// Returns a safe metadata error when the resulting label is empty, too + /// long, or contains a control character. + pub fn parse(value: &str) -> Result<Self, SafeError> { + let normalized = value.trim(); + if normalized.is_empty() + || normalized.chars().count() > MAX_IDENTITY_LABEL_CHARS + || normalized.chars().any(char::is_control) + { + return Err(invalid_identity_metadata()); + } + Ok(Self(normalized.to_owned())) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct IdentityCreatedAt(UnixTimestamp); + +impl IdentityCreatedAt { + #[must_use] + pub const fn new(timestamp: UnixTimestamp) -> Self { + Self(timestamp) + } + + #[must_use] + pub const fn timestamp(self) -> UnixTimestamp { + self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NostrIdentity { + identity: NostrIdentityReference, + signer_binding: SignerBinding, + label: Option<IdentityLabel>, + created_at: IdentityCreatedAt, + last_used_at: Option<UnixTimestamp>, +} + +impl NostrIdentity { + /// Creates an identity summary whose identity and signer binding refer to the same identity. + /// + /// # Errors + /// + /// Returns an invalid-identity-metadata error when the signer binding belongs to a different + /// public key. + pub fn new( + identity: NostrIdentityReference, + signer_binding: impl Into<SignerBinding>, + label: Option<IdentityLabel>, + created_at: IdentityCreatedAt, + last_used_at: Option<UnixTimestamp>, + ) -> Result<Self, SafeError> { + let signer_binding = signer_binding.into(); + if identity.public_key() != signer_binding.identity() { + return Err(invalid_identity_metadata()); + } + Ok(Self { + identity, + signer_binding, + label, + created_at, + last_used_at, + }) + } + + #[must_use] + pub const fn public_key(&self) -> PublicKey { + self.identity.public_key() + } + + #[must_use] + pub fn npub(&self) -> &Npub { + self.identity.npub() + } + + #[must_use] + pub const fn signer_binding(&self) -> SignerBinding { + self.signer_binding + } + + #[must_use] + pub fn label(&self) -> Option<&IdentityLabel> { + self.label.as_ref() + } + + #[must_use] + pub const fn created_at(&self) -> IdentityCreatedAt { + self.created_at + } + + #[must_use] + pub const fn last_used_at(&self) -> Option<UnixTimestamp> { + self.last_used_at + } + + #[must_use] + pub fn with_binding_availability(&self, availability: SignerAvailability) -> Self { + Self { + identity: self.identity.clone(), + signer_binding: SignerBinding::LocalKeyring(LocalKeyringBinding::new( + self.public_key(), + availability, + )), + label: self.label.clone(), + created_at: self.created_at, + last_used_at: self.last_used_at, + } + } + + #[must_use] + pub fn with_last_used_at(&self, last_used_at: UnixTimestamp) -> Self { + Self { + identity: self.identity.clone(), + signer_binding: self.signer_binding, + label: self.label.clone(), + created_at: self.created_at, + last_used_at: Some(last_used_at), + } + } + + #[must_use] + pub fn display_label(&self) -> String { + self.label + .as_ref() + .map_or_else(|| self.npub().short(), |label| label.as_str().to_owned()) + } +} + +const fn invalid_identity_metadata() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidIdentityMetadata, + SafeMessage::new("The identity metadata is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use crate::PublicKey; + use crate::time::UnixTimestamp; + + use super::{ + IdentityCreatedAt, IdentityLabel, LocalKeyringBinding, NostrIdentity, + NostrIdentityReference, SignerAvailability, SignerBinding, SignerRepairAction, + }; + + const DERIVED_NPUB: &str = "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"; + const MISMATCHED_NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; + + fn public_key() -> PublicKey { + PublicKey::from_bytes([7_u8; 32]).expect("valid public key") + } + + fn identity(label: Option<IdentityLabel>) -> NostrIdentity { + let public_key = public_key(); + NostrIdentity::new( + NostrIdentityReference::derive(public_key).expect("identity"), + LocalKeyringBinding::new(public_key, SignerAvailability::Available), + label, + IdentityCreatedAt::new(UnixTimestamp::from_seconds(10).expect("valid time")), + None, + ) + .expect("identity") + } + + #[test] + fn identity_label_is_trimmed_bounded_and_control_free() { + let label = IdentityLabel::parse(" Farm identity ").expect("valid label"); + assert_eq!(label.as_str(), "Farm identity"); + + for invalid in ["", " ", "line\nbreak", &"x".repeat(81)] { + assert!(IdentityLabel::parse(invalid).is_err()); + } + } + + #[test] + fn identity_display_prefers_label_then_shortened_npub() { + let labelled = identity(Some(IdentityLabel::parse("Farm").expect("valid label"))); + let unlabelled = identity(None); + + assert_eq!(labelled.display_label(), "Farm"); + assert_eq!(unlabelled.display_label(), "npub1qurswpc…rsnvjvl7"); + } + + #[test] + fn local_identity_summary_contains_public_metadata_only() { + let identity = identity(None); + let debug = format!("{identity:?}"); + + assert_eq!( + identity.signer_binding().availability(), + SignerAvailability::Available + ); + assert!(identity.label().is_none()); + assert!(identity.last_used_at().is_none()); + assert_eq!(identity.created_at().timestamp().as_seconds(), 10); + assert_eq!(identity.public_key(), public_key()); + assert_eq!(identity.npub().as_str(), DERIVED_NPUB); + assert!(!debug.contains("nsec1")); + assert!(!debug.contains(&"11".repeat(32))); + } + + #[test] + fn nostr_identity_reference_derives_npub_and_rejects_mismatched_persisted_forms() { + let public_key = public_key(); + let identity = NostrIdentityReference::derive(public_key).expect("identity"); + assert_eq!(identity.public_key(), public_key); + assert_eq!(identity.npub().as_str(), DERIVED_NPUB); + assert_eq!( + NostrIdentityReference::verify(public_key, DERIVED_NPUB.to_owned()).expect("verified"), + identity + ); + assert!(NostrIdentityReference::verify(public_key, MISMATCHED_NPUB.to_owned()).is_err()); + assert!( + NostrIdentityReference::verify( + PublicKey::from_hex( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + ) + .expect("second public key"), + MISMATCHED_NPUB.to_owned() + ) + .is_err() + ); + } + + #[test] + fn local_keyring_binding_carries_only_canonical_identity_reference() { + let public_key = public_key(); + let identity = NostrIdentityReference::derive(public_key).expect("identity"); + let binding = LocalKeyringBinding::new(public_key, SignerAvailability::Available); + + assert_eq!(binding.identity(), identity.public_key()); + assert!(!format!("{binding:?}").contains("nsec1")); + } + + #[test] + fn signer_binding_rejects_an_identity_mismatch() { + let identity = NostrIdentityReference::derive(public_key()).expect("identity"); + let other = PublicKey::from_bytes([8_u8; 32]).expect("other public key"); + let binding = SignerBinding::LocalKeyring(LocalKeyringBinding::new( + other, + SignerAvailability::Available, + )); + + let error = NostrIdentity::new( + identity, + binding, + None, + IdentityCreatedAt::new(UnixTimestamp::from_seconds(10).expect("valid time")), + None, + ) + .expect_err("mismatched identity and binding"); + + assert_eq!(error.code(), crate::SafeErrorCode::InvalidIdentityMetadata); + } + + #[test] + fn local_keyring_binding_repair_transitions_are_typed_and_fail_closed() { + let public_key = public_key(); + let mut binding = LocalKeyringBinding::new(public_key, SignerAvailability::Available); + assert_eq!(binding.repair_action(), None); + assert!(binding.repair_credential().is_err()); + + binding + .mark_credential_missing() + .expect("missing credential"); + assert_eq!( + binding.repair_action(), + Some(SignerRepairAction::ImportCredential) + ); + binding.repair_credential().expect("repair"); + + binding.mark_store_unavailable(); + assert_eq!( + binding.repair_action(), + Some(SignerRepairAction::RetryCredentialStore) + ); + binding + .resolve_store_recovery(false) + .expect("store recovery"); + assert_eq!( + binding.availability(), + SignerAvailability::CredentialMissing + ); + assert!(binding.resolve_store_recovery(true).is_err()); + } +} diff --git a/core/crates/harvestcircle_domain/src/lib.rs b/core/crates/harvestcircle_domain/src/lib.rs @@ -1,17 +1,17 @@ -#![doc = "HarvestCircle Nostr account domain types."] +#![doc = "HarvestCircle Nostr identity domain types."] -pub mod account; pub mod error; +pub mod identity; pub mod key; pub mod profile; pub mod relay; pub mod time; -pub use account::{ - AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, - BindingRepairAction, LocalSignerBinding, -}; pub use error::{SafeError, SafeErrorCode, SafeMessage}; +pub use identity::{ + IdentityCreatedAt, IdentityLabel, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, + SignerAvailability, SignerBinding, SignerRepairAction, +}; pub use key::{ MAX_SECRET_KEY_INPUT_BYTES, Npub, Nsec, PersistedPublicKeyClassification, PublicKey, SecretKeyInput, SecretKeyInputKind, classify_persisted_public_key, diff --git a/core/crates/harvestcircle_domain/src/profile.rs b/core/crates/harvestcircle_domain/src/profile.rs @@ -243,7 +243,7 @@ mod tests { fn profile_fields_are_trimmed_bounded_and_public() { let metadata = ProfileMetadata::new( Some(" farmer ".to_owned()), - Some(" Farm Account ".to_owned()), + Some(" Farm Identity ".to_owned()), Some("farmer@example.test".to_owned()), Some("First line\nSecond line".to_owned()), Some("https://images.example.test/profile.png".to_owned()), @@ -251,8 +251,8 @@ mod tests { .expect("valid profile"); assert_eq!(metadata.name(), Some("farmer")); - assert_eq!(metadata.display_name(), Some("Farm Account")); - assert_eq!(metadata.preferred_name(), Some("Farm Account")); + assert_eq!(metadata.display_name(), Some("Farm Identity")); + assert_eq!(metadata.preferred_name(), Some("Farm Identity")); assert_eq!(metadata.nip05(), Some("farmer@example.test")); assert_eq!(metadata.about(), Some("First line\nSecond line")); assert_eq!( diff --git a/core/crates/harvestcircle_domain/src/time.rs b/core/crates/harvestcircle_domain/src/time.rs @@ -1,4 +1,4 @@ -//! Time values shared by account and profile records. +//! Time values shared by identity and profile records. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct UnixTimestamp(i64); diff --git a/core/crates/harvestcircle_ffi/src/commands.rs b/core/crates/harvestcircle_ffi/src/commands.rs @@ -23,7 +23,7 @@ use harvestcircle_runtime::{ use harvestcircle_storage::OsKeyringSecretStore; use crate::{ - AccountDto, AppSnapshotDto, WireErrorCategory, WireErrorCode, WireRecoveryAction, + AppSnapshotDto, IdentityDto, WireErrorCategory, WireErrorCode, WireRecoveryAction, contract::{ DISTRIBUTION_PACKAGE_VERSION, FFI_CONTRACT_HASH, FFI_CONTRACT_ID, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, MINIMUM_SCHEMA_VERSION, PRODUCT_COORDINATE_DIGEST, PRODUCT_VERSION, @@ -45,7 +45,7 @@ pub struct RequestContextDto { #[derive(Clone, Debug, Eq, PartialEq)] #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] -pub struct AccountCommandReceiptDto { +pub struct IdentityCommandReceiptDto { pub request_id: String, pub committed_revision: u64, pub snapshot: AppSnapshotDto, @@ -160,8 +160,8 @@ pub struct GeneratedRecoveryRequest { #[cfg_attr(not(coverage_nightly), uniffi::export)] impl GeneratedRecoveryRequest { - pub fn account(&self) -> AccountDto { - self.handle.view().account().into() + pub fn identity(&self) -> IdentityDto { + self.handle.view().identity().into() } pub fn expires_at_seconds(&self) -> i64 { @@ -285,12 +285,12 @@ impl HarvestCircleAppCore { self.inner.snapshot_dto() } - /// Begins the exclusive generated-account recovery flow without persistence. + /// Begins the exclusive generated-identity recovery flow without persistence. /// /// # Errors /// /// Returns a safe key-generation, conflict, timeout, or lifecycle error. - pub async fn begin_generated_account_v2( + pub async fn begin_generated_identity( &self, ) -> Result<Arc<GeneratedRecoveryRequest>, HarvestCircleError> { self.inner @@ -306,13 +306,13 @@ impl HarvestCircleAppCore { .map_err(HarvestCircleError::from) } - /// Acknowledges recovery and commits the generated account once. + /// Acknowledges recovery and commits the generated identity once. /// /// # Errors /// /// Returns a terminal safe recovery, credential, persistence, timeout, or lifecycle error. /// A failed commit must be recovered by importing the already-saved recovery key. - pub async fn acknowledge_generated_account_v2( + pub async fn acknowledge_generated_identity( &self, context: RequestContextDto, request: Arc<GeneratedRecoveryRequest>, @@ -336,12 +336,12 @@ impl HarvestCircleAppCore { .map_err(generated_commit_failed) } - /// Cancels the exclusive generated-account recovery flow. + /// Cancels the exclusive generated-identity recovery flow. /// /// # Errors /// /// Returns a safe timeout or lifecycle error. - pub async fn cancel_generated_account_v2( + pub async fn cancel_generated_identity( &self, request: Arc<GeneratedRecoveryRequest>, ) -> Result<bool, HarvestCircleError> { @@ -355,16 +355,16 @@ impl HarvestCircleAppCore { .map_err(HarvestCircleError::from) } - /// Imports or repairs an account using a caller-owned idempotency key. + /// Imports or repairs an identity using a caller-owned idempotency key. /// /// # Errors /// /// Returns a correlated validation, conflict, timeout, credential, or storage error. - pub async fn import_account_v2( + pub async fn import_identity( &self, context: RequestContextDto, secret_key: Vec<u8>, - ) -> Result<AccountCommandReceiptDto, HarvestCircleError> { + ) -> Result<IdentityCommandReceiptDto, HarvestCircleError> { let request_id = DurableRequestId::parse(context.request_id.clone()) .map_err(|error| HarvestCircleError::correlated(error, &context.request_id))?; let timeout = command_timeout(context.deadline_millis, &context.request_id)?; @@ -381,7 +381,7 @@ impl HarvestCircleAppCore { .await .map(|_| { let snapshot = self.inner.snapshot_dto(); - AccountCommandReceiptDto { + IdentityCommandReceiptDto { request_id: context.request_id.clone(), committed_revision: snapshot.revision, snapshot, @@ -390,43 +390,43 @@ impl HarvestCircleAppCore { .map_err(|error| HarvestCircleError::correlated(error, &context.request_id)) } - /// Selects one saved account without activating it. + /// Selects one saved identity without activating it. /// /// # Errors /// - /// Returns a safe public-key, account, or storage error. - pub async fn select_account( + /// Returns a safe public-key, identity, or storage error. + pub async fn select_identity( &self, public_key_hex: String, ) -> Result<AppSnapshotDto, HarvestCircleError> { let public_key = parse_public_key(&public_key_hex)?; self.inner .actor - .select_account(public_key) + .select_identity(public_key) .await .map(|snapshot| self.inner.dto_for(&snapshot)) .map_err(HarvestCircleError::from) } - /// Activates one saved account after validating its credential. + /// Activates one saved identity after validating its credential. /// /// # Errors /// - /// Returns a safe public-key, credential, account, or storage error. - pub async fn activate_account( + /// Returns a safe public-key, credential, identity, or storage error. + pub async fn activate_identity( &self, public_key_hex: String, ) -> Result<AppSnapshotDto, HarvestCircleError> { let public_key = parse_public_key(&public_key_hex)?; self.inner .actor - .activate_account(public_key) + .activate_identity(public_key) .await .map(|snapshot| self.inner.dto_for(&snapshot)) .map_err(HarvestCircleError::from) } - /// Signs out while retaining accounts and credentials. + /// Signs out while retaining identities and credentials. /// /// # Errors /// @@ -458,15 +458,15 @@ impl HarvestCircleAppCore { /// /// # Errors /// - /// Returns a safe public-key or account error. - pub async fn request_account_removal( + /// Returns a safe public-key or identity error. + pub async fn request_identity_removal( &self, public_key_hex: String, ) -> Result<Arc<RemovalRequest>, HarvestCircleError> { let public_key = parse_public_key(&public_key_hex)?; self.inner .actor - .request_account_removal(public_key) + .request_identity_removal(public_key) .await .map(|token| { let impact = token.impact(); @@ -481,12 +481,12 @@ impl HarvestCircleAppCore { .map_err(HarvestCircleError::from) } - /// Permanently removes the account represented by a one-time request. + /// Permanently removes the identity represented by a one-time request. /// /// # Errors /// /// Returns a safe confirmation, credential, recovery, or storage error. - pub async fn confirm_account_removal( + pub async fn confirm_identity_removal( &self, context: RequestContextDto, request: Arc<RemovalRequest>, @@ -502,7 +502,7 @@ impl HarvestCircleAppCore { let timeout = command_timeout(context.deadline_millis, &context.request_id)?; self.inner .actor - .confirm_account_removal( + .confirm_identity_removal( token, request_id, harvestcircle_application::SnapshotRevision::from_value(context.expected_revision), @@ -682,7 +682,7 @@ fn confirmation_expired() -> HarvestCircleError { retryable: false, recovery_action: WireRecoveryAction::None, correlation_id: None, - safe_message: "The account removal confirmation is no longer valid.".to_owned(), + safe_message: "The identity removal confirmation is no longer valid.".to_owned(), } } @@ -706,7 +706,7 @@ fn generated_commit_failed(error: SafeError) -> HarvestCircleError { recovery_action: WireRecoveryAction::None, correlation_id: None, safe_message: - "The generated account could not be saved. Import the recovery key you saved to try again." + "The generated identity could not be saved. Import the recovery key you saved to try again." .to_owned(), } } @@ -793,16 +793,16 @@ mod tests { }; let secret = b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; let first = core - .import_account_v2(context.clone(), secret.to_vec()) + .import_identity(context.clone(), secret.to_vec()) .await .expect("first import"); let replay = core - .import_account_v2(context, secret.to_vec()) + .import_identity(context, secret.to_vec()) .await .expect("replayed import"); assert_eq!(first, replay); - assert_eq!(first.snapshot.accounts.len(), 1); + assert_eq!(first.snapshot.identities.len(), 1); assert_eq!(first.request_id, "ffi-test-import-1"); } @@ -811,7 +811,7 @@ mod tests { let core = in_memory_core().await; let initial = core.snapshot(); let recovery = core - .begin_generated_account_v2() + .begin_generated_identity() .await .expect("begin recovery"); @@ -825,12 +825,12 @@ mod tests { deadline_millis: 5_000, }; let committed = core - .acknowledge_generated_account_v2(context.clone(), Arc::clone(&recovery)) + .acknowledge_generated_identity(context.clone(), Arc::clone(&recovery)) .await .expect("acknowledge"); - assert_eq!(committed.accounts.len(), 1); + assert_eq!(committed.identities.len(), 1); let repeated = core - .acknowledge_generated_account_v2(context, recovery) + .acknowledge_generated_identity(context, recovery) .await .expect_err("repeated acknowledgement"); assert!(matches!( @@ -841,11 +841,11 @@ mod tests { } #[tokio::test] - async fn account_lifecycle_and_one_use_removal_are_exercised_through_the_ffi_boundary() { + async fn identity_lifecycle_and_one_use_removal_are_exercised_through_the_ffi_boundary() { let core = in_memory_core().await; let initial = core.bootstrap().await.expect("bootstrap"); let imported = core - .import_account_v2( + .import_identity( RequestContextDto { request_id: "ffi-lifecycle-import".to_owned(), expected_revision: initial.revision, @@ -854,17 +854,17 @@ mod tests { b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_vec(), ) .await - .expect("import account"); - let public_key = imported.snapshot.accounts[0].public_key_hex.clone(); + .expect("import identity"); + let public_key = imported.snapshot.identities[0].public_key_hex.clone(); let selected = core - .select_account(public_key.clone()) + .select_identity(public_key.clone()) .await - .expect("select account"); + .expect("select identity"); let active = core - .activate_account(public_key.clone()) + .activate_identity(public_key.clone()) .await - .expect("activate account"); + .expect("activate identity"); assert!(active.revision > selected.revision); let signed_out = core.sign_out().await.expect("sign out"); assert!(signed_out.revision > active.revision); @@ -875,7 +875,7 @@ mod tests { assert_eq!(refreshed.revision, signed_out.revision); let removal = core - .request_account_removal(public_key.clone()) + .request_identity_removal(public_key.clone()) .await .expect("request removal"); assert_eq!(removal.public_key_hex(), public_key); @@ -883,7 +883,7 @@ mod tests { assert!(!removal.signs_out()); assert!(removal.expires_at_seconds() > 0); let removed = core - .confirm_account_removal( + .confirm_identity_removal( RequestContextDto { request_id: "ffi-lifecycle-remove".to_owned(), expected_revision: signed_out.revision, @@ -893,9 +893,9 @@ mod tests { ) .await .expect("confirm removal"); - assert!(removed.accounts.is_empty()); + assert!(removed.identities.is_empty()); assert!( - core.confirm_account_removal( + core.confirm_identity_removal( RequestContextDto { request_id: "ffi-lifecycle-remove-repeated".to_owned(), expected_revision: removed.revision, @@ -907,7 +907,7 @@ mod tests { .is_err() ); assert!( - core.select_account("not-a-public-key".to_owned()) + core.select_identity("not-a-public-key".to_owned()) .await .is_err() ); @@ -917,19 +917,19 @@ mod tests { async fn generated_recovery_cancellation_and_request_validation_fail_closed() { let core = in_memory_core().await; let recovery = core - .begin_generated_account_v2() + .begin_generated_identity() .await - .expect("begin generated account"); - assert_eq!(recovery.account().public_key_hex.len(), 64); + .expect("begin generated identity"); + assert_eq!(recovery.identity().public_key_hex.len(), 64); assert!(recovery.expires_at_seconds() > 0); assert!( - core.cancel_generated_account_v2(Arc::clone(&recovery)) + core.cancel_generated_identity(Arc::clone(&recovery)) .await .expect("first cancellation") ); assert!( !core - .cancel_generated_account_v2(recovery) + .cancel_generated_identity(recovery) .await .expect("second cancellation") ); @@ -951,10 +951,10 @@ mod tests { deadline_millis: 30_001, }, ] { - assert!(core.import_account_v2(context, vec![0; 32]).await.is_err()); + assert!(core.import_identity(context, vec![0; 32]).await.is_err()); } assert!( - core.import_account_v2( + core.import_identity( RequestContextDto { request_id: "ffi-invalid-secret".to_owned(), expected_revision: 0, @@ -993,7 +993,7 @@ mod tests { WireErrorCategory::Lifecycle, false, WireRecoveryAction::None, - "The account removal confirmation is no longer valid.", + "The identity removal confirmation is no longer valid.", ), ( generated_commit_failed(SafeError::new( @@ -1004,7 +1004,7 @@ mod tests { WireErrorCategory::Storage, false, WireRecoveryAction::None, - "The generated account could not be saved. Import the recovery key you saved to try again.", + "The generated identity could not be saved. Import the recovery key you saved to try again.", ), ] { assert_eq!(error.to_string(), message); @@ -1118,7 +1118,7 @@ mod tests { let commands = include_str!("commands.rs"); let observer = include_str!("observer.rs"); for forbidden in [ - format!("pub async fn {}_account(", "generate"), + format!("pub async fn {}_identity(", "generate"), format!("pub async fn {}_secret_key(", "import"), format!("pub fn {}(development_mode", "open"), format!("pub async fn {}(", "subscribe"), diff --git a/core/crates/harvestcircle_ffi/src/dto.rs b/core/crates/harvestcircle_ffi/src/dto.rs @@ -1,9 +1,9 @@ use harvestcircle_application::{ - ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConnectionState, + ActiveIdentitySnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConnectionState, RuntimeLifecycle, SessionState, }; use harvestcircle_domain::{ - AccountSummary, BindingAvailability, ProfileMetadata, SafeError, SafeErrorCode, + NostrIdentity, ProfileMetadata, SafeError, SafeErrorCode, SignerAvailability, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -11,11 +11,11 @@ use harvestcircle_domain::{ pub enum WireErrorCode { InvalidPublicKey, InvalidSecretKey, - InvalidAccountMetadata, + InvalidIdentityMetadata, InvalidProfileMetadata, InvalidApplicationState, - AccountAlreadyExists, - AccountNotFound, + IdentityAlreadyExists, + IdentityNotFound, KeyringUnavailable, CredentialMissing, StorageUnavailable, @@ -119,19 +119,16 @@ pub enum ProfileLoadStateDto { #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] -pub enum SignerKindDto { - LocalSecret, - WatchOnly, - RemoteNip46, +pub enum SignerBindingKindDto { + LocalKeyring, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] -pub enum KeyAvailabilityDto { +pub enum SignerAvailabilityDto { Available, CredentialMissing, StoreUnavailable, - NotRequired, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -146,20 +143,20 @@ pub struct ProfileDto { #[derive(Clone, Debug, Eq, PartialEq)] #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] -pub struct AccountDto { +pub struct IdentityDto { pub public_key_hex: String, pub npub: String, pub display_label: String, - pub signer_kind: SignerKindDto, - pub key_availability: KeyAvailabilityDto, + pub signer_binding_kind: SignerBindingKindDto, + pub signer_availability: SignerAvailabilityDto, pub created_at_seconds: i64, pub last_used_at_seconds: Option<i64>, } #[derive(Clone, Debug, Eq, PartialEq)] #[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] -pub struct ActiveAccountDto { - pub account: AccountDto, +pub struct ActiveIdentityDto { + pub identity: IdentityDto, pub relay_state: RelayConnectionStateDto, pub profile_state: ProfileLoadStateDto, pub profile: Option<ProfileDto>, @@ -172,12 +169,12 @@ pub struct AppSnapshotDto { pub lifecycle: AppLifecycleDto, pub lifecycle_error: Option<SafeErrorDto>, pub configured_relays: Vec<String>, - pub accounts: Vec<AccountDto>, + pub identities: Vec<IdentityDto>, pub selected_public_key_hex: Option<String>, pub session: SessionStateDto, pub session_subject_public_key_hex: Option<String>, pub session_error: Option<SafeErrorDto>, - pub active_account: Option<ActiveAccountDto>, + pub active_identity: Option<ActiveIdentityDto>, pub recoverable_problem: Option<SafeErrorDto>, } @@ -207,14 +204,18 @@ impl From<&AppSnapshot> for AppSnapshotDto { .iter() .map(|relay| relay.as_str().to_owned()) .collect(), - accounts: snapshot.accounts().iter().map(AccountDto::from).collect(), + identities: snapshot + .identities() + .iter() + .map(IdentityDto::from) + .collect(), selected_public_key_hex: snapshot - .selected_account() + .selected_identity() .map(harvestcircle_domain::PublicKey::to_hex), session, session_subject_public_key_hex, session_error, - active_account: snapshot.active_account().map(ActiveAccountDto::from), + active_identity: snapshot.active_identity().map(ActiveIdentityDto::from), recoverable_problem: snapshot.recoverable_problem().map(SafeErrorDto::from), } } @@ -250,26 +251,26 @@ impl AppSnapshotDto { } } -impl From<&AccountSummary> for AccountDto { - fn from(account: &AccountSummary) -> Self { +impl From<&NostrIdentity> for IdentityDto { + fn from(identity: &NostrIdentity) -> Self { Self { - public_key_hex: account.public_key().to_hex(), - npub: account.npub().as_str().to_owned(), - display_label: account.display_label(), - signer_kind: SignerKindDto::LocalSecret, - key_availability: account.signer().availability().into(), - created_at_seconds: account.created_at().timestamp().as_seconds(), - last_used_at_seconds: account + public_key_hex: identity.public_key().to_hex(), + npub: identity.npub().as_str().to_owned(), + display_label: identity.display_label(), + signer_binding_kind: SignerBindingKindDto::LocalKeyring, + signer_availability: identity.signer_binding().availability().into(), + created_at_seconds: identity.created_at().timestamp().as_seconds(), + last_used_at_seconds: identity .last_used_at() .map(harvestcircle_domain::UnixTimestamp::as_seconds), } } } -impl From<&ActiveAccountSnapshot> for ActiveAccountDto { - fn from(active: &ActiveAccountSnapshot) -> Self { +impl From<&ActiveIdentitySnapshot> for ActiveIdentityDto { + fn from(active: &ActiveIdentitySnapshot) -> Self { Self { - account: active.account().into(), + identity: active.identity().into(), relay_state: active.relay_state().into(), profile_state: active.profile_state().into(), profile: active.profile().map(ProfileDto::from), @@ -307,11 +308,11 @@ impl From<SafeErrorCode> for WireErrorCode { match code { SafeErrorCode::InvalidPublicKey => Self::InvalidPublicKey, SafeErrorCode::InvalidSecretKey => Self::InvalidSecretKey, - SafeErrorCode::InvalidAccountMetadata => Self::InvalidAccountMetadata, + SafeErrorCode::InvalidIdentityMetadata => Self::InvalidIdentityMetadata, SafeErrorCode::InvalidProfileMetadata => Self::InvalidProfileMetadata, SafeErrorCode::InvalidApplicationState => Self::InvalidApplicationState, - SafeErrorCode::AccountAlreadyExists => Self::AccountAlreadyExists, - SafeErrorCode::AccountNotFound => Self::AccountNotFound, + SafeErrorCode::IdentityAlreadyExists => Self::IdentityAlreadyExists, + SafeErrorCode::IdentityNotFound => Self::IdentityNotFound, SafeErrorCode::KeyringUnavailable => Self::KeyringUnavailable, SafeErrorCode::CredentialMissing => Self::CredentialMissing, SafeErrorCode::StorageUnavailable => Self::StorageUnavailable, @@ -338,11 +339,11 @@ pub(crate) const fn error_policy( match code { SafeErrorCode::InvalidPublicKey | SafeErrorCode::InvalidSecretKey - | SafeErrorCode::InvalidAccountMetadata + | SafeErrorCode::InvalidIdentityMetadata | SafeErrorCode::InvalidProfileMetadata => { (WireErrorCategory::Input, false, WireRecoveryAction::None) } - SafeErrorCode::AccountAlreadyExists | SafeErrorCode::AccountNotFound => { + SafeErrorCode::IdentityAlreadyExists | SafeErrorCode::IdentityNotFound => { (WireErrorCategory::Conflict, false, WireRecoveryAction::None) } SafeErrorCode::KeyringUnavailable => ( @@ -406,12 +407,12 @@ pub(crate) const fn error_policy( } } -impl From<BindingAvailability> for KeyAvailabilityDto { - fn from(value: BindingAvailability) -> Self { +impl From<SignerAvailability> for SignerAvailabilityDto { + fn from(value: SignerAvailability) -> Self { match value { - BindingAvailability::Available => Self::Available, - BindingAvailability::CredentialMissing => Self::CredentialMissing, - BindingAvailability::StoreUnavailable => Self::StoreUnavailable, + SignerAvailability::Available => Self::Available, + SignerAvailability::CredentialMissing => Self::CredentialMissing, + SignerAvailability::StoreUnavailable => Self::StoreUnavailable, } } } @@ -450,12 +451,12 @@ mod tests { }; use harvestcircle_nostr::NostrKeyMaterialProvider; - use harvestcircle_domain::{BindingAvailability, SafeError, SafeErrorCode, SafeMessage}; + use harvestcircle_domain::{SafeError, SafeErrorCode, SafeMessage, SignerAvailability}; use super::{ - AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileLoadStateDto, - RelayConnectionStateDto, SafeErrorDto, WireErrorCategory, WireErrorCode, - WireRecoveryAction, error_policy, + AppLifecycleDto, AppSnapshotDto, ProfileLoadStateDto, RelayConnectionStateDto, + SafeErrorDto, SignerAvailabilityDto, WireErrorCategory, WireErrorCode, WireRecoveryAction, + error_policy, }; fn safe_error(code: SafeErrorCode) -> SafeError { @@ -473,7 +474,7 @@ mod tests { let debug = format!("{dto:?}"); assert_eq!(dto.revision, 1); - assert!(dto.accounts.is_empty()); + assert!(dto.identities.is_empty()); assert!(!debug.contains("nsec")); assert!(!debug.contains("secret_key")); assert!(!debug.contains("server_url")); @@ -525,8 +526,8 @@ mod tests { WireErrorCode::InvalidSecretKey, ), ( - SafeErrorCode::InvalidAccountMetadata, - WireErrorCode::InvalidAccountMetadata, + SafeErrorCode::InvalidIdentityMetadata, + WireErrorCode::InvalidIdentityMetadata, ), ( SafeErrorCode::InvalidProfileMetadata, @@ -537,12 +538,12 @@ mod tests { WireErrorCode::InvalidApplicationState, ), ( - SafeErrorCode::AccountAlreadyExists, - WireErrorCode::AccountAlreadyExists, + SafeErrorCode::IdentityAlreadyExists, + WireErrorCode::IdentityAlreadyExists, ), ( - SafeErrorCode::AccountNotFound, - WireErrorCode::AccountNotFound, + SafeErrorCode::IdentityNotFound, + WireErrorCode::IdentityNotFound, ), ( SafeErrorCode::KeyringUnavailable, @@ -675,19 +676,19 @@ mod tests { for (source, expected) in [ ( - BindingAvailability::Available, - KeyAvailabilityDto::Available, + SignerAvailability::Available, + SignerAvailabilityDto::Available, ), ( - BindingAvailability::CredentialMissing, - KeyAvailabilityDto::CredentialMissing, + SignerAvailability::CredentialMissing, + SignerAvailabilityDto::CredentialMissing, ), ( - BindingAvailability::StoreUnavailable, - KeyAvailabilityDto::StoreUnavailable, + SignerAvailability::StoreUnavailable, + SignerAvailabilityDto::StoreUnavailable, ), ] { - assert_eq!(KeyAvailabilityDto::from(source), expected); + assert_eq!(SignerAvailabilityDto::from(source), expected); } for (source, expected) in [ ( diff --git a/core/crates/harvestcircle_ffi/src/lib.rs b/core/crates/harvestcircle_ffi/src/lib.rs @@ -7,7 +7,7 @@ mod dto; mod observer; pub use commands::{ - AccountCommandReceiptDto, GeneratedRecoveryRequest, HarvestCircleAppCore, HarvestCircleError, + GeneratedRecoveryRequest, HarvestCircleAppCore, HarvestCircleError, IdentityCommandReceiptDto, RemovalRequest, RequestContextDto, }; pub use contract::{ @@ -16,9 +16,10 @@ pub use contract::{ SNAPSHOT_SCHEMA_VERSION, SOURCE_FOUNDATION_BASELINE, SOURCE_PROVENANCE_DIGEST, }; pub use dto::{ - AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto, - ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, SignerKindDto, - WireErrorCategory, WireErrorCode, WireRecoveryAction, + ActiveIdentityDto, AppLifecycleDto, AppSnapshotDto, IdentityDto, ProfileDto, + ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, + SignerAvailabilityDto, SignerBindingKindDto, WireErrorCategory, WireErrorCode, + WireRecoveryAction, }; pub use observer::{ HarvestCircleChangeObserver, ObserverSubscription, ShutdownReceiptDto, SnapshotChangeDto, diff --git a/core/crates/harvestcircle_ffi/src/observer.rs b/core/crates/harvestcircle_ffi/src/observer.rs @@ -437,7 +437,7 @@ mod tests { .await .expect("subscribe"); let imported = core - .import_account_v2( + .import_identity( crate::RequestContextDto { request_id: "observer-import".to_owned(), expected_revision: core.snapshot().revision, @@ -449,13 +449,13 @@ mod tests { .expect("import") .snapshot; let public_key = imported.selected_public_key_hex.expect("selection"); - core.activate_account(public_key).await.expect("activate"); + core.activate_identity(public_key).await.expect("activate"); core.refresh_active_profile().await.expect("refresh"); wait_for_fresh_profile(&observer).await; let snapshots = observer.snapshots.lock().expect("snapshots").clone(); assert!(snapshots.iter().any(|snapshot| { - snapshot.active_account.as_ref().is_some_and(|active| { + snapshot.active_identity.as_ref().is_some_and(|active| { active.profile_state == ProfileLoadStateDto::Fresh && active .profile @@ -503,7 +503,7 @@ mod tests { .expect("snapshots") .iter() .any(|snapshot| { - snapshot.active_account.as_ref().is_some_and(|active| { + snapshot.active_identity.as_ref().is_some_and(|active| { active.profile_state == ProfileLoadStateDto::Fresh }) }); diff --git a/core/crates/harvestcircle_nostr/src/client.rs b/core/crates/harvestcircle_nostr/src/client.rs @@ -195,7 +195,7 @@ mod tests { publisher.wait_for_connection(Duration::from_secs(2)).await; publisher .send_event_builder(EventBuilder::metadata( - &Metadata::new().name("Farmer").display_name("Farm Account"), + &Metadata::new().name("Farmer").display_name("Farm Identity"), )) .await .expect("publish metadata"); @@ -217,7 +217,7 @@ mod tests { let profile = profile.expect("published profile"); assert_eq!(profile.author(), public_key); - assert_eq!(profile.metadata().preferred_name(), Some("Farm Account")); + assert_eq!(profile.metadata().preferred_name(), Some("Farm Identity")); assert_eq!( completeness, harvestcircle_application::RelayFetchCompleteness::Complete diff --git a/core/crates/harvestcircle_nostr/src/profile.rs b/core/crates/harvestcircle_nostr/src/profile.rs @@ -77,7 +77,7 @@ mod tests { let event = EventBuilder::metadata( &Metadata::new() .name(" farmer ") - .display_name(" Farm Account ") + .display_name(" Farm Identity ") .nip05("farmer@example.test") .about("Local grower") .picture( @@ -100,7 +100,7 @@ mod tests { assert_eq!(candidate.author(), expected_author); assert_eq!(candidate.metadata().name(), Some("farmer")); - assert_eq!(candidate.metadata().display_name(), Some("Farm Account")); + assert_eq!(candidate.metadata().display_name(), Some("Farm Identity")); assert_eq!(candidate.metadata().nip05(), Some("farmer@example.test")); assert_eq!(candidate.metadata().about(), Some("Local grower")); assert_eq!( diff --git a/core/crates/harvestcircle_preferences/src/lib.rs b/core/crates/harvestcircle_preferences/src/lib.rs @@ -3,7 +3,7 @@ //! //! This module carries forward the uniquely required preference behavior from //! source commit `6074a4745be361f21bb47d4778c74a14b2d57954`. It intentionally -//! excludes that source's process-global state, sample account, and FFI layer. +//! excludes that source's process-global state, sample identity, and FFI layer. use url::Url; diff --git a/core/crates/harvestcircle_runtime/src/persistence.rs b/core/crates/harvestcircle_runtime/src/persistence.rs @@ -2,7 +2,7 @@ use std::path::Path; use std::sync::Arc; use harvestcircle_application::{ - AppCore, AppSnapshot, Clock, DurableRequestId, GenerateAccountReceipt, ImportAccountReceipt, + AppCore, AppSnapshot, Clock, DurableRequestId, GenerateIdentityReceipt, ImportIdentityReceipt, KeyMaterialProvider, RelayConfiguration, RemovalConfirmationToken, SecretStore, StagedGeneratedKey, }; @@ -45,7 +45,7 @@ impl PersistentAppCore { staged: StagedGeneratedKey, secrets: &(impl SecretStore + ?Sized), clock: &(impl Clock + ?Sized), - ) -> Result<ImportAccountReceipt, SafeError> { + ) -> Result<ImportIdentityReceipt, SafeError> { self.core.commit_staged_generated_key( request_id, staged, @@ -89,7 +89,7 @@ impl PersistentAppCore { self.key_material.as_ref() } - /// Restores public accounts and selection while keeping the session signed out. + /// Restores public identities and selection while keeping the session signed out. /// /// # Errors /// @@ -117,17 +117,17 @@ impl PersistentAppCore { self.core.bootstrap_from(&self.database, &self.database) } - /// Generates and durably persists one selected, signed-out local account. + /// Generates and durably persists one selected, signed-out local identity. /// /// # Errors /// /// Returns a safe credential, storage, key, or application-state error. - pub fn generate_account( + pub fn generate_identity( &self, secrets: &(impl SecretStore + ?Sized), clock: &(impl Clock + ?Sized), - ) -> Result<GenerateAccountReceipt, SafeError> { - self.core.generate_account( + ) -> Result<GenerateIdentityReceipt, SafeError> { + self.core.generate_identity( &self.database, &self.database, secrets, @@ -136,7 +136,7 @@ impl PersistentAppCore { ) } - /// Imports and durably persists one selected, signed-out local account. + /// Imports and durably persists one selected, signed-out local identity. /// /// # Errors /// @@ -146,7 +146,7 @@ impl PersistentAppCore { input: SecretKeyInput, secrets: &(impl SecretStore + ?Sized), clock: &(impl Clock + ?Sized), - ) -> Result<ImportAccountReceipt, SafeError> { + ) -> Result<ImportIdentityReceipt, SafeError> { self.core.import_secret_key( input, &self.database, @@ -157,19 +157,19 @@ impl PersistentAppCore { ) } - /// Generates an account through the durable request coordinator. + /// Generates an identity through the durable request coordinator. /// /// # Errors /// /// Returns a safe conflict, credential, storage, or application-state error. - pub fn generate_account_durable( + pub fn generate_identity_durable( &self, request_id: &DurableRequestId, expected_revision: u64, secrets: &(impl SecretStore + ?Sized), clock: &(impl Clock + ?Sized), - ) -> Result<GenerateAccountReceipt, SafeError> { - self.core.generate_account_durable( + ) -> Result<GenerateIdentityReceipt, SafeError> { + self.core.generate_identity_durable( request_id, expected_revision, &self.database, @@ -180,7 +180,7 @@ impl PersistentAppCore { ) } - /// Imports or repairs an account through the durable request coordinator. + /// Imports or repairs an identity through the durable request coordinator. /// /// # Errors /// @@ -192,7 +192,7 @@ impl PersistentAppCore { input: SecretKeyInput, secrets: &(impl SecretStore + ?Sized), clock: &(impl Clock + ?Sized), - ) -> Result<ImportAccountReceipt, SafeError> { + ) -> Result<ImportIdentityReceipt, SafeError> { self.core.import_secret_key_durable( request_id, expected_revision, @@ -205,28 +205,28 @@ impl PersistentAppCore { ) } - /// Persists and publishes one saved-account selection without activation. + /// Persists and publishes one saved-identity selection without activation. /// /// # Errors /// - /// Returns a safe account, storage, or application-state error. - pub fn select_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> { + /// Returns a safe identity, storage, or application-state error. + pub fn select_identity(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> { self.core - .select_account(public_key, &self.database, &self.database) + .select_identity(public_key, &self.database, &self.database) } - /// Activates a saved account after validating its credential and cached profile. + /// Activates a saved identity after validating its credential and cached profile. /// /// # Errors /// - /// Returns a safe account, credential, storage, or application-state error. - pub fn activate_account( + /// Returns a safe identity, credential, storage, or application-state error. + pub fn activate_identity( &self, public_key: PublicKey, secrets: &(impl SecretStore + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<AppSnapshot, SafeError> { - self.core.activate_account( + self.core.activate_identity( public_key, &self.database, &self.database, @@ -236,7 +236,7 @@ impl PersistentAppCore { ) } - /// Signs out while retaining durable account data and credentials. + /// Signs out while retaining durable identity data and credentials. /// /// # Errors /// @@ -245,31 +245,31 @@ impl PersistentAppCore { self.core.sign_out() } - /// Issues a revision-bound, single-use account-removal confirmation. + /// Issues a revision-bound, single-use identity-removal confirmation. /// /// # Errors /// - /// Returns a safe error when the target account is not saved. - pub fn request_account_removal( + /// Returns a safe error when the target identity is not saved. + pub fn request_identity_removal( &self, public_key: PublicKey, clock: &(impl Clock + ?Sized), ) -> Result<RemovalConfirmationToken, SafeError> { - self.core.request_account_removal(public_key, clock) + self.core.request_identity_removal(public_key, clock) } - /// Permanently removes one confirmed account and its credential. + /// Permanently removes one confirmed identity and its credential. /// /// # Errors /// /// Returns a safe confirmation, credential, storage, recovery, or state error. - pub fn confirm_account_removal( + pub fn confirm_identity_removal( &self, token: RemovalConfirmationToken, secrets: &(impl SecretStore + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<AppSnapshot, SafeError> { - self.core.confirm_account_removal( + self.core.confirm_identity_removal( token, &self.database, &self.database, @@ -284,14 +284,14 @@ impl PersistentAppCore { /// # Errors /// /// Returns a safe expiry, conflict, credential, storage, recovery, or state error. - pub fn confirm_account_removal_durable( + pub fn confirm_identity_removal_durable( &self, request_id: &DurableRequestId, token: RemovalConfirmationToken, secrets: &(impl SecretStore + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<AppSnapshot, SafeError> { - self.core.confirm_account_removal_durable( + self.core.confirm_identity_removal_durable( request_id, token, &self.database, @@ -318,30 +318,30 @@ mod tests { use std::fs; use harvestcircle_application::{ - AccountOperationKind, AccountOperationPhase, AccountRepository, AppLifecycle, - AppStateRepository, Clock, DurableOperationKind, DurableOperationPhase, + AppLifecycle, AppStateRepository, Clock, DurableOperationKind, DurableOperationPhase, DurableOperationRepository, DurableRequestId, DurableTerminalOutcome, FailureSecretStore, - InMemorySecretStore, OperationJournal, OperationPriorState, RelayConfiguration, - SecretStore, SecretStoreOperation, SessionState, + IdentityOperationKind, IdentityOperationPhase, IdentityRepository, InMemorySecretStore, + OperationJournal, OperationPriorState, RelayConfiguration, SecretStore, + SecretStoreOperation, SessionState, }; use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, SafeErrorCode, SecretKeyInput, UnixTimestamp, + IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, PublicKey, + SafeErrorCode, SecretKeyInput, SignerAvailability, UnixTimestamp, }; use tempfile::tempdir; use super::PersistentAppCore; - fn account() -> AccountSummary { + fn identity() -> NostrIdentity { let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), + NostrIdentity::new( + NostrIdentityReference::derive(public_key).expect("identity"), + LocalKeyringBinding::new(public_key, SignerAvailability::Available), None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + IdentityCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), None, ) - .expect("account") + .expect("identity") } struct FixedClock; @@ -360,7 +360,7 @@ mod tests { .canonicalize() .expect("canonical temporary directory") .join("harvestcircle.sqlite3"); - let public_key = account().public_key(); + let public_key = identity().public_key(); let secrets = InMemorySecretStore::default(); { let adapter = PersistentAppCore::open(&path, RelayConfiguration::default()) @@ -368,14 +368,14 @@ mod tests { let fresh = adapter .bootstrap(&secrets, &FixedClock) .expect("fresh bootstrap"); - assert!(fresh.accounts().is_empty()); + assert!(fresh.identities().is_empty()); adapter .database() - .insert_account(&account()) - .expect("account"); + .insert_identity(&identity()) + .expect("identity"); adapter .database() - .save_selected_account(Some(public_key)) + .save_selected_identity(Some(public_key)) .expect("selection"); } @@ -383,10 +383,10 @@ mod tests { PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter"); let restored = adapter.bootstrap(&secrets, &FixedClock).expect("restore"); assert_eq!(restored.lifecycle(), AppLifecycle::Ready); - assert_eq!(restored.accounts().len(), 1); - assert_eq!(restored.selected_account(), Some(public_key)); + assert_eq!(restored.identities().len(), 1); + assert_eq!(restored.selected_identity(), Some(public_key)); assert_eq!(restored.session(), SessionState::SignedOut); - assert!(restored.active_account().is_none()); + assert!(restored.active_identity().is_none()); } #[test] @@ -424,11 +424,11 @@ mod tests { PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter"); adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); let generated = adapter - .generate_account(&secrets, &FixedClock) + .generate_identity(&secrets, &FixedClock) .expect("generate"); assert!( secrets - .contains(generated.account().public_key()) + .contains(generated.identity().public_key()) .expect("generated credential") ); let imported = adapter @@ -442,8 +442,8 @@ mod tests { &FixedClock, ) .expect("import"); - selected = imported.account().public_key(); - assert_eq!(adapter.core().snapshot().accounts().len(), 2); + selected = imported.identity().public_key(); + assert_eq!(adapter.core().snapshot().identities().len(), 2); } let bytes = fs::read(&path).expect("database bytes"); @@ -454,8 +454,8 @@ mod tests { let reopened = PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen"); let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore"); - assert_eq!(restored.accounts().len(), 2); - assert_eq!(restored.selected_account(), Some(selected)); + assert_eq!(restored.identities().len(), 2); + assert_eq!(restored.selected_identity(), Some(selected)); assert_eq!(restored.session(), SessionState::SignedOut); } @@ -483,7 +483,7 @@ mod tests { .expect("operation") .expect("durable record"); let receipt = operation.terminal().expect("terminal receipt"); - assert_eq!(receipt.account(), imported.account().public_key()); + assert_eq!(receipt.identity(), imported.identity().public_key()); assert_eq!( receipt.resulting_revision(), Some(adapter.core().snapshot().revision().value()) @@ -494,14 +494,14 @@ mod tests { fn durable_recovery_preserves_repair_metadata_and_deletes_orphan_credentials() { let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); let secrets = InMemorySecretStore::default(); - let missing = account().with_binding_availability(BindingAvailability::CredentialMissing); + let missing = identity().with_binding_availability(SignerAvailability::CredentialMissing); adapter .database() - .insert_account(&missing) - .expect("account"); + .insert_identity(&missing) + .expect("identity"); adapter .database() - .save_selected_account(Some(missing.public_key())) + .save_selected_identity(Some(missing.public_key())) .expect("selection"); let request = DurableRequestId::parse("repair:recovery:1").expect("request"); adapter @@ -513,7 +513,7 @@ mod tests { Some(0), OperationPriorState::new( Some(missing.public_key()), - Some(BindingAvailability::CredentialMissing), + Some(SignerAvailability::CredentialMissing), ), FixedClock.now(), ) @@ -541,12 +541,12 @@ mod tests { adapter.bootstrap(&secrets, &FixedClock).expect("recovery"); let repaired = adapter .database() - .find_account(missing.public_key()) + .find_identity(missing.public_key()) .expect("lookup") - .expect("preserved account"); + .expect("preserved identity"); assert_eq!( - repaired.signer().availability(), - BindingAvailability::CredentialMissing + repaired.signer_binding().availability(), + SignerAvailability::CredentialMissing ); assert!(!secrets.contains(missing.public_key()).expect("credential")); assert_eq!( @@ -566,8 +566,11 @@ mod tests { fn durable_recovery_covers_response_loss_and_irreversible_removal_windows() { let secrets = InMemorySecretStore::default(); let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); - let saved = account(); - adapter.database().insert_account(&saved).expect("account"); + let saved = identity(); + adapter + .database() + .insert_identity(&saved) + .expect("identity"); let import = DurableRequestId::parse("import:response-loss:1").expect("request"); adapter .database() @@ -603,7 +606,7 @@ mod tests { let restored = adapter .bootstrap(&secrets, &FixedClock) .expect("response recovery"); - assert_eq!(restored.selected_account(), Some(saved.public_key())); + assert_eq!(restored.selected_identity(), Some(saved.public_key())); assert_eq!( adapter .database() @@ -620,11 +623,11 @@ mod tests { PersistentAppCore::in_memory(RelayConfiguration::default()).expect("remove adapter"); removal_adapter .database() - .insert_account(&saved) - .expect("remove account"); + .insert_identity(&saved) + .expect("remove identity"); removal_adapter .database() - .save_selected_account(Some(saved.public_key())) + .save_selected_identity(Some(saved.public_key())) .expect("remove selection"); let removal = DurableRequestId::parse("remove:response-loss:1").expect("request"); removal_adapter @@ -634,7 +637,7 @@ mod tests { DurableOperationKind::Remove, saved.public_key(), Some(0), - OperationPriorState::new(None, Some(BindingAvailability::Available)), + OperationPriorState::new(None, Some(SignerAvailability::Available)), FixedClock.now(), ) .expect("remove intent"); @@ -651,8 +654,8 @@ mod tests { let removed = removal_adapter .bootstrap(&secrets, &FixedClock) .expect("removal recovery"); - assert!(removed.accounts().is_empty()); - assert_eq!(removed.selected_account(), None); + assert!(removed.identities().is_empty()); + assert_eq!(removed.selected_identity(), None); } #[test] @@ -671,25 +674,25 @@ mod tests { PersistentAppCore::open(&path, RelayConfiguration::default()).expect("adapter"); adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); first = adapter - .generate_account(&secrets, &FixedClock) + .generate_identity(&secrets, &FixedClock) .expect("first") - .account() + .identity() .public_key(); removed = adapter - .generate_account(&secrets, &FixedClock) + .generate_identity(&secrets, &FixedClock) .expect("removed") - .account() + .identity() .public_key(); let operation = adapter .database() - .begin_operation(AccountOperationKind::Remove, removed, FixedClock.now()) + .begin_operation(IdentityOperationKind::Remove, removed, FixedClock.now()) .expect("intent"); secrets.delete(removed).expect("credential deletion"); adapter .database() .update_operation( operation, - AccountOperationPhase::CredentialDeleted, + IdentityOperationPhase::CredentialDeleted, FixedClock.now(), None, ) @@ -701,8 +704,8 @@ mod tests { let restored = reopened .bootstrap(&secrets, &FixedClock) .expect("recover and bootstrap"); - assert_eq!(restored.accounts().len(), 1); - assert_eq!(restored.selected_account(), Some(first)); + assert_eq!(restored.identities().len(), 1); + assert_eq!(restored.selected_identity(), Some(first)); assert_eq!(restored.session(), SessionState::SignedOut); assert!( reopened @@ -714,7 +717,7 @@ mod tests { assert!( reopened .database() - .find_account(removed) + .find_identity(removed) .expect("removed") .is_none() ); @@ -732,17 +735,17 @@ mod tests { let adapter = PersistentAppCore::in_memory(RelayConfiguration::default()).expect("adapter"); adapter .database() - .insert_account(&account()) - .expect("account"); + .insert_identity(&identity()) + .expect("identity"); adapter .database() - .save_selected_account(Some(account().public_key())) + .save_selected_identity(Some(identity().public_key())) .expect("selection"); adapter .database() .begin_operation( - AccountOperationKind::Remove, - account().public_key(), + IdentityOperationKind::Remove, + identity().public_key(), FixedClock.now(), ) .expect("intent"); @@ -757,6 +760,6 @@ mod tests { .list_pending_operations() .expect("pending"); assert_eq!(pending.len(), 1); - assert_eq!(pending[0].phase(), AccountOperationPhase::IntentRecorded); + assert_eq!(pending[0].phase(), IdentityOperationPhase::IntentRecorded); } } diff --git a/core/crates/harvestcircle_runtime/src/runtime_actor.rs b/core/crates/harvestcircle_runtime/src/runtime_actor.rs @@ -7,17 +7,17 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use harvestcircle_application::{ - ActorMailbox, AppSnapshot, ChangeSubscriptionId, Clock, CommandContext, CommandEnvelope, - CommandReceipt, CommandResult, CommandSubmission, DurableRequestId, ForegroundSessionBinding, - GenerateAccountReceipt, GeneratedKeyRecoveryHandle, GeneratedKeyStage, ImportAccountReceipt, + ActiveSessionBinding, ActorMailbox, AppSnapshot, ChangeSubscriptionId, Clock, CommandContext, + CommandEnvelope, CommandReceipt, CommandResult, CommandSubmission, DurableRequestId, + GenerateIdentityReceipt, GeneratedKeyRecoveryHandle, GeneratedKeyStage, ImportIdentityReceipt, LifecycleGate, NostrClient, OrderedSnapshotChanges, ProfileFetchResult, ProfileRefreshPlan, RecoveryStageId, RelayConfiguration, RemovalConfirmationToken, RequestId, RuntimeCommandClass, RuntimeLifecycle, SecretStore, SessionGeneration, SnapshotChange, SnapshotChangeReceiver, SnapshotRevision, StagedGeneratedKey, TaskCorrelation, }; use harvestcircle_domain::{ - AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, - SafeMessage, SecretKeyInput, + LocalKeyringBinding, NostrIdentityReference, PublicKey, SafeError, SafeErrorCode, SafeMessage, + SecretKeyInput, SignerAvailability, }; use tokio::runtime::Handle; use tokio::sync::{mpsc, oneshot, watch}; @@ -31,7 +31,7 @@ const DEFAULT_BLOCKING_CAPACITY: usize = 4; enum RuntimeCommand { Snapshot, - GenerateAccount { + GenerateIdentity { durable_request: DurableRequestId, expected_revision: u64, }, @@ -46,12 +46,12 @@ enum RuntimeCommand { durable_request: DurableRequestId, expected_revision: u64, }, - SelectAccount(PublicKey), - ActivateAccount(PublicKey), + SelectIdentity(PublicKey), + ActivateIdentity(PublicKey), SignOut, RefreshActiveProfile, - RequestAccountRemoval(PublicKey), - ConfirmAccountRemoval { + RequestIdentityRemoval(PublicKey), + ConfirmIdentityRemoval { token: RemovalConfirmationToken, durable_request: DurableRequestId, }, @@ -62,10 +62,10 @@ enum RuntimeCommand { enum RuntimeCommandValue { Snapshot(Box<AppSnapshot>), - Generated(GenerateAccountReceipt), + Generated(GenerateIdentityReceipt), GeneratedKeyStage(GeneratedKeyRecoveryHandle), GeneratedKeyStageCancelled(bool), - Imported(ImportAccountReceipt), + Imported(ImportIdentityReceipt), RemovalRequest(RemovalConfirmationToken), Subscription(RuntimeChangeSubscription), Unsubscribed(bool), @@ -78,15 +78,15 @@ impl RuntimeCommand { Self::Snapshot | Self::SubscribeChanges(_) | Self::UnsubscribeChanges(_) => { RuntimeCommandClass::Observe } - Self::GenerateAccount { .. } + Self::GenerateIdentity { .. } | Self::BeginGeneratedKeyStage | Self::AcknowledgeGeneratedKeyStage { .. } | Self::ImportSecretKey { .. } - | Self::ActivateAccount(_) - | Self::ConfirmAccountRemoval { .. } => RuntimeCommandClass::UseCredential, - Self::SelectAccount(_) + | Self::ActivateIdentity(_) + | Self::ConfirmIdentityRemoval { .. } => RuntimeCommandClass::UseCredential, + Self::SelectIdentity(_) | Self::SignOut - | Self::RequestAccountRemoval(_) + | Self::RequestIdentityRemoval(_) | Self::CancelGeneratedKeyStage => RuntimeCommandClass::MutateLocalState, Self::RefreshActiveProfile => RuntimeCommandClass::UseRelay, Self::Close => RuntimeCommandClass::Shutdown, @@ -96,7 +96,7 @@ impl RuntimeCommand { const fn resolves_revision_through_durable_replay(&self) -> bool { matches!( self, - Self::GenerateAccount { .. } | Self::ImportSecretKey { .. } + Self::GenerateIdentity { .. } | Self::ImportSecretKey { .. } ) } } @@ -113,7 +113,7 @@ struct RuntimeActor { published_session_generation: Arc<AtomicU64>, profile_tasks: BTreeMap<RequestId, PendingProfileTask>, changes: OrderedSnapshotChanges, - published_foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>, + published_foreground_session: Arc<Mutex<Option<ActiveSessionBinding>>>, generated_key_stage: GeneratedKeyStage, } @@ -137,7 +137,7 @@ pub struct RuntimeActorHandle { lifecycle: Arc<Mutex<LifecycleGate>>, next_request: Arc<AtomicU64>, session_generation: Arc<AtomicU64>, - foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>, + foreground_session: Arc<Mutex<Option<ActiveSessionBinding>>>, installation_identity: InstallationIdentity, runtime: Handle, actor_task: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>, @@ -316,7 +316,7 @@ impl RuntimeActorHandle { } #[must_use] - pub fn foreground_session(&self) -> Option<ForegroundSessionBinding> { + pub fn foreground_session(&self) -> Option<ActiveSessionBinding> { self.foreground_session .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -342,20 +342,20 @@ impl RuntimeActorHandle { Self::expect_snapshot(self.dispatch(RuntimeCommand::Snapshot, None).await?) } - /// Generates one account through the serialized actor boundary. + /// Generates one identity through the serialized actor boundary. /// /// # Errors /// - /// Returns a safe account, storage, keyring, timeout, or actor error. - pub async fn generate_account( + /// Returns a safe identity, storage, keyring, timeout, or actor error. + pub async fn generate_identity( &self, request: DurableRequestId, expected_revision: SnapshotRevision, timeout: Duration, - ) -> Result<GenerateAccountReceipt, SafeError> { + ) -> Result<GenerateIdentityReceipt, SafeError> { match self .dispatch_durable( - RuntimeCommand::GenerateAccount { + RuntimeCommand::GenerateIdentity { durable_request: request, expected_revision: expected_revision.value(), }, @@ -384,7 +384,7 @@ impl RuntimeActorHandle { } } - /// Acknowledges recovery and commits the staged account and credential once. + /// Acknowledges recovery and commits the staged identity and credential once. /// /// # Errors /// @@ -424,18 +424,18 @@ impl RuntimeActorHandle { } } - /// Imports one account through the serialized actor boundary. + /// Imports one identity through the serialized actor boundary. /// /// # Errors /// - /// Returns a safe account, storage, keyring, timeout, or actor error. + /// Returns a safe identity, storage, keyring, timeout, or actor error. pub async fn import_secret_key( &self, request: DurableRequestId, expected_revision: SnapshotRevision, input: SecretKeyInput, timeout: Duration, - ) -> Result<ImportAccountReceipt, SafeError> { + ) -> Result<ImportIdentityReceipt, SafeError> { match self .dispatch_durable( RuntimeCommand::ImportSecretKey { @@ -453,26 +453,26 @@ impl RuntimeActorHandle { } } - /// Selects one account through the serialized actor boundary. + /// Selects one identity through the serialized actor boundary. /// /// # Errors /// - /// Returns a safe account, storage, timeout, or actor error. - pub async fn select_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> { + /// Returns a safe identity, storage, timeout, or actor error. + pub async fn select_identity(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> { let value = self - .dispatch(RuntimeCommand::SelectAccount(public_key), None) + .dispatch(RuntimeCommand::SelectIdentity(public_key), None) .await?; Self::expect_snapshot(value) } - /// Activates one account through the serialized actor boundary. + /// Activates one identity through the serialized actor boundary. /// /// # Errors /// - /// Returns a safe account, credential, storage, timeout, or actor error. - pub async fn activate_account(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> { + /// Returns a safe identity, credential, storage, timeout, or actor error. + pub async fn activate_identity(&self, public_key: PublicKey) -> Result<AppSnapshot, SafeError> { let value = self - .dispatch(RuntimeCommand::ActivateAccount(public_key), None) + .dispatch(RuntimeCommand::ActivateIdentity(public_key), None) .await?; Self::expect_snapshot(value) } @@ -503,13 +503,13 @@ impl RuntimeActorHandle { /// /// # Errors /// - /// Returns a safe account, timeout, or actor error. - pub async fn request_account_removal( + /// Returns a safe identity, timeout, or actor error. + pub async fn request_identity_removal( &self, public_key: PublicKey, ) -> Result<RemovalConfirmationToken, SafeError> { match self - .dispatch(RuntimeCommand::RequestAccountRemoval(public_key), None) + .dispatch(RuntimeCommand::RequestIdentityRemoval(public_key), None) .await? { RuntimeCommandValue::RemovalRequest(token) => Ok(token), @@ -521,8 +521,8 @@ impl RuntimeActorHandle { /// /// # Errors /// - /// Returns a safe account, credential, storage, timeout, or actor error. - pub async fn confirm_account_removal( + /// Returns a safe identity, credential, storage, timeout, or actor error. + pub async fn confirm_identity_removal( &self, token: RemovalConfirmationToken, request: DurableRequestId, @@ -531,7 +531,7 @@ impl RuntimeActorHandle { ) -> Result<AppSnapshot, SafeError> { let value = self .dispatch_durable( - RuntimeCommand::ConfirmAccountRemoval { + RuntimeCommand::ConfirmIdentityRemoval { token, durable_request: request, }, @@ -710,7 +710,7 @@ impl RuntimeActorHandle { async fn import_secret_key_test( &self, input: SecretKeyInput, - ) -> Result<ImportAccountReceipt, SafeError> { + ) -> Result<ImportIdentityReceipt, SafeError> { let request_number = self.next_request.fetch_add(1, Ordering::Relaxed); self.import_secret_key( DurableRequestId::parse(format!("test:import:{request_number}"))?, @@ -737,12 +737,12 @@ impl RuntimeActorHandle { } #[cfg(test)] - async fn confirm_account_removal_test( + async fn confirm_identity_removal_test( &self, token: RemovalConfirmationToken, ) -> Result<AppSnapshot, SafeError> { let request_number = self.next_request.fetch_add(1, Ordering::Relaxed); - self.confirm_account_removal( + self.confirm_identity_removal( token, DurableRequestId::parse(format!("test:remove:{request_number}"))?, self.snapshot().revision(), @@ -756,7 +756,7 @@ impl RuntimeActorHandle { &self, input: SecretKeyInput, timeout: Duration, - ) -> Result<ImportAccountReceipt, SafeError> { + ) -> Result<ImportIdentityReceipt, SafeError> { let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed); let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?; let expected_revision = self.adapter.core().snapshot().revision(); @@ -836,9 +836,9 @@ impl RuntimeActor { } let changes_session = matches!( command, - RuntimeCommand::ActivateAccount(_) + RuntimeCommand::ActivateIdentity(_) | RuntimeCommand::SignOut - | RuntimeCommand::ConfirmAccountRemoval { .. } + | RuntimeCommand::ConfirmIdentityRemoval { .. } ); let begins_generated_recovery = matches!(&command, RuntimeCommand::BeginGeneratedKeyStage); let result = self.execute_command(context, command).await; @@ -909,13 +909,13 @@ impl RuntimeActor { RuntimeCommand::Snapshot => Ok(RuntimeCommandValue::Snapshot(Box::new( self.adapter.core().snapshot(), ))), - RuntimeCommand::GenerateAccount { + RuntimeCommand::GenerateIdentity { durable_request, expected_revision, } => { self.run_blocking(context.deadline(), move |adapter, secrets, clock| { adapter - .generate_account_durable( + .generate_identity_durable( &durable_request, expected_revision, secrets.as_ref(), @@ -978,19 +978,19 @@ impl RuntimeActor { }) .await } - RuntimeCommand::SelectAccount(public_key) => { + RuntimeCommand::SelectIdentity(public_key) => { self.run_blocking(context.deadline(), move |adapter, _, _| { adapter - .select_account(public_key) + .select_identity(public_key) .map(Box::new) .map(RuntimeCommandValue::Snapshot) }) .await } - RuntimeCommand::ActivateAccount(public_key) => { + RuntimeCommand::ActivateIdentity(public_key) => { self.run_blocking(context.deadline(), move |adapter, secrets, clock| { adapter - .activate_account(public_key, secrets.as_ref(), clock.as_ref()) + .activate_identity(public_key, secrets.as_ref(), clock.as_ref()) .map(Box::new) .map(RuntimeCommandValue::Snapshot) }) @@ -1001,17 +1001,17 @@ impl RuntimeActor { .sign_out() .map(Box::new) .map(RuntimeCommandValue::Snapshot), - RuntimeCommand::RequestAccountRemoval(public_key) => self + RuntimeCommand::RequestIdentityRemoval(public_key) => self .adapter - .request_account_removal(public_key, self.clock.as_ref()) + .request_identity_removal(public_key, self.clock.as_ref()) .map(RuntimeCommandValue::RemovalRequest), - RuntimeCommand::ConfirmAccountRemoval { + RuntimeCommand::ConfirmIdentityRemoval { token, durable_request, } => { self.run_blocking(context.deadline(), move |adapter, secrets, clock| { adapter - .confirm_account_removal_durable( + .confirm_identity_removal_durable( &durable_request, token, secrets.as_ref(), @@ -1105,7 +1105,7 @@ impl RuntimeActor { let correlation = TaskCorrelation::new( context.request_id(), plan.public_key(), - foreground.signer(), + foreground.signer_binding(), plan.expected_revision(), self.session_generation, ); @@ -1114,7 +1114,7 @@ impl RuntimeActor { let request_id = context.request_id(); let handle = self.runtime.spawn(async move { let result = client - .fetch_profile(correlation.account(), &relays, context.deadline()) + .fetch_profile(correlation.identity(), &relays, context.deadline()) .await; let _ = completion_sender .send(ProfileCompletion { request_id, result }) @@ -1183,12 +1183,12 @@ impl RuntimeActor { let correlated = task.correlation.session_generation() == self.session_generation && foreground.is_some_and(|binding| { binding.generation() == task.correlation.session_generation() - && binding.identity().public_key() == task.correlation.account() - && binding.signer() == task.correlation.binding() + && binding.identity().public_key() == task.correlation.identity() + && binding.signer_binding() == task.correlation.binding() }) - && current - .active_account() - .is_some_and(|active| active.account().public_key() == task.correlation.account()); + && current.active_identity().is_some_and(|active| { + active.identity().public_key() == task.correlation.identity() + }); let result = if correlated { let plan = task.plan.clone(); let completed = self @@ -1238,12 +1238,12 @@ impl RuntimeActor { .adapter .core() .snapshot() - .active_account() + .active_identity() .map(|active| { - let public_key = active.account().public_key(); - ForegroundSessionBinding::new( - AccountIdentity::derive(public_key)?, - LocalSignerBinding::new(public_key, BindingAvailability::Available), + let public_key = active.identity().public_key(); + ActiveSessionBinding::new( + NostrIdentityReference::derive(public_key)?, + LocalKeyringBinding::new(public_key, SignerAvailability::Available), self.session_generation, ) }); @@ -1313,7 +1313,7 @@ const fn request_space_exhausted() -> SafeError { const fn stale_profile_binding() -> SafeError { SafeError::new( SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The active account binding changed before profile refresh."), + SafeMessage::new("The active identity binding changed before profile refresh."), ) } @@ -1384,13 +1384,13 @@ mod tests { use std::time::{Duration, Instant}; use harvestcircle_application::{ - BoxFuture, Clock, DurableRequestId, FailureSecretStore, ForegroundSessionBinding, + ActiveSessionBinding, BoxFuture, Clock, DurableRequestId, FailureSecretStore, InMemorySecretStore, NostrClient, ProfileFetchResult, RelayConfiguration, RuntimeLifecycle, SecretStore, SecretStoreOperation, SessionGeneration, SessionState, SnapshotRevision, }; use harvestcircle_domain::{ - AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, - RelayDestinationPolicy, RelayUrl, SafeError, SafeErrorCode, SecretKeyInput, UnixTimestamp, + LocalKeyringBinding, NostrIdentityReference, PublicKey, RelayDestinationPolicy, RelayUrl, + SafeError, SafeErrorCode, SecretKeyInput, SignerAvailability, UnixTimestamp, }; use super::{ @@ -1636,7 +1636,7 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn account_mutations_run_serially_through_one_ready_actor() { + async fn identity_mutations_run_serially_through_one_ready_actor() { let (actor, secrets) = actor().await; assert_eq!(actor.lifecycle(), RuntimeLifecycle::Ready); @@ -1649,12 +1649,12 @@ mod tests { ) .await .expect("import"); - let public_key = imported.account().public_key(); - let activated = actor.activate_account(public_key).await.expect("activate"); + let public_key = imported.identity().public_key(); + let activated = actor.activate_identity(public_key).await.expect("activate"); assert_eq!(activated.session(), SessionState::Active); let foreground = actor.foreground_session().expect("foreground session"); assert_eq!(foreground.identity().public_key(), public_key); - assert_eq!(foreground.signer().account(), public_key); + assert_eq!(foreground.signer_binding().identity(), public_key); assert_eq!(foreground.generation(), actor.session_generation()); assert!(secrets.contains(public_key).expect("credential")); @@ -1662,14 +1662,14 @@ mod tests { assert_eq!(signed_out.session(), SessionState::SignedOut); assert!(actor.foreground_session().is_none()); let removal = actor - .request_account_removal(public_key) + .request_identity_removal(public_key) .await .expect("removal request"); let removed = actor - .confirm_account_removal_test(removal) + .confirm_identity_removal_test(removal) .await .expect("remove"); - assert!(removed.accounts().is_empty()); + assert!(removed.identities().is_empty()); assert!(!secrets.contains(public_key).expect("credential removed")); } @@ -1679,34 +1679,34 @@ mod tests { let unchanged = actor .refresh_active_profile() .await - .expect("refresh without an active account"); - assert!(unchanged.active_account().is_none()); + .expect("refresh without an active identity"); + assert!(unchanged.active_identity().is_none()); let generated = actor - .generate_account( + .generate_identity( DurableRequestId::parse("test:generate:public-surface").expect("request"), actor.snapshot().revision(), DEFAULT_COMMAND_TIMEOUT, ) .await - .expect("generate account"); + .expect("generate identity"); let selected = actor - .select_account(generated.account().public_key()) + .select_identity(generated.identity().public_key()) .await - .expect("select generated account"); + .expect("select generated identity"); assert_eq!( - selected.selected_account(), - Some(generated.account().public_key()) + selected.selected_identity(), + Some(generated.identity().public_key()) ); let missing = PublicKey::from_hex("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") .expect("public key"); - let error = match actor.request_account_removal(missing).await { - Ok(_) => panic!("unknown account removal must fail"), + let error = match actor.request_identity_removal(missing).await { + Ok(_) => panic!("unknown identity removal must fail"), Err(error) => error, }; - assert_eq!(error.code(), SafeErrorCode::AccountNotFound); + assert_eq!(error.code(), SafeErrorCode::IdentityNotFound); } #[tokio::test(flavor = "multi_thread")] @@ -1766,7 +1766,7 @@ mod tests { assert_eq!(actor.snapshot(), initial); assert!( !secrets - .contains(stage.view().account().public_key()) + .contains(stage.view().identity().public_key()) .expect("keyring") ); assert!(actor.sign_out().await.is_err()); @@ -1796,7 +1796,7 @@ mod tests { .begin_generated_key_stage() .await .expect("generated key stage"); - let public_key = handle.view().account().public_key(); + let public_key = handle.view().identity().public_key(); let recovery = handle.take_recovery_nsec().expect("recovery material"); assert_eq!(recovery.with_exposed_secret(str::len), 63); assert!(handle.take_recovery_nsec().is_err()); @@ -1807,8 +1807,8 @@ mod tests { .acknowledge_generated_key_stage_test(handle.id()) .await .expect("acknowledge"); - assert_eq!(committed.accounts().len(), 1); - assert_eq!(committed.selected_account(), Some(public_key)); + assert_eq!(committed.identities().len(), 1); + assert_eq!(committed.selected_identity(), Some(public_key)); assert!(secrets.contains(public_key).expect("credential committed")); assert!( actor @@ -1842,7 +1842,7 @@ mod tests { .expect_err("injected keyring failure"); assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); - assert!(actor.snapshot().accounts().is_empty()); + assert!(actor.snapshot().identities().is_empty()); actor .begin_generated_key_stage() .await @@ -1875,7 +1875,7 @@ mod tests { .await .expect("import"); actor - .activate_account(imported.account().public_key()) + .activate_identity(imported.identity().public_key()) .await .expect("activate"); assert_eq!(actor.session_generation().value(), 1); @@ -1893,7 +1893,7 @@ mod tests { assert_eq!(actor.session_generation().value(), 2); assert_eq!(signed_out.session(), SessionState::SignedOut); assert_eq!(cancelled.session(), SessionState::SignedOut); - assert!(cancelled.active_account().is_none()); + assert!(cancelled.active_identity().is_none()); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -1917,21 +1917,21 @@ mod tests { )) .await .expect("import"); - let public_key = imported.account().public_key(); - actor.activate_account(public_key).await.expect("activate"); + let public_key = imported.identity().public_key(); + actor.activate_identity(public_key).await.expect("activate"); let binding = actor.foreground_session().expect("foreground binding"); - let stale_binding = ForegroundSessionBinding::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), + let stale_binding = ActiveSessionBinding::new( + NostrIdentityReference::derive(public_key).expect("identity"), + LocalKeyringBinding::new(public_key, SignerAvailability::Available), SessionGeneration::from_value(binding.generation().value() + 1), ) .expect("stale binding fixture"); let other_public_key = PublicKey::from_hex("c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5") .expect("other public key"); - let other_binding = ForegroundSessionBinding::new( - AccountIdentity::derive(other_public_key).expect("other identity"), - LocalSignerBinding::new(other_public_key, BindingAvailability::Available), + let other_binding = ActiveSessionBinding::new( + NostrIdentityReference::derive(other_public_key).expect("other identity"), + LocalKeyringBinding::new(other_public_key, SignerAvailability::Available), binding.generation(), ) .expect("other binding fixture"); @@ -1946,7 +1946,7 @@ mod tests { .expect_err("stale generation must reject before relay work"); assert_eq!( error.message().as_str(), - "The active account binding changed before profile refresh." + "The active identity binding changed before profile refresh." ); *actor @@ -1956,10 +1956,10 @@ mod tests { let error = actor .refresh_active_profile() .await - .expect_err("different account binding must reject before relay work"); + .expect_err("different identity binding must reject before relay work"); assert_eq!( error.message().as_str(), - "The active account binding changed before profile refresh." + "The active identity binding changed before profile refresh." ); *actor @@ -2024,7 +2024,7 @@ mod tests { let unchanged = refresh .await .expect("refresh task") - .expect("different account binding returns current snapshot"); + .expect("different identity binding returns current snapshot"); assert_eq!(unchanged.revision(), actor.snapshot().revision()); *actor @@ -2089,10 +2089,15 @@ mod tests { .expect_err("accepted stale revision conflicts explicitly"); assert_eq!( second.message().as_str(), - "The account operation conflicts with the current application state." + "The identity operation conflicts with the current application state." ); assert_eq!( - actor.bootstrap().await.expect("snapshot").accounts().len(), + actor + .bootstrap() + .await + .expect("snapshot") + .identities() + .len(), 1 ); } @@ -2131,7 +2136,12 @@ mod tests { secrets.release(); first.await.expect("first task").expect("first command"); assert_eq!( - actor.bootstrap().await.expect("snapshot").accounts().len(), + actor + .bootstrap() + .await + .expect("snapshot") + .identities() + .len(), 1 ); } @@ -2218,7 +2228,7 @@ mod tests { .bootstrap() .await .expect("still open") - .accounts() + .identities() .len(), 1 ); @@ -2247,7 +2257,7 @@ mod tests { .await .expect("import"); actor - .activate_account(imported.account().public_key()) + .activate_identity(imported.identity().public_key()) .await .expect("activate"); let mut changes = actor diff --git a/core/crates/harvestcircle_runtime/tests/local_relay_e2e.rs b/core/crates/harvestcircle_runtime/tests/local_relay_e2e.rs @@ -37,7 +37,7 @@ async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() { .send_event_builder(EventBuilder::metadata( &Metadata::new() .name("farmer") - .display_name("Farm Account") + .display_name("Farm Identity") .about("Local food profile"), )) .await @@ -57,12 +57,12 @@ async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() { &secrets, &FixedClock, ) - .expect("import account"); - let public_key = imported.account().public_key(); + .expect("import identity"); + let public_key = imported.identity().public_key(); assert!(secrets.contains(public_key).expect("credential exists")); adapter - .activate_account(public_key, &secrets, &FixedClock) - .expect("activate account"); + .activate_identity(public_key, &secrets, &FixedClock) + .expect("activate identity"); let refreshed = adapter .core() @@ -76,12 +76,12 @@ async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() { .expect("refresh profile"); assert_eq!(refreshed.session(), SessionState::Active); - let active = refreshed.active_account().expect("active account"); + let active = refreshed.active_identity().expect("active identity"); assert_eq!(active.relay_state(), RelayConnectionState::Connected); assert_eq!(active.profile_state(), ProfileLoadState::Fresh); assert_eq!( active.profile().and_then(|profile| profile.display_name()), - Some("Farm Account") + Some("Farm Identity") ); let cached = adapter .database() @@ -90,7 +90,7 @@ async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() { .expect("cached profile"); assert_eq!( cached.candidate().metadata().preferred_name(), - Some("Farm Account") + Some("Farm Identity") ); let public_debug = format!("{refreshed:?}"); assert!(!public_debug.contains(SECRET_HEX)); diff --git a/core/crates/harvestcircle_runtime/tests/restart_isolation.rs b/core/crates/harvestcircle_runtime/tests/restart_isolation.rs @@ -1,7 +1,7 @@ use std::fs; use harvestcircle_application::{ - AccountNamespaceRepository, AccountPreferenceKey, Clock, InMemorySecretStore, + Clock, IdentityNamespaceRepository, IdentityPreferenceKey, InMemorySecretStore, RelayConfiguration, SessionState, }; use harvestcircle_domain::{SecretKeyInput, UnixTimestamp}; @@ -20,7 +20,7 @@ impl Clock for FixedClock { } #[test] -fn restart_restores_selection_and_keeps_account_namespaces_isolated() { +fn restart_restores_selection_and_keeps_identity_namespaces_isolated() { let directory = tempdir().expect("temporary directory"); let path = directory .path() @@ -40,8 +40,8 @@ fn restart_restores_selection_and_keeps_account_namespaces_isolated() { &secrets, &FixedClock, ) - .expect("account A") - .account() + .expect("identity A") + .identity() .public_key(); owner_b = adapter .import_secret_key( @@ -49,39 +49,39 @@ fn restart_restores_selection_and_keeps_account_namespaces_isolated() { &secrets, &FixedClock, ) - .expect("account B") - .account() + .expect("identity B") + .identity() .public_key(); adapter .database() - .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "account-a") + .set_value(owner_a, IdentityPreferenceKey::NamespaceProbe, "identity-a") .expect("namespace A"); adapter .database() - .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "account-b") + .set_value(owner_b, IdentityPreferenceKey::NamespaceProbe, "identity-b") .expect("namespace B"); - adapter.select_account(owner_b).expect("select B"); + adapter.select_identity(owner_b).expect("select B"); } let reopened = PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter"); let restored = reopened.bootstrap(&secrets, &FixedClock).expect("restore"); - assert_eq!(restored.accounts().len(), 2); - assert_eq!(restored.selected_account(), Some(owner_b)); + assert_eq!(restored.identities().len(), 2); + assert_eq!(restored.selected_identity(), Some(owner_b)); assert_eq!(restored.session(), SessionState::SignedOut); assert_eq!( reopened .database() - .get_value(owner_a, AccountPreferenceKey::NamespaceProbe) + .get_value(owner_a, IdentityPreferenceKey::NamespaceProbe) .expect("read A"), - Some("account-a".to_owned()) + Some("identity-a".to_owned()) ); assert_eq!( reopened .database() - .get_value(owner_b, AccountPreferenceKey::NamespaceProbe) + .get_value(owner_b, IdentityPreferenceKey::NamespaceProbe) .expect("read B"), - Some("account-b".to_owned()) + Some("identity-b".to_owned()) ); let database = fs::read(path).expect("database bytes"); diff --git a/core/crates/harvestcircle_storage/src/account_namespace.rs b/core/crates/harvestcircle_storage/src/account_namespace.rs @@ -1,189 +0,0 @@ -use harvestcircle_application::{AccountNamespaceRepository, AccountPreferenceKey}; -use harvestcircle_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage}; -use rusqlite::{OptionalExtension, params}; - -use crate::Database; - -const MAX_VALUE_CHARS: usize = 4_096; - -impl AccountNamespaceRepository for Database { - fn get_value( - &self, - owner: PublicKey, - key: AccountPreferenceKey, - ) -> Result<Option<String>, SafeError> { - self.connection() - .query_row( - "SELECT preference_value FROM account_preferences \ - WHERE owner_public_key = ?1 AND preference_key = ?2", - params![owner.to_hex(), encode_key(key)], - |row| row.get(0), - ) - .optional() - .map_err(|_| storage_error()) - } - - fn set_value( - &self, - owner: PublicKey, - key: AccountPreferenceKey, - value: &str, - ) -> Result<(), SafeError> { - if value.chars().count() > MAX_VALUE_CHARS || value.chars().any(char::is_control) { - return Err(invalid_preference()); - } - self.connection() - .execute( - "INSERT INTO account_preferences (owner_public_key, preference_key, preference_value) \ - VALUES (?1, ?2, ?3) ON CONFLICT(owner_public_key, preference_key) DO UPDATE SET \ - preference_value = excluded.preference_value", - params![owner.to_hex(), encode_key(key), value], - ) - .map(|_| ()) - .map_err(|_| storage_error()) - } - - fn clear_owner(&self, owner: PublicKey) -> Result<(), SafeError> { - self.connection() - .execute( - "DELETE FROM account_preferences WHERE owner_public_key = ?1", - [owner.to_hex()], - ) - .map(|_| ()) - .map_err(|_| storage_error()) - } -} - -const fn encode_key(key: AccountPreferenceKey) -> &'static str { - match key { - AccountPreferenceKey::NamespaceProbe => "namespace_probe", - } -} - -const fn storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The account preference is unavailable."), - ) -} - -const fn invalid_preference() -> SafeError { - SafeError::new( - SafeErrorCode::InvalidAccountMetadata, - SafeMessage::new("The account preference is invalid."), - ) -} - -#[cfg(test)] -mod tests { - use harvestcircle_application::{ - AccountNamespaceRepository, AccountPreferenceKey, AccountRepository, AppStateRepository, - }; - use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, UnixTimestamp, - }; - - use crate::Database; - - fn public_key(byte: u8) -> PublicKey { - let value = match byte { - 1 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", - 2 => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", - _ => "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - }; - PublicKey::from_hex(value).expect("valid public key") - } - - fn account(byte: u8) -> AccountSummary { - let public_key = public_key(byte); - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(i64::from(byte)).expect("time")), - None, - ) - .expect("account") - } - - #[test] - fn namespace_partitions_same_typed_key_by_owner_and_selection() { - let database = Database::in_memory().expect("database"); - let owner_a = public_key(1); - let owner_b = public_key(2); - database.insert_account(&account(1)).expect("account a"); - database.insert_account(&account(2)).expect("account b"); - database - .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "A") - .expect("set a"); - database - .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "B") - .expect("set b"); - - database - .save_selected_account(Some(owner_b)) - .expect("select b"); - let selected = database - .load_selected_account() - .expect("selection") - .expect("selected owner"); - assert_eq!( - database - .get_value(selected, AccountPreferenceKey::NamespaceProbe) - .expect("selected value"), - Some("B".to_owned()) - ); - assert_eq!( - database - .get_value(owner_a, AccountPreferenceKey::NamespaceProbe) - .expect("owner a value"), - Some("A".to_owned()) - ); - } - - #[test] - fn namespace_updates_and_cascades_with_owner_removal() { - let database = Database::in_memory().expect("database"); - let owner = public_key(3); - database.insert_account(&account(3)).expect("account"); - database - .set_value(owner, AccountPreferenceKey::NamespaceProbe, "before") - .expect("set"); - database - .set_value(owner, AccountPreferenceKey::NamespaceProbe, "after") - .expect("update"); - assert_eq!( - database - .get_value(owner, AccountPreferenceKey::NamespaceProbe) - .expect("value"), - Some("after".to_owned()) - ); - - database.remove_account(owner).expect("remove"); - assert_eq!( - database - .get_value(owner, AccountPreferenceKey::NamespaceProbe) - .expect("deleted value"), - None - ); - } - - #[test] - fn namespace_rejects_oversized_and_control_character_values() { - let database = Database::in_memory().expect("database"); - let owner = public_key(3); - database.insert_account(&account(3)).expect("account"); - let oversized = "a".repeat(super::MAX_VALUE_CHARS + 1); - assert!( - database - .set_value(owner, AccountPreferenceKey::NamespaceProbe, &oversized) - .is_err() - ); - assert!( - database - .set_value(owner, AccountPreferenceKey::NamespaceProbe, "line\nbreak") - .is_err() - ); - } -} diff --git a/core/crates/harvestcircle_storage/src/accounts.rs b/core/crates/harvestcircle_storage/src/accounts.rs @@ -1,520 +0,0 @@ -use harvestcircle_application::{AccountRepository, AppStateRepository}; -use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, - LocalSignerBinding, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, -}; -use rusqlite::{OptionalExtension, Row, params}; - -use crate::Database; - -impl AccountRepository for Database { - fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { - let connection = self.connection(); - let mut statement = connection - .prepare( - "SELECT identity.public_key, identity.npub, binding.binding_kind, \ - binding.availability, identity.label, identity.created_at, identity.last_used_at \ - FROM account_identities AS identity \ - JOIN local_signer_bindings AS binding \ - ON binding.account_public_key = identity.public_key \ - ORDER BY identity.created_at ASC, identity.public_key ASC", - ) - .map_err(|_| storage_error())?; - let rows = statement - .query_map([], decode_account) - .map_err(|_| storage_error())?; - rows.map(|row| row.map_err(|_| corrupt_storage_error())) - .collect() - } - - fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { - self.connection() - .query_row( - "SELECT identity.public_key, identity.npub, binding.binding_kind, \ - binding.availability, identity.label, identity.created_at, identity.last_used_at \ - FROM account_identities AS identity \ - JOIN local_signer_bindings AS binding \ - ON binding.account_public_key = identity.public_key \ - WHERE identity.public_key = ?1", - [public_key.to_hex()], - decode_account, - ) - .optional() - .map_err(|_| storage_error()) - } - - fn insert_account(&self, account: &AccountSummary) -> Result<(), SafeError> { - let encoded = EncodedAccount::from(account); - let mut connection = self.connection(); - let transaction = connection.transaction().map_err(|_| storage_error())?; - let result = transaction.execute( - "INSERT INTO account_identities (public_key, npub, label, created_at, last_used_at) \ - VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - encoded.public_key, - encoded.npub, - encoded.label, - encoded.created_at, - encoded.last_used_at - ], - ); - match result { - Ok(1) => {} - Err(error) if is_constraint_violation(&error) => return Err(account_exists()), - Ok(_) | Err(_) => return Err(storage_error()), - } - if transaction - .execute( - "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, \ - binding_kind, availability) VALUES (?1, ?1, ?2, ?3)", - params![ - encoded.public_key, - encoded.signer_kind, - encoded.key_availability - ], - ) - .map_err(|_| storage_error())? - != 1 - { - return Err(storage_error()); - } - transaction.commit().map_err(|_| storage_error()) - } - - fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { - let encoded = EncodedAccount::from(account); - let mut connection = self.connection(); - let transaction = connection.transaction().map_err(|_| storage_error())?; - let identity_rows = transaction - .execute( - "UPDATE account_identities SET npub = ?2, label = ?5, created_at = ?6, \ - last_used_at = ?7 WHERE public_key = ?1", - params![ - encoded.public_key, - encoded.npub, - encoded.signer_kind, - encoded.key_availability, - encoded.label, - encoded.created_at, - encoded.last_used_at, - ], - ) - .map_err(|_| storage_error())?; - if identity_rows == 0 { - return Err(account_not_found()); - } - if identity_rows != 1 { - return Err(storage_error()); - } - let binding_rows = transaction - .execute( - "UPDATE local_signer_bindings SET binding_kind = ?2, availability = ?3 \ - WHERE account_public_key = ?1 AND binding_public_key = ?1", - params![ - encoded.public_key, - encoded.signer_kind, - encoded.key_availability - ], - ) - .map_err(|_| storage_error())?; - if binding_rows != 1 { - return Err(corrupt_storage_error()); - } - transaction.commit().map_err(|_| storage_error()) - } - - fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { - match self.connection().execute( - "DELETE FROM account_identities WHERE public_key = ?1", - [public_key.to_hex()], - ) { - Ok(1) => Ok(()), - Ok(0) => Err(account_not_found()), - Ok(_) | Err(_) => Err(storage_error()), - } - } -} - -impl AppStateRepository for Database { - fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { - let value = self - .connection() - .query_row( - "SELECT selected_public_key FROM runtime_state WHERE singleton = 1", - [], - |row| row.get::<_, Option<String>>(0), - ) - .map_err(|_| corrupt_storage_error())?; - value - .map(|hex| PublicKey::from_hex(&hex).map_err(|_| corrupt_storage_error())) - .transpose() - } - - fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { - let mut connection = self.connection(); - let transaction = connection.transaction().map_err(|_| storage_error())?; - if let Some(public_key) = public_key { - let exists = transaction - .query_row( - "SELECT EXISTS(SELECT 1 FROM account_identities WHERE public_key = ?1)", - [public_key.to_hex()], - |row| row.get::<_, bool>(0), - ) - .map_err(|_| storage_error())?; - if !exists { - return Err(account_not_found()); - } - } - let rows = transaction - .execute( - "UPDATE runtime_state SET selected_public_key = ?1 WHERE singleton = 1", - [public_key.map(PublicKey::to_hex)], - ) - .map_err(|_| storage_error())?; - if rows != 1 { - return Err(corrupt_storage_error()); - } - transaction.commit().map_err(|_| storage_error()) - } -} - -struct EncodedAccount { - public_key: String, - npub: String, - signer_kind: &'static str, - key_availability: &'static str, - label: Option<String>, - created_at: i64, - last_used_at: Option<i64>, -} - -impl From<&AccountSummary> for EncodedAccount { - fn from(account: &AccountSummary) -> Self { - Self { - public_key: account.public_key().to_hex(), - npub: account.npub().as_str().to_owned(), - signer_kind: "local_secret", - key_availability: encode_key_availability(account.signer().availability()), - label: account.label().map(|label| label.as_str().to_owned()), - created_at: account.created_at().timestamp().as_seconds(), - last_used_at: account.last_used_at().map(UnixTimestamp::as_seconds), - } - } -} - -fn decode_account(row: &Row<'_>) -> rusqlite::Result<AccountSummary> { - let public_key = - PublicKey::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?; - let npub: String = row.get(1)?; - if row.get::<_, String>(2)?.as_str() != "local_secret" { - return Err(invalid_column(2)); - } - let key_availability = decode_key_availability(row.get::<_, String>(3)?.as_str())?; - let label = row - .get::<_, Option<String>>(4)? - .map(|value| AccountLabel::parse(&value).map_err(|_| invalid_column(4))) - .transpose()?; - let created_at = UnixTimestamp::from_seconds(row.get(5)?).ok_or_else(|| invalid_column(5))?; - let last_used_at = row - .get::<_, Option<i64>>(6)? - .map(|value| UnixTimestamp::from_seconds(value).ok_or_else(|| invalid_column(6))) - .transpose()?; - - AccountSummary::new( - AccountIdentity::verify(public_key, npub).map_err(|_| invalid_column(1))?, - LocalSignerBinding::new(public_key, key_availability), - label, - AccountCreatedAt::new(created_at), - last_used_at, - ) - .map_err(|_| invalid_column(0)) -} - -const fn encode_key_availability(value: BindingAvailability) -> &'static str { - match value { - BindingAvailability::Available => "available", - BindingAvailability::CredentialMissing => "credential_missing", - BindingAvailability::StoreUnavailable => "store_unavailable", - } -} - -fn decode_key_availability(value: &str) -> rusqlite::Result<BindingAvailability> { - match value { - "available" => Ok(BindingAvailability::Available), - "credential_missing" => Ok(BindingAvailability::CredentialMissing), - "store_unavailable" => Ok(BindingAvailability::StoreUnavailable), - _ => Err(invalid_column(3)), - } -} - -fn invalid_column(index: usize) -> rusqlite::Error { - rusqlite::Error::InvalidColumnType( - index, - "public account metadata".to_owned(), - rusqlite::types::Type::Text, - ) -} - -fn is_constraint_violation(error: &rusqlite::Error) -> bool { - matches!( - error, - rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::ConstraintViolation, - .. - }, - _ - ) - ) -} - -const fn storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageUnavailable, - SafeMessage::new("The application database is unavailable."), - ) -} - -const fn corrupt_storage_error() -> SafeError { - SafeError::new( - SafeErrorCode::StorageCorrupt, - SafeMessage::new("The application database could not be read."), - ) -} - -const fn account_exists() -> SafeError { - SafeError::new( - SafeErrorCode::AccountAlreadyExists, - SafeMessage::new("The Nostr account is already saved."), - ) -} - -const fn account_not_found() -> SafeError { - SafeError::new( - SafeErrorCode::AccountNotFound, - SafeMessage::new("The account was not found."), - ) -} - -#[cfg(test)] -mod tests { - use std::fs; - - use harvestcircle_application::{AccountRepository, AppStateRepository}; - use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountLabel, AccountSummary, BindingAvailability, - LocalSignerBinding, PublicKey, SafeErrorCode, UnixTimestamp, - }; - use tempfile::{TempDir, tempdir_in}; - - use crate::Database; - - fn tempdir() -> std::io::Result<TempDir> { - tempdir_in(std::env::temp_dir().canonicalize()?) - } - - fn public_key(key_byte: u8) -> PublicKey { - let value = match key_byte { - 1 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", - 2 => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", - _ => "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", - }; - PublicKey::from_hex(value).expect("valid public key") - } - - fn account(key_byte: u8, created_at: i64) -> AccountSummary { - let public_key = public_key(key_byte); - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), - Some(AccountLabel::parse("Farm account").expect("valid label")), - AccountCreatedAt::new( - UnixTimestamp::from_seconds(created_at).expect("valid timestamp"), - ), - None, - ) - .expect("account") - } - - #[test] - fn accounts_insert_list_update_and_reject_duplicates() { - let database = Database::in_memory().expect("database"); - let first = account(1, 20); - let second = account(2, 10); - - database.insert_account(&first).expect("insert first"); - database.insert_account(&second).expect("insert second"); - let duplicate = database.insert_account(&first).expect_err("duplicate"); - - assert_eq!(duplicate.code(), SafeErrorCode::AccountAlreadyExists); - assert_eq!( - database.list_accounts().expect("list"), - vec![second, first.clone()] - ); - assert_eq!( - database.find_account(first.public_key()).expect("find"), - Some(first) - ); - } - - #[test] - fn accounts_and_selection_survive_restart_without_secret_text() { - let directory = tempdir().expect("temporary directory"); - let path = directory.path().join("harvestcircle.sqlite3"); - let account = account(3, 30); - - { - let database = Database::open(&path).expect("database"); - database.insert_account(&account).expect("insert"); - database - .save_selected_account(Some(account.public_key())) - .expect("select"); - } - let reopened = Database::open(&path).expect("reopen"); - - assert_eq!( - reopened.list_accounts().expect("list"), - vec![account.clone()] - ); - assert_eq!( - reopened.load_selected_account().expect("selection"), - Some(account.public_key()) - ); - let bytes = fs::read(path).expect("database bytes"); - assert!(!String::from_utf8_lossy(&bytes).contains("nsec1known-test-secret")); - } - - #[test] - fn selection_requires_an_existing_account_and_clears_on_delete() { - let database = Database::in_memory().expect("database"); - let account = account(4, 40); - - let missing = database - .save_selected_account(Some(account.public_key())) - .expect_err("missing account"); - assert_eq!(missing.code(), SafeErrorCode::AccountNotFound); - - database.insert_account(&account).expect("insert"); - database - .save_selected_account(Some(account.public_key())) - .expect("select"); - database - .remove_account(account.public_key()) - .expect("remove"); - - assert_eq!(database.load_selected_account().expect("selection"), None); - } - - #[test] - fn account_mutations_reject_missing_and_corrupt_rows() { - let database = Database::in_memory().expect("database"); - let missing = account(3, 30); - assert_eq!( - database - .update_account(&missing) - .expect_err("missing update") - .code(), - SafeErrorCode::AccountNotFound - ); - assert_eq!( - database - .remove_account(missing.public_key()) - .expect_err("missing removal") - .code(), - SafeErrorCode::AccountNotFound - ); - assert_eq!( - database.find_account(missing.public_key()).expect("find"), - None - ); - - database.insert_account(&missing).expect("insert"); - database.update_account(&missing).expect("update"); - database - .connection() - .execute( - "DELETE FROM local_signer_bindings WHERE account_public_key = ?1", - [missing.public_key().to_hex()], - ) - .expect("delete binding"); - assert_eq!( - database - .update_account(&missing) - .expect_err("missing binding must fail") - .code(), - SafeErrorCode::StorageCorrupt - ); - database - .connection() - .execute( - "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')", - [missing.public_key().to_hex()], - ) - .expect("restore binding"); - database - .connection() - .pragma_update(None, "ignore_check_constraints", "ON") - .expect("disable check constraints for corruption fixture"); - database - .connection() - .execute( - "UPDATE local_signer_bindings SET binding_kind = 'remote' WHERE account_public_key = ?1", - [missing.public_key().to_hex()], - ) - .expect("corrupt binding kind"); - assert_eq!( - database - .list_accounts() - .expect_err("corrupt binding must fail") - .code(), - SafeErrorCode::StorageCorrupt - ); - - let database = Database::in_memory().expect("database"); - database.insert_account(&missing).expect("insert"); - database - .connection() - .pragma_update(None, "ignore_check_constraints", "ON") - .expect("disable check constraints for corruption fixture"); - database - .connection() - .execute( - "UPDATE local_signer_bindings SET availability = 'invalid' WHERE account_public_key = ?1", - [missing.public_key().to_hex()], - ) - .expect("corrupt availability"); - assert_eq!( - database - .find_account(missing.public_key()) - .expect_err("corrupt availability must fail") - .code(), - SafeErrorCode::StorageUnavailable - ); - - let database = Database::in_memory().expect("database"); - database - .connection() - .execute("DELETE FROM runtime_state", []) - .expect("delete runtime singleton"); - assert_eq!( - database - .save_selected_account(None) - .expect_err("missing runtime singleton must fail") - .code(), - SafeErrorCode::StorageCorrupt - ); - - let read_only = Database::in_memory().expect("read-only database"); - read_only - .connection() - .pragma_update(None, "query_only", "ON") - .expect("enable query-only mode"); - assert_eq!( - read_only - .insert_account(&missing) - .expect_err("non-constraint insertion failure must fail closed") - .code(), - SafeErrorCode::StorageUnavailable - ); - } -} diff --git a/core/crates/harvestcircle_storage/src/compatibility.rs b/core/crates/harvestcircle_storage/src/compatibility.rs @@ -1,8 +1,8 @@ use std::path::Path; use harvestcircle_domain::{ - AccountIdentity, PersistedPublicKeyClassification, SafeError, SafeErrorCode, SafeMessage, - classify_persisted_public_key, + NostrIdentityReference, PersistedPublicKeyClassification, SafeError, SafeErrorCode, + SafeMessage, classify_persisted_public_key, }; use rusqlite::{Connection, OpenFlags}; use sha2::{Digest, Sha256}; @@ -259,7 +259,7 @@ fn scan_display_identities( else { continue; }; - if AccountIdentity::verify(public_key, npub.clone()).is_err() { + if NostrIdentityReference::verify(public_key, npub.clone()).is_err() { issues.push(issue( table, npub_column, @@ -339,7 +339,7 @@ mod tests { scan_display_identities, scan_public_key_column, }; use crate::Database; - use harvestcircle_domain::{AccountIdentity, PublicKey, SafeErrorCode}; + use harvestcircle_domain::{NostrIdentityReference, PublicKey, SafeErrorCode}; fn tempdir() -> std::io::Result<TempDir> { tempdir_in(std::env::temp_dir().canonicalize()?) @@ -418,12 +418,12 @@ mod tests { fn identity_scans_classify_all_persisted_key_and_display_failures() { let connection = Connection::open_in_memory().expect("database"); connection - .execute("CREATE TABLE identities (public_key TEXT, npub TEXT)", []) + .execute("CREATE TABLE accounts (public_key TEXT, npub TEXT)", []) .expect("identity table"); let canonical = PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df") .expect("canonical key"); - let npub = AccountIdentity::derive(canonical) + let npub = NostrIdentityReference::derive(canonical) .expect("identity") .npub() .as_str() @@ -438,19 +438,19 @@ mod tests { for (public_key, npub) in values { connection .execute( - "INSERT INTO identities (public_key, npub) VALUES (?1, ?2)", + "INSERT INTO accounts (public_key, npub) VALUES (?1, ?2)", params![public_key, npub], ) .expect("identity row"); } - assert!(column_exists(&connection, "identities", "public_key").expect("column")); + assert!(column_exists(&connection, "accounts", "public_key").expect("column")); assert!(!column_exists(&connection, "missing", "public_key").expect("missing table")); - assert!(!column_exists(&connection, "identities", "missing").expect("missing column")); + assert!(!column_exists(&connection, "accounts", "missing").expect("missing column")); let mut issues = Vec::new(); - scan_public_key_column(&connection, "identities", "public_key", &mut issues) + scan_public_key_column(&connection, "accounts", "public_key", &mut issues) .expect("scan public keys"); - scan_display_identities(&connection, "identities", "public_key", "npub", &mut issues) + scan_display_identities(&connection, "accounts", "public_key", "npub", &mut issues) .expect("scan display identities"); scan_display_identities(&connection, "missing", "public_key", "npub", &mut issues) .expect("skip missing table"); @@ -469,7 +469,7 @@ mod tests { assert!(issues.iter().any(|issue| issue.kind() == kind)); } for issue in &issues { - assert_eq!(issue.table(), "identities"); + assert_eq!(issue.table(), "accounts"); assert!(matches!(issue.column(), "public_key" | "npub")); assert!(issue.row_id() > 0); assert_ne!(issue.fingerprint(), &[0_u8; 32]); diff --git a/core/crates/harvestcircle_storage/src/db.rs b/core/crates/harvestcircle_storage/src/db.rs @@ -379,7 +379,7 @@ mod tests { use tempfile::{TempDir, tempdir_in}; - use harvestcircle_application::{AccountRepository, AppStateRepository}; + use harvestcircle_application::{AppStateRepository, IdentityRepository}; use harvestcircle_domain::{PublicKey, SafeErrorCode}; use refinery::Target; use rusqlite::Connection; @@ -454,7 +454,7 @@ mod tests { } #[test] - fn normalized_schema_is_strict_and_enforces_same_account_bindings() { + fn normalized_schema_is_strict_and_enforces_same_identity_bindings() { let database = Database::in_memory().expect("open memory database"); let connection = database.connection(); let strict_tables: i64 = connection @@ -502,7 +502,7 @@ mod tests { "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"], ) - .expect("legacy account"); + .expect("legacy identity"); connection .execute( "UPDATE app_state SET selected_pubkey = ?1 WHERE singleton = 1", @@ -519,9 +519,9 @@ mod tests { let database = Database::open(&path).expect("migrated database"); assert_eq!(database.schema_version().expect("version"), 10); - assert_eq!(database.list_accounts().expect("accounts").len(), 1); + assert_eq!(database.list_identities().expect("identities").len(), 1); assert_eq!( - database.load_selected_account().expect("selection"), + database.load_selected_identity().expect("selection"), Some(PublicKey::from_bytes([7; 32]).expect("valid public key")) ); let connection = database.connection(); @@ -576,7 +576,7 @@ mod tests { "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", ["07".repeat(32), "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg".to_owned()], ) - .expect("mismatched legacy account"); + .expect("mismatched legacy identity"); } assert!(Database::open(&path).is_err()); @@ -588,10 +588,10 @@ mod tests { |row| row.get(0), ) .expect("legacy version"); - let accounts: i64 = connection + let identities: i64 = connection .query_row("SELECT COUNT(*) FROM accounts", [], |row| row.get(0)) - .expect("legacy accounts"); - assert_eq!((version, accounts), (5, 1)); + .expect("legacy identities"); + assert_eq!((version, identities), (5, 1)); } #[test] @@ -728,7 +728,7 @@ mod tests { "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"], ) - .expect("legacy account"); + .expect("legacy identity"); connection .execute( "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, refreshed_at, refresh_status) VALUES (?1, 'invalid', 11, 12, 'success')", diff --git a/core/crates/harvestcircle_storage/src/identities.rs b/core/crates/harvestcircle_storage/src/identities.rs @@ -0,0 +1,520 @@ +use harvestcircle_application::{AppStateRepository, IdentityRepository}; +use harvestcircle_domain::{ + IdentityCreatedAt, IdentityLabel, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, + PublicKey, SafeError, SafeErrorCode, SafeMessage, SignerAvailability, UnixTimestamp, +}; +use rusqlite::{OptionalExtension, Row, params}; + +use crate::Database; + +impl IdentityRepository for Database { + fn list_identities(&self) -> Result<Vec<NostrIdentity>, SafeError> { + let connection = self.connection(); + let mut statement = connection + .prepare( + "SELECT identity.public_key, identity.npub, binding.binding_kind, \ + binding.availability, identity.label, identity.created_at, identity.last_used_at \ + FROM account_identities AS identity \ + JOIN local_signer_bindings AS binding \ + ON binding.account_public_key = identity.public_key \ + ORDER BY identity.created_at ASC, identity.public_key ASC", + ) + .map_err(|_| storage_error())?; + let rows = statement + .query_map([], decode_identity) + .map_err(|_| storage_error())?; + rows.map(|row| row.map_err(|_| corrupt_storage_error())) + .collect() + } + + fn find_identity(&self, public_key: PublicKey) -> Result<Option<NostrIdentity>, SafeError> { + self.connection() + .query_row( + "SELECT identity.public_key, identity.npub, binding.binding_kind, \ + binding.availability, identity.label, identity.created_at, identity.last_used_at \ + FROM account_identities AS identity \ + JOIN local_signer_bindings AS binding \ + ON binding.account_public_key = identity.public_key \ + WHERE identity.public_key = ?1", + [public_key.to_hex()], + decode_identity, + ) + .optional() + .map_err(|_| storage_error()) + } + + fn insert_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError> { + let encoded = EncodedIdentity::from(identity); + let mut connection = self.connection(); + let transaction = connection.transaction().map_err(|_| storage_error())?; + let result = transaction.execute( + "INSERT INTO account_identities (public_key, npub, label, created_at, last_used_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + encoded.public_key, + encoded.npub, + encoded.label, + encoded.created_at, + encoded.last_used_at + ], + ); + match result { + Ok(1) => {} + Err(error) if is_constraint_violation(&error) => return Err(identity_exists()), + Ok(_) | Err(_) => return Err(storage_error()), + } + if transaction + .execute( + "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, \ + binding_kind, availability) VALUES (?1, ?1, ?2, ?3)", + params![ + encoded.public_key, + encoded.signer_kind, + encoded.key_availability + ], + ) + .map_err(|_| storage_error())? + != 1 + { + return Err(storage_error()); + } + transaction.commit().map_err(|_| storage_error()) + } + + fn update_identity(&self, identity: &NostrIdentity) -> Result<(), SafeError> { + let encoded = EncodedIdentity::from(identity); + let mut connection = self.connection(); + let transaction = connection.transaction().map_err(|_| storage_error())?; + let identity_rows = transaction + .execute( + "UPDATE account_identities SET npub = ?2, label = ?5, created_at = ?6, \ + last_used_at = ?7 WHERE public_key = ?1", + params![ + encoded.public_key, + encoded.npub, + encoded.signer_kind, + encoded.key_availability, + encoded.label, + encoded.created_at, + encoded.last_used_at, + ], + ) + .map_err(|_| storage_error())?; + if identity_rows == 0 { + return Err(identity_not_found()); + } + if identity_rows != 1 { + return Err(storage_error()); + } + let binding_rows = transaction + .execute( + "UPDATE local_signer_bindings SET binding_kind = ?2, availability = ?3 \ + WHERE account_public_key = ?1 AND binding_public_key = ?1", + params![ + encoded.public_key, + encoded.signer_kind, + encoded.key_availability + ], + ) + .map_err(|_| storage_error())?; + if binding_rows != 1 { + return Err(corrupt_storage_error()); + } + transaction.commit().map_err(|_| storage_error()) + } + + fn remove_identity(&self, public_key: PublicKey) -> Result<(), SafeError> { + match self.connection().execute( + "DELETE FROM account_identities WHERE public_key = ?1", + [public_key.to_hex()], + ) { + Ok(1) => Ok(()), + Ok(0) => Err(identity_not_found()), + Ok(_) | Err(_) => Err(storage_error()), + } + } +} + +impl AppStateRepository for Database { + fn load_selected_identity(&self) -> Result<Option<PublicKey>, SafeError> { + let value = self + .connection() + .query_row( + "SELECT selected_public_key FROM runtime_state WHERE singleton = 1", + [], + |row| row.get::<_, Option<String>>(0), + ) + .map_err(|_| corrupt_storage_error())?; + value + .map(|hex| PublicKey::from_hex(&hex).map_err(|_| corrupt_storage_error())) + .transpose() + } + + fn save_selected_identity(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { + let mut connection = self.connection(); + let transaction = connection.transaction().map_err(|_| storage_error())?; + if let Some(public_key) = public_key { + let exists = transaction + .query_row( + "SELECT EXISTS(SELECT 1 FROM account_identities WHERE public_key = ?1)", + [public_key.to_hex()], + |row| row.get::<_, bool>(0), + ) + .map_err(|_| storage_error())?; + if !exists { + return Err(identity_not_found()); + } + } + let rows = transaction + .execute( + "UPDATE runtime_state SET selected_public_key = ?1 WHERE singleton = 1", + [public_key.map(PublicKey::to_hex)], + ) + .map_err(|_| storage_error())?; + if rows != 1 { + return Err(corrupt_storage_error()); + } + transaction.commit().map_err(|_| storage_error()) + } +} + +struct EncodedIdentity { + public_key: String, + npub: String, + signer_kind: &'static str, + key_availability: &'static str, + label: Option<String>, + created_at: i64, + last_used_at: Option<i64>, +} + +impl From<&NostrIdentity> for EncodedIdentity { + fn from(identity: &NostrIdentity) -> Self { + Self { + public_key: identity.public_key().to_hex(), + npub: identity.npub().as_str().to_owned(), + signer_kind: "local_secret", + key_availability: encode_key_availability(identity.signer_binding().availability()), + label: identity.label().map(|label| label.as_str().to_owned()), + created_at: identity.created_at().timestamp().as_seconds(), + last_used_at: identity.last_used_at().map(UnixTimestamp::as_seconds), + } + } +} + +fn decode_identity(row: &Row<'_>) -> rusqlite::Result<NostrIdentity> { + let public_key = + PublicKey::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?; + let npub: String = row.get(1)?; + if row.get::<_, String>(2)?.as_str() != "local_secret" { + return Err(invalid_column(2)); + } + let key_availability = decode_key_availability(row.get::<_, String>(3)?.as_str())?; + let label = row + .get::<_, Option<String>>(4)? + .map(|value| IdentityLabel::parse(&value).map_err(|_| invalid_column(4))) + .transpose()?; + let created_at = UnixTimestamp::from_seconds(row.get(5)?).ok_or_else(|| invalid_column(5))?; + let last_used_at = row + .get::<_, Option<i64>>(6)? + .map(|value| UnixTimestamp::from_seconds(value).ok_or_else(|| invalid_column(6))) + .transpose()?; + + NostrIdentity::new( + NostrIdentityReference::verify(public_key, npub).map_err(|_| invalid_column(1))?, + LocalKeyringBinding::new(public_key, key_availability), + label, + IdentityCreatedAt::new(created_at), + last_used_at, + ) + .map_err(|_| invalid_column(0)) +} + +const fn encode_key_availability(value: SignerAvailability) -> &'static str { + match value { + SignerAvailability::Available => "available", + SignerAvailability::CredentialMissing => "credential_missing", + SignerAvailability::StoreUnavailable => "store_unavailable", + } +} + +fn decode_key_availability(value: &str) -> rusqlite::Result<SignerAvailability> { + match value { + "available" => Ok(SignerAvailability::Available), + "credential_missing" => Ok(SignerAvailability::CredentialMissing), + "store_unavailable" => Ok(SignerAvailability::StoreUnavailable), + _ => Err(invalid_column(3)), + } +} + +fn invalid_column(index: usize) -> rusqlite::Error { + rusqlite::Error::InvalidColumnType( + index, + "public identity metadata".to_owned(), + rusqlite::types::Type::Text, + ) +} + +fn is_constraint_violation(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: rusqlite::ErrorCode::ConstraintViolation, + .. + }, + _ + ) + ) +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The application database is unavailable."), + ) +} + +const fn corrupt_storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageCorrupt, + SafeMessage::new("The application database could not be read."), + ) +} + +const fn identity_exists() -> SafeError { + SafeError::new( + SafeErrorCode::IdentityAlreadyExists, + SafeMessage::new("The Nostr identity is already saved."), + ) +} + +const fn identity_not_found() -> SafeError { + SafeError::new( + SafeErrorCode::IdentityNotFound, + SafeMessage::new("The identity was not found."), + ) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use harvestcircle_application::{AppStateRepository, IdentityRepository}; + use harvestcircle_domain::{ + IdentityCreatedAt, IdentityLabel, LocalKeyringBinding, NostrIdentity, + NostrIdentityReference, PublicKey, SafeErrorCode, SignerAvailability, UnixTimestamp, + }; + use tempfile::{TempDir, tempdir_in}; + + use crate::Database; + + fn tempdir() -> std::io::Result<TempDir> { + tempdir_in(std::env::temp_dir().canonicalize()?) + } + + fn public_key(key_byte: u8) -> PublicKey { + let value = match key_byte { + 1 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + 2 => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + _ => "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + }; + PublicKey::from_hex(value).expect("valid public key") + } + + fn identity(key_byte: u8, created_at: i64) -> NostrIdentity { + let public_key = public_key(key_byte); + NostrIdentity::new( + NostrIdentityReference::derive(public_key).expect("identity"), + LocalKeyringBinding::new(public_key, SignerAvailability::Available), + Some(IdentityLabel::parse("Farm identity").expect("valid label")), + IdentityCreatedAt::new( + UnixTimestamp::from_seconds(created_at).expect("valid timestamp"), + ), + None, + ) + .expect("identity") + } + + #[test] + fn identities_insert_list_update_and_reject_duplicates() { + let database = Database::in_memory().expect("database"); + let first = identity(1, 20); + let second = identity(2, 10); + + database.insert_identity(&first).expect("insert first"); + database.insert_identity(&second).expect("insert second"); + let duplicate = database.insert_identity(&first).expect_err("duplicate"); + + assert_eq!(duplicate.code(), SafeErrorCode::IdentityAlreadyExists); + assert_eq!( + database.list_identities().expect("list"), + vec![second, first.clone()] + ); + assert_eq!( + database.find_identity(first.public_key()).expect("find"), + Some(first) + ); + } + + #[test] + fn identities_and_selection_survive_restart_without_secret_text() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("harvestcircle.sqlite3"); + let identity = identity(3, 30); + + { + let database = Database::open(&path).expect("database"); + database.insert_identity(&identity).expect("insert"); + database + .save_selected_identity(Some(identity.public_key())) + .expect("select"); + } + let reopened = Database::open(&path).expect("reopen"); + + assert_eq!( + reopened.list_identities().expect("list"), + vec![identity.clone()] + ); + assert_eq!( + reopened.load_selected_identity().expect("selection"), + Some(identity.public_key()) + ); + let bytes = fs::read(path).expect("database bytes"); + assert!(!String::from_utf8_lossy(&bytes).contains("nsec1known-test-secret")); + } + + #[test] + fn selection_requires_an_existing_identity_and_clears_on_delete() { + let database = Database::in_memory().expect("database"); + let identity = identity(4, 40); + + let missing = database + .save_selected_identity(Some(identity.public_key())) + .expect_err("missing identity"); + assert_eq!(missing.code(), SafeErrorCode::IdentityNotFound); + + database.insert_identity(&identity).expect("insert"); + database + .save_selected_identity(Some(identity.public_key())) + .expect("select"); + database + .remove_identity(identity.public_key()) + .expect("remove"); + + assert_eq!(database.load_selected_identity().expect("selection"), None); + } + + #[test] + fn identity_mutations_reject_missing_and_corrupt_rows() { + let database = Database::in_memory().expect("database"); + let missing = identity(3, 30); + assert_eq!( + database + .update_identity(&missing) + .expect_err("missing update") + .code(), + SafeErrorCode::IdentityNotFound + ); + assert_eq!( + database + .remove_identity(missing.public_key()) + .expect_err("missing removal") + .code(), + SafeErrorCode::IdentityNotFound + ); + assert_eq!( + database.find_identity(missing.public_key()).expect("find"), + None + ); + + database.insert_identity(&missing).expect("insert"); + database.update_identity(&missing).expect("update"); + database + .connection() + .execute( + "DELETE FROM local_signer_bindings WHERE account_public_key = ?1", + [missing.public_key().to_hex()], + ) + .expect("delete binding"); + assert_eq!( + database + .update_identity(&missing) + .expect_err("missing binding must fail") + .code(), + SafeErrorCode::StorageCorrupt + ); + database + .connection() + .execute( + "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')", + [missing.public_key().to_hex()], + ) + .expect("restore binding"); + database + .connection() + .pragma_update(None, "ignore_check_constraints", "ON") + .expect("disable check constraints for corruption fixture"); + database + .connection() + .execute( + "UPDATE local_signer_bindings SET binding_kind = 'remote' WHERE account_public_key = ?1", + [missing.public_key().to_hex()], + ) + .expect("corrupt binding kind"); + assert_eq!( + database + .list_identities() + .expect_err("corrupt binding must fail") + .code(), + SafeErrorCode::StorageCorrupt + ); + + let database = Database::in_memory().expect("database"); + database.insert_identity(&missing).expect("insert"); + database + .connection() + .pragma_update(None, "ignore_check_constraints", "ON") + .expect("disable check constraints for corruption fixture"); + database + .connection() + .execute( + "UPDATE local_signer_bindings SET availability = 'invalid' WHERE account_public_key = ?1", + [missing.public_key().to_hex()], + ) + .expect("corrupt availability"); + assert_eq!( + database + .find_identity(missing.public_key()) + .expect_err("corrupt availability must fail") + .code(), + SafeErrorCode::StorageUnavailable + ); + + let database = Database::in_memory().expect("database"); + database + .connection() + .execute("DELETE FROM runtime_state", []) + .expect("delete runtime singleton"); + assert_eq!( + database + .save_selected_identity(None) + .expect_err("missing runtime singleton must fail") + .code(), + SafeErrorCode::StorageCorrupt + ); + + let read_only = Database::in_memory().expect("read-only database"); + read_only + .connection() + .pragma_update(None, "query_only", "ON") + .expect("enable query-only mode"); + assert_eq!( + read_only + .insert_identity(&missing) + .expect_err("non-constraint insertion failure must fail closed") + .code(), + SafeErrorCode::StorageUnavailable + ); + } +} diff --git a/core/crates/harvestcircle_storage/src/identity_namespace.rs b/core/crates/harvestcircle_storage/src/identity_namespace.rs @@ -0,0 +1,189 @@ +use harvestcircle_application::{IdentityNamespaceRepository, IdentityPreferenceKey}; +use harvestcircle_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage}; +use rusqlite::{OptionalExtension, params}; + +use crate::Database; + +const MAX_VALUE_CHARS: usize = 4_096; + +impl IdentityNamespaceRepository for Database { + fn get_value( + &self, + owner: PublicKey, + key: IdentityPreferenceKey, + ) -> Result<Option<String>, SafeError> { + self.connection() + .query_row( + "SELECT preference_value FROM account_preferences \ + WHERE owner_public_key = ?1 AND preference_key = ?2", + params![owner.to_hex(), encode_key(key)], + |row| row.get(0), + ) + .optional() + .map_err(|_| storage_error()) + } + + fn set_value( + &self, + owner: PublicKey, + key: IdentityPreferenceKey, + value: &str, + ) -> Result<(), SafeError> { + if value.chars().count() > MAX_VALUE_CHARS || value.chars().any(char::is_control) { + return Err(invalid_preference()); + } + self.connection() + .execute( + "INSERT INTO account_preferences (owner_public_key, preference_key, preference_value) \ + VALUES (?1, ?2, ?3) ON CONFLICT(owner_public_key, preference_key) DO UPDATE SET \ + preference_value = excluded.preference_value", + params![owner.to_hex(), encode_key(key), value], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } + + fn clear_owner(&self, owner: PublicKey) -> Result<(), SafeError> { + self.connection() + .execute( + "DELETE FROM account_preferences WHERE owner_public_key = ?1", + [owner.to_hex()], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } +} + +const fn encode_key(key: IdentityPreferenceKey) -> &'static str { + match key { + IdentityPreferenceKey::NamespaceProbe => "namespace_probe", + } +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The identity preference is unavailable."), + ) +} + +const fn invalid_preference() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidIdentityMetadata, + SafeMessage::new("The identity preference is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use harvestcircle_application::{ + AppStateRepository, IdentityNamespaceRepository, IdentityPreferenceKey, IdentityRepository, + }; + use harvestcircle_domain::{ + IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, PublicKey, + SignerAvailability, UnixTimestamp, + }; + + use crate::Database; + + fn public_key(byte: u8) -> PublicKey { + let value = match byte { + 1 => "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + 2 => "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + _ => "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + }; + PublicKey::from_hex(value).expect("valid public key") + } + + fn identity(byte: u8) -> NostrIdentity { + let public_key = public_key(byte); + NostrIdentity::new( + NostrIdentityReference::derive(public_key).expect("identity"), + LocalKeyringBinding::new(public_key, SignerAvailability::Available), + None, + IdentityCreatedAt::new(UnixTimestamp::from_seconds(i64::from(byte)).expect("time")), + None, + ) + .expect("identity") + } + + #[test] + fn namespace_partitions_same_typed_key_by_owner_and_selection() { + let database = Database::in_memory().expect("database"); + let owner_a = public_key(1); + let owner_b = public_key(2); + database.insert_identity(&identity(1)).expect("identity a"); + database.insert_identity(&identity(2)).expect("identity b"); + database + .set_value(owner_a, IdentityPreferenceKey::NamespaceProbe, "A") + .expect("set a"); + database + .set_value(owner_b, IdentityPreferenceKey::NamespaceProbe, "B") + .expect("set b"); + + database + .save_selected_identity(Some(owner_b)) + .expect("select b"); + let selected = database + .load_selected_identity() + .expect("selection") + .expect("selected owner"); + assert_eq!( + database + .get_value(selected, IdentityPreferenceKey::NamespaceProbe) + .expect("selected value"), + Some("B".to_owned()) + ); + assert_eq!( + database + .get_value(owner_a, IdentityPreferenceKey::NamespaceProbe) + .expect("owner a value"), + Some("A".to_owned()) + ); + } + + #[test] + fn namespace_updates_and_cascades_with_owner_removal() { + let database = Database::in_memory().expect("database"); + let owner = public_key(3); + database.insert_identity(&identity(3)).expect("identity"); + database + .set_value(owner, IdentityPreferenceKey::NamespaceProbe, "before") + .expect("set"); + database + .set_value(owner, IdentityPreferenceKey::NamespaceProbe, "after") + .expect("update"); + assert_eq!( + database + .get_value(owner, IdentityPreferenceKey::NamespaceProbe) + .expect("value"), + Some("after".to_owned()) + ); + + database.remove_identity(owner).expect("remove"); + assert_eq!( + database + .get_value(owner, IdentityPreferenceKey::NamespaceProbe) + .expect("deleted value"), + None + ); + } + + #[test] + fn namespace_rejects_oversized_and_control_character_values() { + let database = Database::in_memory().expect("database"); + let owner = public_key(3); + database.insert_identity(&identity(3)).expect("identity"); + let oversized = "a".repeat(super::MAX_VALUE_CHARS + 1); + assert!( + database + .set_value(owner, IdentityPreferenceKey::NamespaceProbe, &oversized) + .is_err() + ); + assert!( + database + .set_value(owner, IdentityPreferenceKey::NamespaceProbe, "line\nbreak") + .is_err() + ); + } +} diff --git a/core/crates/harvestcircle_storage/src/journal.rs b/core/crates/harvestcircle_storage/src/journal.rs @@ -1,11 +1,11 @@ use harvestcircle_application::{ - AccountOperationKind, AccountOperationPhase, DurableAccountOperation, DurableOperationKind, - DurableOperationPhase, DurableOperationReceipt, DurableOperationRepository, - DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic, - OperationId, OperationJournal, OperationPriorState, PendingAccountOperation, + DurableIdentityOperation, DurableOperationKind, DurableOperationPhase, DurableOperationReceipt, + DurableOperationRepository, DurableOperationStart, DurableRequestId, DurableTerminalOutcome, + IdentityOperationKind, IdentityOperationPhase, OperationDiagnostic, OperationId, + OperationJournal, OperationPriorState, PendingIdentityOperation, }; use harvestcircle_domain::{ - BindingAvailability, PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp, + PublicKey, SafeError, SafeErrorCode, SafeMessage, SignerAvailability, UnixTimestamp, }; use rusqlite::{OptionalExtension, Row, params}; @@ -16,7 +16,7 @@ impl DurableOperationRepository for Database { &self, request_id: &DurableRequestId, kind: DurableOperationKind, - account: PublicKey, + identity: PublicKey, expected_revision: Option<u64>, prior: OperationPriorState, updated_at: UnixTimestamp, @@ -36,9 +36,9 @@ impl DurableOperationRepository for Database { params![ request_id.as_str(), encode_durable_kind(kind), - account.to_hex(), + identity.to_hex(), encoded_expected_revision, - prior.selected_account().map(PublicKey::to_hex), + prior.selected_identity().map(PublicKey::to_hex), updated_at.as_seconds(), prior .binding_availability() @@ -49,7 +49,7 @@ impl DurableOperationRepository for Database { let operation = query_durable_operation(&transaction, request_id)?.ok_or_else(corrupt_storage_error)?; if operation.kind() != kind - || operation.account() != account + || operation.identity() != identity || operation.expected_revision() != expected_revision || operation.prior() != prior { @@ -66,7 +66,7 @@ impl DurableOperationRepository for Database { fn load_durable_operation( &self, request_id: &DurableRequestId, - ) -> Result<Option<DurableAccountOperation>, SafeError> { + ) -> Result<Option<DurableIdentityOperation>, SafeError> { query_durable_operation(&self.connection(), request_id) } @@ -77,7 +77,7 @@ impl DurableOperationRepository for Database { next_phase: DurableOperationPhase, updated_at: UnixTimestamp, diagnostic: Option<OperationDiagnostic>, - ) -> Result<DurableAccountOperation, SafeError> { + ) -> Result<DurableIdentityOperation, SafeError> { let mut connection = self.connection(); let transaction = connection.transaction().map_err(|_| storage_error())?; let rows = transaction @@ -150,7 +150,7 @@ impl DurableOperationRepository for Database { fn list_unfinished_durable_operations( &self, - ) -> Result<Vec<DurableAccountOperation>, SafeError> { + ) -> Result<Vec<DurableIdentityOperation>, SafeError> { let connection = self.connection(); let mut statement = connection .prepare(&format!( @@ -172,7 +172,7 @@ const DURABLE_OPERATION_SELECT: &str = "SELECT request_id, operation_kind, accou fn query_durable_operation( connection: &rusqlite::Connection, request_id: &DurableRequestId, -) -> Result<Option<DurableAccountOperation>, SafeError> { +) -> Result<Option<DurableIdentityOperation>, SafeError> { connection .query_row( &format!("{DURABLE_OPERATION_SELECT} WHERE request_id = ?1"), @@ -183,11 +183,11 @@ fn query_durable_operation( .map_err(|_| corrupt_storage_error()) } -fn decode_durable_operation(row: &Row<'_>) -> rusqlite::Result<DurableAccountOperation> { +fn decode_durable_operation(row: &Row<'_>) -> rusqlite::Result<DurableIdentityOperation> { let request_id = DurableRequestId::parse(row.get::<_, String>(0)?).map_err(|_| invalid_column(0))?; let kind = decode_durable_kind(row.get::<_, String>(1)?.as_str())?; - let account = + let identity = PublicKey::from_hex(row.get::<_, String>(2)?.as_str()).map_err(|_| invalid_column(2))?; let expected_revision = row .get::<_, Option<i64>>(3)? @@ -216,12 +216,12 @@ fn decode_durable_operation(row: &Row<'_>) -> rusqlite::Result<DurableAccountOpe .map(|value| u64::try_from(value).map_err(|_| invalid_column(10))) .transpose()?; let terminal = outcome.map(|outcome| { - DurableOperationReceipt::new(request_id.clone(), account, outcome, resulting_revision) + DurableOperationReceipt::new(request_id.clone(), identity, outcome, resulting_revision) }); - Ok(DurableAccountOperation::new( + Ok(DurableIdentityOperation::new( request_id, kind, - account, + identity, expected_revision, phase, OperationPriorState::new(prior_selected, prior_availability), @@ -234,7 +234,7 @@ fn decode_durable_operation(row: &Row<'_>) -> rusqlite::Result<DurableAccountOpe impl OperationJournal for Database { fn begin_operation( &self, - kind: AccountOperationKind, + kind: IdentityOperationKind, subject: PublicKey, updated_at: UnixTimestamp, ) -> Result<OperationId, SafeError> { @@ -254,7 +254,7 @@ impl OperationJournal for Database { fn update_operation( &self, id: OperationId, - phase: AccountOperationPhase, + phase: IdentityOperationPhase, updated_at: UnixTimestamp, diagnostic: Option<OperationDiagnostic>, ) -> Result<(), SafeError> { @@ -275,7 +275,7 @@ impl OperationJournal for Database { } } - fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> { + fn list_pending_operations(&self) -> Result<Vec<PendingIdentityOperation>, SafeError> { let connection = self.connection(); let mut statement = connection .prepare( @@ -302,7 +302,7 @@ impl OperationJournal for Database { } } -fn decode_operation(row: &Row<'_>) -> rusqlite::Result<PendingAccountOperation> { +fn decode_operation(row: &Row<'_>) -> rusqlite::Result<PendingIdentityOperation> { let id = u64::try_from(row.get::<_, i64>(0)?).map_err(|_| invalid_column(0))?; let kind = decode_kind(row.get::<_, String>(1)?.as_str())?; let subject = @@ -313,7 +313,7 @@ fn decode_operation(row: &Row<'_>) -> rusqlite::Result<PendingAccountOperation> .get::<_, Option<String>>(5)? .map(|value| decode_diagnostic(&value)) .transpose()?; - Ok(PendingAccountOperation::new( + Ok(PendingIdentityOperation::new( OperationId::from_raw(id), kind, subject, @@ -386,59 +386,59 @@ fn decode_terminal_outcome(value: &str) -> rusqlite::Result<DurableTerminalOutco } } -const fn encode_binding_availability(value: BindingAvailability) -> &'static str { +const fn encode_binding_availability(value: SignerAvailability) -> &'static str { match value { - BindingAvailability::Available => "available", - BindingAvailability::CredentialMissing => "credential_missing", - BindingAvailability::StoreUnavailable => "store_unavailable", + SignerAvailability::Available => "available", + SignerAvailability::CredentialMissing => "credential_missing", + SignerAvailability::StoreUnavailable => "store_unavailable", } } -fn decode_binding_availability(value: &str) -> rusqlite::Result<BindingAvailability> { +fn decode_binding_availability(value: &str) -> rusqlite::Result<SignerAvailability> { match value { - "available" => Ok(BindingAvailability::Available), - "credential_missing" => Ok(BindingAvailability::CredentialMissing), - "store_unavailable" => Ok(BindingAvailability::StoreUnavailable), + "available" => Ok(SignerAvailability::Available), + "credential_missing" => Ok(SignerAvailability::CredentialMissing), + "store_unavailable" => Ok(SignerAvailability::StoreUnavailable), _ => Err(invalid_column(9)), } } -const fn encode_kind(value: AccountOperationKind) -> &'static str { +const fn encode_kind(value: IdentityOperationKind) -> &'static str { match value { - AccountOperationKind::Add => "add", - AccountOperationKind::Import => "import", - AccountOperationKind::Remove => "remove", + IdentityOperationKind::Add => "add", + IdentityOperationKind::Import => "import", + IdentityOperationKind::Remove => "remove", } } -fn decode_kind(value: &str) -> rusqlite::Result<AccountOperationKind> { +fn decode_kind(value: &str) -> rusqlite::Result<IdentityOperationKind> { match value { - "add" => Ok(AccountOperationKind::Add), - "import" => Ok(AccountOperationKind::Import), - "remove" => Ok(AccountOperationKind::Remove), + "add" => Ok(IdentityOperationKind::Add), + "import" => Ok(IdentityOperationKind::Import), + "remove" => Ok(IdentityOperationKind::Remove), _ => Err(invalid_column(1)), } } -const fn encode_phase(value: AccountOperationPhase) -> &'static str { +const fn encode_phase(value: IdentityOperationPhase) -> &'static str { match value { - AccountOperationPhase::IntentRecorded => "intent_recorded", - AccountOperationPhase::CredentialWritten => "credential_written", - AccountOperationPhase::MetadataCommitted => "metadata_committed", - AccountOperationPhase::CompensationPending => "compensation_pending", - AccountOperationPhase::CredentialDeleted => "credential_deleted", - AccountOperationPhase::MetadataDeleted => "metadata_deleted", + IdentityOperationPhase::IntentRecorded => "intent_recorded", + IdentityOperationPhase::CredentialWritten => "credential_written", + IdentityOperationPhase::MetadataCommitted => "metadata_committed", + IdentityOperationPhase::CompensationPending => "compensation_pending", + IdentityOperationPhase::CredentialDeleted => "credential_deleted", + IdentityOperationPhase::MetadataDeleted => "metadata_deleted", } } -fn decode_phase(value: &str) -> rusqlite::Result<AccountOperationPhase> { +fn decode_phase(value: &str) -> rusqlite::Result<IdentityOperationPhase> { match value { - "intent_recorded" => Ok(AccountOperationPhase::IntentRecorded), - "credential_written" => Ok(AccountOperationPhase::CredentialWritten), - "metadata_committed" => Ok(AccountOperationPhase::MetadataCommitted), - "compensation_pending" => Ok(AccountOperationPhase::CompensationPending), - "credential_deleted" => Ok(AccountOperationPhase::CredentialDeleted), - "metadata_deleted" => Ok(AccountOperationPhase::MetadataDeleted), + "intent_recorded" => Ok(IdentityOperationPhase::IntentRecorded), + "credential_written" => Ok(IdentityOperationPhase::CredentialWritten), + "metadata_committed" => Ok(IdentityOperationPhase::MetadataCommitted), + "compensation_pending" => Ok(IdentityOperationPhase::CompensationPending), + "credential_deleted" => Ok(IdentityOperationPhase::CredentialDeleted), + "metadata_deleted" => Ok(IdentityOperationPhase::MetadataDeleted), _ => Err(invalid_column(3)), } } @@ -469,7 +469,7 @@ fn decode_diagnostic(value: &str) -> rusqlite::Result<OperationDiagnostic> { fn invalid_column(index: usize) -> rusqlite::Error { rusqlite::Error::InvalidColumnType( index, - "account operation journal".to_owned(), + "identity operation journal".to_owned(), rusqlite::types::Type::Text, ) } @@ -477,39 +477,39 @@ fn invalid_column(index: usize) -> rusqlite::Error { const fn storage_error() -> SafeError { SafeError::new( SafeErrorCode::StorageUnavailable, - SafeMessage::new("The account recovery journal is unavailable."), + SafeMessage::new("The identity recovery journal is unavailable."), ) } const fn corrupt_storage_error() -> SafeError { SafeError::new( SafeErrorCode::StorageCorrupt, - SafeMessage::new("The account recovery journal could not be read."), + SafeMessage::new("The identity recovery journal could not be read."), ) } const fn operation_not_found() -> SafeError { SafeError::new( SafeErrorCode::PendingOperationRecoveryRequired, - SafeMessage::new("The account recovery operation was not found."), + SafeMessage::new("The identity recovery operation was not found."), ) } const fn operation_conflict() -> SafeError { SafeError::new( SafeErrorCode::InvalidApplicationState, - SafeMessage::new("The durable account operation conflicts with existing state."), + SafeMessage::new("The durable identity operation conflicts with existing state."), ) } #[cfg(test)] mod tests { use harvestcircle_application::{ - AccountOperationKind, AccountOperationPhase, DurableOperationKind, DurableOperationPhase, - DurableOperationRepository, DurableOperationStart, DurableRequestId, - DurableTerminalOutcome, OperationDiagnostic, OperationJournal, OperationPriorState, + DurableOperationKind, DurableOperationPhase, DurableOperationRepository, + DurableOperationStart, DurableRequestId, DurableTerminalOutcome, IdentityOperationKind, + IdentityOperationPhase, OperationDiagnostic, OperationJournal, OperationPriorState, }; - use harvestcircle_domain::{BindingAvailability, PublicKey, UnixTimestamp}; + use harvestcircle_domain::{PublicKey, SignerAvailability, UnixTimestamp}; use crate::Database; @@ -528,7 +528,7 @@ mod tests { let subject = public_key(7); let id = database .begin_operation( - AccountOperationKind::Import, + IdentityOperationKind::Import, subject, UnixTimestamp::from_seconds(10).expect("time"), ) @@ -536,7 +536,7 @@ mod tests { database .update_operation( id, - AccountOperationPhase::CompensationPending, + IdentityOperationPhase::CompensationPending, UnixTimestamp::from_seconds(11).expect("time"), Some(OperationDiagnostic::KeyringUnavailable), ) @@ -545,10 +545,10 @@ mod tests { let pending = database.list_pending_operations().expect("pending"); assert_eq!(pending.len(), 1); assert_eq!(pending[0].subject(), subject); - assert_eq!(pending[0].kind(), AccountOperationKind::Import); + assert_eq!(pending[0].kind(), IdentityOperationKind::Import); assert_eq!( pending[0].phase(), - AccountOperationPhase::CompensationPending + IdentityOperationPhase::CompensationPending ); assert_eq!( pending[0].diagnostic(), @@ -569,7 +569,7 @@ mod tests { let database = Database::in_memory().expect("database"); database .begin_operation( - AccountOperationKind::Remove, + IdentityOperationKind::Remove, public_key(8), UnixTimestamp::from_seconds(12).expect("time"), ) @@ -590,16 +590,16 @@ mod tests { fn durable_repository_replays_matching_requests_and_retains_terminal_receipts() { let database = Database::in_memory().expect("database"); let request = DurableRequestId::parse("import:test:1").expect("request"); - let account = public_key(9); + let identity = public_key(9); let prior = OperationPriorState::new( Some(public_key(8)), - Some(BindingAvailability::CredentialMissing), + Some(SignerAvailability::CredentialMissing), ); let started = database .begin_durable_operation( &request, DurableOperationKind::Repair, - account, + identity, Some(4), prior, UnixTimestamp::from_seconds(10).expect("time"), @@ -610,7 +610,7 @@ mod tests { .begin_durable_operation( &request, DurableOperationKind::Repair, - account, + identity, Some(4), prior, UnixTimestamp::from_seconds(11).expect("time"), @@ -622,7 +622,7 @@ mod tests { .begin_durable_operation( &request, DurableOperationKind::Remove, - account, + identity, Some(4), prior, UnixTimestamp::from_seconds(11).expect("time"), @@ -658,7 +658,7 @@ mod tests { .begin_durable_operation( &request, DurableOperationKind::Repair, - account, + identity, Some(5), prior, UnixTimestamp::from_seconds(11).expect("time"), @@ -670,7 +670,7 @@ mod tests { .begin_durable_operation( &request, DurableOperationKind::Repair, - account, + identity, Some(4), OperationPriorState::new(None, None), UnixTimestamp::from_seconds(11).expect("time"), @@ -746,7 +746,7 @@ mod tests { .begin_durable_operation( &overflow_request, DurableOperationKind::Import, - account, + identity, None, OperationPriorState::new(None, None), UnixTimestamp::from_seconds(15).expect("time"), diff --git a/core/crates/harvestcircle_storage/src/lib.rs b/core/crates/harvestcircle_storage/src/lib.rs @@ -1,10 +1,10 @@ #![doc = "HarvestCircle persistence adapters."] #![cfg_attr(coverage_nightly, feature(coverage_attribute))] -pub mod account_namespace; -pub mod accounts; mod compatibility; pub mod db; +pub mod identities; +pub mod identity_namespace; mod installation; pub mod journal; // The operating-system credential store requires an explicit, ignored host smoke test. diff --git a/core/crates/harvestcircle_storage/src/os_keyring.rs b/core/crates/harvestcircle_storage/src/os_keyring.rs @@ -79,15 +79,15 @@ const fn map_read_error(error: &KeyringError) -> SafeError { const fn credential_exists() -> SafeError { SafeError::new( - SafeErrorCode::AccountAlreadyExists, - SafeMessage::new("The Nostr account credential already exists."), + SafeErrorCode::IdentityAlreadyExists, + SafeMessage::new("The Nostr identity credential already exists."), ) } const fn credential_missing() -> SafeError { SafeError::new( SafeErrorCode::CredentialMissing, - SafeMessage::new("The Nostr account credential is missing."), + SafeMessage::new("The Nostr identity credential is missing."), ) } diff --git a/core/crates/harvestcircle_storage/src/profiles.rs b/core/crates/harvestcircle_storage/src/profiles.rs @@ -147,11 +147,11 @@ const fn corrupt_storage_error() -> SafeError { #[cfg(test)] mod tests { use harvestcircle_application::{ - AccountRepository, CachedProfile, ProfileRefreshStatus, ProfileRepository, + CachedProfile, IdentityRepository, ProfileRefreshStatus, ProfileRepository, }; use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, EventId, - Kind0ProfileCandidate, LocalSignerBinding, ProfileMetadata, PublicKey, UnixTimestamp, + EventId, IdentityCreatedAt, Kind0ProfileCandidate, LocalKeyringBinding, NostrIdentity, + NostrIdentityReference, ProfileMetadata, PublicKey, SignerAvailability, UnixTimestamp, }; use crate::Database; @@ -160,15 +160,15 @@ mod tests { PublicKey::from_bytes([7; 32]).expect("valid public key") } - fn account(public_key: PublicKey) -> AccountSummary { - AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), + fn identity(public_key: PublicKey) -> NostrIdentity { + NostrIdentity::new( + NostrIdentityReference::derive(public_key).expect("identity"), + LocalKeyringBinding::new(public_key, SignerAvailability::Available), None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + IdentityCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), None, ) - .expect("account") + .expect("identity") } fn profile(public_key: PublicKey, id: u8, created_at: i64, name: &str) -> CachedProfile { @@ -190,8 +190,8 @@ mod tests { let database = Database::in_memory().expect("database"); let public_key = public_key(); database - .insert_account(&account(public_key)) - .expect("account"); + .insert_identity(&identity(public_key)) + .expect("identity"); database .save_profile(&profile(public_key, 1, 10, "Farm")) .expect("save profile"); @@ -217,8 +217,8 @@ mod tests { let database = Database::in_memory().expect("database"); let public_key = public_key(); database - .insert_account(&account(public_key)) - .expect("account"); + .insert_identity(&identity(public_key)) + .expect("identity"); database .save_profile(&profile(public_key, 9, 20, "High ID")) .expect("initial"); @@ -238,16 +238,18 @@ mod tests { } #[test] - fn profile_cache_cascades_with_account_removal() { + fn profile_cache_cascades_with_identity_removal() { let database = Database::in_memory().expect("database"); let public_key = public_key(); database - .insert_account(&account(public_key)) - .expect("account"); + .insert_identity(&identity(public_key)) + .expect("identity"); database .save_profile(&profile(public_key, 1, 10, "Farm")) .expect("profile"); - database.remove_account(public_key).expect("remove account"); + database + .remove_identity(public_key) + .expect("remove identity"); assert_eq!(database.load_profile(public_key).expect("load"), None); } diff --git a/core/crates/harvestcircle_storage/tests/redaction.rs b/core/crates/harvestcircle_storage/tests/redaction.rs @@ -1,9 +1,9 @@ use std::fs; -use harvestcircle_application::{AccountOperationKind, AccountRepository, OperationJournal}; +use harvestcircle_application::{IdentityOperationKind, IdentityRepository, OperationJournal}; use harvestcircle_domain::{ - AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, - PublicKey, UnixTimestamp, + IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, PublicKey, + SignerAvailability, UnixTimestamp, }; use harvestcircle_storage::Database; use tempfile::{TempDir, tempdir_in}; @@ -35,19 +35,19 @@ fn redaction_guards_sqlite_schema_and_non_secret_records() { { let database = Database::open(&path).expect("database"); let public_key = PublicKey::from_bytes([7; 32]).expect("valid public key"); - let account = AccountSummary::new( - AccountIdentity::derive(public_key).expect("identity"), - LocalSignerBinding::new(public_key, BindingAvailability::Available), + let identity = NostrIdentity::new( + NostrIdentityReference::derive(public_key).expect("identity"), + LocalKeyringBinding::new(public_key, SignerAvailability::Available), None, - AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + IdentityCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), None, ) - .expect("account"); - database.insert_account(&account).expect("account"); + .expect("identity"); + database.insert_identity(&identity).expect("identity"); database .begin_operation( - AccountOperationKind::Add, - account.public_key(), + IdentityOperationKind::Add, + identity.public_key(), UnixTimestamp::from_seconds(2).expect("time"), ) .expect("journal");