commit 7275bd259a8bddfe04ef4aff4b706fe310081a1d parent 2ca15c6d9a018e462ec8cde2d5d208e4926798f0 Author: triesap <tyson@radroots.org> Date: Mon, 3 Aug 2026 23:58:17 +0000 accounts: harden secret key import surface - bound masked import input before native transport - project typed validation duplicate and repair guidance - clear stale failure metadata when import editing resumes - cover input bounds and credential-repair presentation Diffstat:
6 files changed, 65 insertions(+), 0 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt @@ -8,6 +8,8 @@ import org.radroots.studio.ffi.ActiveAccountDto import org.radroots.studio.ffi.ProfileLoadStateDto import org.radroots.studio.ffi.RelayConnectionStateDto import org.radroots.studio.ffi.SessionStateDto +import org.radroots.studio.ffi.WireErrorCode +import org.radroots.studio.ffi.WireRecoveryAction data class AccountUiModel( val publicKeyHex: String, @@ -54,6 +56,7 @@ data class StudioUiModel( val session: SessionStateDto, val busy: Boolean, val problem: String?, + val importGuidance: String?, ) fun StudioStoreState.toUiModel(): StudioUiModel { @@ -77,9 +80,21 @@ fun StudioStoreState.toUiModel(): StudioUiModel { ?: snapshot.recoverableProblem?.message ?: snapshot.sessionError?.message ?: snapshot.lifecycleError?.message, + importGuidance = importGuidance(lastFailureCode, 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 +} + fun shortenNpub(npub: String): String = if (npub.length <= 24) npub else "${npub.take(14)}…${npub.takeLast(8)}" diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt @@ -294,6 +294,9 @@ private fun AccountEntry(model: StudioUiModel, actions: StudioUiActions) { innerTextField() }, ) + model.importGuidance?.let { guidance -> + BasicText(guidance, Modifier.testTag("import-guidance")) + } TextAction( text = "Add existing key", testTag = "import-key", diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.launch import org.radroots.studio.ffi.AppSnapshotDto import org.radroots.studio.ffi.AppLifecycleDto import org.radroots.studio.ffi.StudioException +import org.radroots.studio.ffi.WireErrorCode +import org.radroots.studio.ffi.WireRecoveryAction enum class StudioRoute { OPENING, @@ -51,6 +53,8 @@ data class StudioStoreState( val busy: Boolean = false, val commandStatus: CommandStatus = CommandStatus.IDLE, val lastCommandRequestId: String? = null, + val lastFailureCode: WireErrorCode? = null, + val recoveryAction: WireRecoveryAction = WireRecoveryAction.NONE, val problem: String? = null, ) @@ -90,6 +94,8 @@ class StudioAppStore( fun editImportDraft(value: String) { mutableState.value = mutableState.value.copy( importDraft = value.take(MAX_IMPORT_SECRET_CHARS), + lastFailureCode = null, + recoveryAction = WireRecoveryAction.NONE, problem = null, ) } @@ -247,6 +253,8 @@ class StudioAppStore( mutableState.value = mutableState.value.copy( commandStatus = CommandStatus.ACCEPTED, lastCommandRequestId = result.receipt.requestId, + lastFailureCode = null, + recoveryAction = WireRecoveryAction.NONE, ) if (hideChooser) { mutableState.value = mutableState.value.copy(accountChooserVisible = false) @@ -260,6 +268,8 @@ class StudioAppStore( CommandStatus.FAILED_TERMINAL }, lastCommandRequestId = result.failure.correlationId, + lastFailureCode = result.failure.code, + recoveryAction = result.failure.recoveryAction, problem = result.failure.safeMessage, ) } @@ -339,6 +349,8 @@ class StudioAppStore( CommandStatus.FAILED_TERMINAL }, lastCommandRequestId = native?.correlationId, + lastFailureCode = native?.code, + recoveryAction = native?.recoveryAction ?: WireRecoveryAction.NONE, problem = native?.safeMessage ?: "The application command failed.", ) } diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsUiModelTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsUiModelTest.kt @@ -16,6 +16,8 @@ import org.radroots.studio.ffi.ProfileLoadStateDto import org.radroots.studio.ffi.RelayConnectionStateDto import org.radroots.studio.ffi.SessionStateDto import org.radroots.studio.ffi.SignerKindDto +import org.radroots.studio.ffi.WireErrorCode +import org.radroots.studio.ffi.WireRecoveryAction class AccountsUiModelTest { @Test @@ -62,6 +64,25 @@ class AccountsUiModelTest { assertEquals("npub1short", shortenNpub("npub1short")) assertEquals("npub1abcdefghi…34567890", shortenNpub("npub1abcdefghijklmnopqrstuvwxyz1234567890")) } + + @Test + fun mapsTypedImportFailuresToSpecificRepairGuidance() { + val invalid = StudioStoreState( + snapshot = snapshot(), + lastFailureCode = WireErrorCode.INVALID_SECRET_KEY, + ).toUiModel() + val repair = StudioStoreState( + 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( diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt @@ -285,6 +285,7 @@ class StudioScreenTest { private fun emptyUiModel( importDraft: String = "", problem: String? = null, + importGuidance: String? = null, ) = StudioUiModel( route = StudioRoute.ACCOUNTS, accounts = emptyList(), @@ -298,6 +299,7 @@ private fun emptyUiModel( session = SessionStateDto.SIGNED_OUT, busy = false, problem = problem, + importGuidance = importGuidance, ) private fun accountUi(publicKeyHex: String, selected: Boolean) = AccountUiModel( diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt @@ -166,6 +166,18 @@ class StudioAppStoreTest { } @Test + fun `bounds imported secret presentation input before transport`() = runTest { + val gateway = FakeStudioCoreGateway(snapshot(0UL)) + val store = StudioAppStore(gateway, this) + advanceUntilIdle() + + store.editImportDraft("x".repeat(MAX_IMPORT_SECRET_CHARS + 50)) + + assertEquals(MAX_IMPORT_SECRET_CHARS, store.state.value.importDraft.length) + store.close() + } + + @Test fun `projects boot fatal and terminal lifecycle failures`() = runTest { val booting = snapshot(0UL, AppLifecycleDto.OPENING) val bootGateway = FakeStudioCoreGateway(booting, booting)