app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 5548b0e6eb65c3c438c09dfc5063cf5e9e6873de
parent 5d73568ffb852dea9749c23a75816d5adb932831
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 01:52:05 +0000

application: define platform-neutral runtime contracts

- add validated identity, operation, revision, lifecycle, and error models
- define shared commands, results, changes, recovery, and shutdown contracts
- expose a coroutine Flow-based runtime interface without native types
- enforce prohibited imports and exhaustive common-model tests in both lanes

Diffstat:
Mapp/shared/build.gradle.kts | 16++++++++++++++++
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ApplicationModels.kt | 231+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeContracts.kt | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeIdentifiers.kt | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/shared/src/commonTest/kotlin/org/harvestcircle/application/RuntimeContractsTest.kt | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AbuildSrc/src/main/kotlin/org/harvestcircle/gradle/VerifySharedBoundary.kt | 36++++++++++++++++++++++++++++++++++++
Mgradle/libs.versions.toml | 2+-
7 files changed, 697 insertions(+), 1 deletion(-)

diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts @@ -1,3 +1,4 @@ +import org.harvestcircle.gradle.VerifySharedBoundary import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType @@ -19,6 +20,9 @@ kotlin { } sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + } commonTest.dependencies { implementation(kotlin("test")) } @@ -34,3 +38,15 @@ kotlin { "HarvestCircle shared must declare exactly one KMP platform target named desktop" } } + +val verifySharedBoundary by tasks.registering(VerifySharedBoundary::class) { + commonSources.from( + fileTree("src/commonMain/kotlin") { + include("**/*.kt") + }, + ) +} + +tasks.named("check") { + dependsOn(verifySharedBoundary) +} diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ApplicationModels.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ApplicationModels.kt @@ -0,0 +1,231 @@ +package org.harvestcircle.application + +enum class ApplicationLifecycle { + Opening, + CompatibilityChecking, + AcquiringOwnership, + Migrating, + Recovering, + Ready, + Degraded, + Blocked, + ShuttingDown, + Closed, + Fatal, +} + +enum class SessionLifecycle { + SignedOut, + Activating, + Active, + SigningOut, + Failed, +} + +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) + } + } +} + +data class SignerBindingSummary( + val kind: SignerBindingKind, + val availability: SignerAvailability, +) + +data class IdentitySummary( + val id: IdentityId, + val npub: String, + val displayLabel: String, + val signer: SignerBindingSummary, + val createdAt: UnixSeconds, + val lastUsedAt: UnixSeconds?, +) { + init { + requireSafeText(npub, "Nostr public identity", 128) + requireSafeText(displayLabel, "Identity display label", 128) + require(lastUsedAt == null || lastUsedAt.value >= createdAt.value) { + "Identity last-used time precedes creation" + } + } +} + +enum class RelayConnectionState { + Disconnected, + Connecting, + Connected, + Degraded, + Error, +} + +data class RelaySummary( + val destinations: List<String>, + val state: RelayConnectionState, +) { + init { + require(destinations.distinct() == destinations) { "Relay destinations must be unique" } + destinations.forEach { requireSafeText(it, "Relay destination", 2048) } + } +} + +enum class ProfileLoadState { + Empty, + Loading, + Cached, + Fresh, + Error, +} + +data class ProfileSummary( + val name: String?, + val displayName: String?, + val nip05: String?, + val about: String?, + val picture: String?, +) { + init { + validateOptional(name, "Profile name", 256) + validateOptional(displayName, "Profile display name", 256) + validateOptional(nip05, "Profile NIP-05 identifier", 320) + validateOptional(about, "Profile about text", 4096) + validateOptional(picture, "Profile picture URL", 2048) + } +} + +data class ActiveIdentity( + val identity: IdentitySummary, + val relays: RelaySummary, + val profileState: ProfileLoadState, + val profile: ProfileSummary?, +) + +enum class ApplicationErrorCode { + InvalidPublicKey, + InvalidSecretKey, + InvalidIdentityMetadata, + InvalidProfileMetadata, + InvalidApplicationState, + IdentityAlreadyExists, + IdentityNotFound, + KeyringUnavailable, + CredentialMissing, + StorageUnavailable, + StorageCorrupt, + StorageQuarantined, + StorageBackupInvalid, + UnsupportedSchemaVersion, + RepairUnauthorized, + PendingOperationRecoveryRequired, + InvalidRelayConfiguration, + RelayConnectionFailed, + ProfileRefreshFailed, + ObserverRegistrationFailed, + NativeLibraryLoadFailed, + CompatibilityMismatch, + Internal, +} + +enum class ApplicationErrorCategory { + Input, + Conflict, + Credential, + Storage, + Network, + Lifecycle, + Compatibility, + Internal, +} + +enum class RecoveryAction { + None, + Retry, + RepairCredential, + Authenticate, + RepairStorage, + RestoreBackup, + CheckConfiguration, + RestartApplication, + UpdateApplication, +} + +data class ApplicationProblem( + val code: ApplicationErrorCode, + val category: ApplicationErrorCategory, + val retryable: Boolean, + val recoveryAction: RecoveryAction, + val operationId: OperationId?, + val safeMessage: String, +) { + init { + requireSafeText(safeMessage, "Safe error message", 512) + } +} + +data class ApplicationSnapshot( + val revision: SnapshotRevision, + val lifecycle: ApplicationLifecycle, + val lifecycleProblem: ApplicationProblem?, + val configuredRelays: List<String>, + val identities: List<IdentitySummary>, + val selectedIdentityId: IdentityId?, + val session: SessionLifecycle, + val sessionSubjectIdentityId: IdentityId?, + val sessionProblem: ApplicationProblem?, + val activeIdentity: ActiveIdentity?, + val recoverableProblem: ApplicationProblem?, +) { + init { + require(configuredRelays.distinct() == configuredRelays) { "Configured relays must be unique" } + configuredRelays.forEach { requireSafeText(it, "Configured relay", 2048) } + require(identities.map(IdentitySummary::id).distinct().size == identities.size) { + "Snapshot identities must be unique" + } + val ids = identities.map(IdentitySummary::id).toSet() + require(selectedIdentityId == null || selectedIdentityId in ids) { + "Selected identity is not present in the snapshot" + } + require(sessionSubjectIdentityId == null || sessionSubjectIdentityId in ids) { + "Session subject is not present in the snapshot" + } + require(activeIdentity == null || activeIdentity.identity in identities) { + "Active identity does not match an identity in the snapshot" + } + require((session == SessionLifecycle.Active) == (activeIdentity != null)) { + "Active session and active identity must agree" + } + require(activeIdentity == null || activeIdentity.relays.destinations == configuredRelays) { + "Active identity relays do not match configured relays" + } + } +} + +private fun validateOptional( + value: String?, + label: String, + maximumLength: Int, +) { + if (value != null) requireSafeText(value, label, maximumLength) +} + +private fun requireSafeText( + value: String, + label: String, + maximumLength: Int, +) { + require(value.isNotBlank() && value.length <= maximumLength && value.none(Char::isISOControl)) { + "$label is empty, oversized, or contains a control character" + } +} diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeContracts.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeContracts.kt @@ -0,0 +1,103 @@ +package org.harvestcircle.application + +import kotlinx.coroutines.flow.Flow + +sealed interface ApplicationCommand { + data class AcknowledgeGeneratedIdentity( + val requestId: RecoveryRequestId, + val context: RequestContext, + ) : ApplicationCommand + + data class CancelGeneratedIdentity( + val requestId: RecoveryRequestId, + ) : ApplicationCommand + + data class ImportLocalIdentity( + val secretKey: SecretKeyInput, + val context: RequestContext, + ) : ApplicationCommand + + data class SelectIdentity( + val identityId: IdentityId, + ) : ApplicationCommand + + data class ActivateIdentity( + val identityId: IdentityId, + ) : ApplicationCommand + + data object SignOut : ApplicationCommand + + data object RefreshActiveProfile : ApplicationCommand + + data class ConfirmIdentityRemoval( + val requestId: RemovalRequestId, + val context: RequestContext, + ) : ApplicationCommand +} + +sealed interface ApplicationCommandResult { + val snapshot: ApplicationSnapshot + + data class Committed( + val operationId: OperationId, + val committedRevision: SnapshotRevision, + override val snapshot: ApplicationSnapshot, + ) : ApplicationCommandResult { + init { + require(committedRevision == snapshot.revision) { + "Committed revision does not match the returned snapshot" + } + } + } + + data class Updated( + override val snapshot: ApplicationSnapshot, + ) : ApplicationCommandResult +} + +data class GeneratedIdentityRecovery( + val requestId: RecoveryRequestId, + val identity: IdentitySummary, + val expiresAt: UnixSeconds, + val backup: GeneratedKeyBackup, +) + +data class IdentityRemovalRequest( + val requestId: RemovalRequestId, + val identityId: IdentityId, + val deletesLocalCredential: Boolean, + val signsOut: Boolean, + val expiresAt: UnixSeconds, +) + +data class ApplicationChange( + val snapshot: ApplicationSnapshot, + val previousRevision: SnapshotRevision?, +) { + init { + require(previousRevision == null || previousRevision.value < snapshot.revision.value) { + "Application change revisions are not monotonic" + } + } +} + +data class ShutdownReceipt( + val finalRevision: SnapshotRevision, + val closed: Boolean, +) + +interface HarvestCircleRuntime { + suspend fun bootstrap(): ApplicationSnapshot + + fun currentSnapshot(): ApplicationSnapshot + + fun changes(): Flow<ApplicationChange> + + suspend fun execute(command: ApplicationCommand): ApplicationCommandResult + + suspend fun prepareLocalIdentity(): GeneratedIdentityRecovery + + suspend fun requestIdentityRemoval(identityId: IdentityId): IdentityRemovalRequest + + suspend fun shutdown(): ShutdownReceipt +} diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeIdentifiers.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeIdentifiers.kt @@ -0,0 +1,93 @@ +package org.harvestcircle.application + +private val lowercaseHex = Regex("[0-9a-f]{64}") +private val opaqueIdentifier = Regex("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}") + +@JvmInline +value class IdentityId private constructor( + val value: String, +) { + companion object { + fun fromPublicKeyHex(value: String): IdentityId { + require(lowercaseHex.matches(value)) { "Identity ID must be canonical lowercase public-key hex" } + return IdentityId(value) + } + } +} + +@JvmInline +value class OperationId private constructor( + val value: String, +) { + companion object { + fun from(value: String): OperationId { + require(opaqueIdentifier.matches(value)) { "Operation ID is malformed" } + return OperationId(value) + } + } +} + +@JvmInline +value class RecoveryRequestId private constructor( + val value: String, +) { + companion object { + fun from(value: String): RecoveryRequestId { + require(opaqueIdentifier.matches(value)) { "Recovery request ID is malformed" } + return RecoveryRequestId(value) + } + } +} + +@JvmInline +value class RemovalRequestId private constructor( + val value: String, +) { + companion object { + fun from(value: String): RemovalRequestId { + require(opaqueIdentifier.matches(value)) { "Removal request ID is malformed" } + return RemovalRequestId(value) + } + } +} + +@JvmInline +value class SnapshotRevision( + val value: ULong, +) + +@JvmInline +value class UnixSeconds( + val value: Long, +) + +data class RequestContext( + val operationId: OperationId, + val expectedRevision: SnapshotRevision, + val deadlineMillis: ULong, +) { + init { + require(deadlineMillis in 1UL..30_000UL) { "Command deadline is outside the supported window" } + } +} + +class SecretKeyInput private constructor( + private var secret: String?, +) { + fun take(): String = checkNotNull(secret).also { secret = null } + + fun clear() { + secret = null + } + + override fun toString(): String = "SecretKeyInput([REDACTED])" + + companion object { + fun from(value: String): SecretKeyInput { + require(value.isNotBlank() && value.length <= 256 && value.none(Char::isISOControl)) { + "Secret-key input is malformed" + } + return SecretKeyInput(value) + } + } +} diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/RuntimeContractsTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/RuntimeContractsTest.kt @@ -0,0 +1,217 @@ +package org.harvestcircle.application + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RuntimeContractsTest { + @Test + fun identifiersAndCommandInputsValidateAndRedact() { + val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) + assertEquals("01".repeat(32), identityId.value) + assertFailsWith<IllegalArgumentException> { IdentityId.fromPublicKeyHex("AB".repeat(32)) } + assertFailsWith<IllegalArgumentException> { OperationId.from("contains space") } + assertFailsWith<IllegalArgumentException> { + RequestContext(OperationId.from("operation-1"), SnapshotRevision(0UL), 0UL) + } + + val secret = SecretKeyInput.from("nsec1boundedsecret") + assertFalse(secret.toString().contains("nsec1boundedsecret")) + assertEquals("nsec1boundedsecret", secret.take()) + assertFailsWith<IllegalStateException> { secret.take() } + } + + @Test + fun snapshotsRejectBrokenIdentityAndRevisionRelationships() { + val snapshot = snapshot(revision = 2UL) + assertEquals(snapshot.identities.single().id, snapshot.selectedIdentityId) + assertFailsWith<IllegalArgumentException> { + snapshot.copy(selectedIdentityId = IdentityId.fromPublicKeyHex("02".repeat(32))) + } + assertFailsWith<IllegalArgumentException> { + ApplicationChange(snapshot, SnapshotRevision(2UL)) + } + assertFailsWith<IllegalArgumentException> { + ApplicationCommandResult.Committed( + operationId = OperationId.from("operation-1"), + committedRevision = SnapshotRevision(1UL), + snapshot = snapshot, + ) + } + } + + @Test + fun lifecycleErrorAndRecoveryModelsAreExhaustive() { + assertEquals( + 11, + ApplicationLifecycle.entries + .map(::lifecycleName) + .distinct() + .size, + ) + assertEquals( + 5, + SessionLifecycle.entries + .map(::sessionName) + .distinct() + .size, + ) + assertEquals( + 4, + SignerAvailability.entries + .map(::availabilityName) + .distinct() + .size, + ) + assertEquals( + 5, + RelayConnectionState.entries + .map(::relayName) + .distinct() + .size, + ) + assertEquals( + 5, + ProfileLoadState.entries + .map(::profileStateName) + .distinct() + .size, + ) + assertEquals( + 23, + ApplicationErrorCode.entries + .map(::errorCodeName) + .distinct() + .size, + ) + assertEquals( + 8, + ApplicationErrorCategory.entries + .map(::errorCategoryName) + .distinct() + .size, + ) + assertEquals( + 9, + RecoveryAction.entries + .map(::recoveryName) + .distinct() + .size, + ) + } + + @Test + fun everyFoundationCommandHasAnExplicitRuntimeKind() { + val id = IdentityId.fromPublicKeyHex("01".repeat(32)) + val context = RequestContext(OperationId.from("operation-1"), SnapshotRevision(1UL), 1_000UL) + val commands = + listOf( + ApplicationCommand.AcknowledgeGeneratedIdentity(RecoveryRequestId.from("recovery-1"), context), + ApplicationCommand.CancelGeneratedIdentity(RecoveryRequestId.from("recovery-1")), + ApplicationCommand.ImportLocalIdentity(SecretKeyInput.from("nsec1boundedsecret"), context), + ApplicationCommand.SelectIdentity(id), + ApplicationCommand.ActivateIdentity(id), + ApplicationCommand.SignOut, + ApplicationCommand.RefreshActiveProfile, + ApplicationCommand.ConfirmIdentityRemoval(RemovalRequestId.from("removal-1"), context), + ) + + assertEquals(8, commands.map(::commandName).distinct().size) + assertTrue(commands.none { it.toString().contains("nsec1boundedsecret") }) + } +} + +private fun snapshot(revision: ULong): ApplicationSnapshot { + val identity = + IdentitySummary( + id = IdentityId.fromPublicKeyHex("01".repeat(32)), + npub = "npub1identity", + displayLabel = "Identity", + signer = SignerBindingSummary(SignerBindingKind.LocalKeyring, SignerAvailability.Available), + createdAt = UnixSeconds(1), + lastUsedAt = null, + ) + return ApplicationSnapshot( + revision = SnapshotRevision(revision), + lifecycle = ApplicationLifecycle.Ready, + lifecycleProblem = null, + configuredRelays = listOf("wss://relay.example"), + identities = listOf(identity), + selectedIdentityId = identity.id, + session = SessionLifecycle.SignedOut, + sessionSubjectIdentityId = null, + sessionProblem = null, + activeIdentity = null, + recoverableProblem = null, + ) +} + +private fun lifecycleName(value: ApplicationLifecycle): String = + when (value) { + ApplicationLifecycle.Opening -> "opening" + ApplicationLifecycle.CompatibilityChecking -> "compatibility" + ApplicationLifecycle.AcquiringOwnership -> "ownership" + ApplicationLifecycle.Migrating -> "migrating" + ApplicationLifecycle.Recovering -> "recovering" + ApplicationLifecycle.Ready -> "ready" + ApplicationLifecycle.Degraded -> "degraded" + ApplicationLifecycle.Blocked -> "blocked" + ApplicationLifecycle.ShuttingDown -> "shutting-down" + ApplicationLifecycle.Closed -> "closed" + ApplicationLifecycle.Fatal -> "fatal" + } + +private fun sessionName(value: SessionLifecycle): String = + when (value) { + SessionLifecycle.SignedOut -> "signed-out" + SessionLifecycle.Activating -> "activating" + SessionLifecycle.Active -> "active" + SessionLifecycle.SigningOut -> "signing-out" + SessionLifecycle.Failed -> "failed" + } + +private fun availabilityName(value: SignerAvailability): String = + when (value) { + SignerAvailability.Available -> "available" + SignerAvailability.CredentialMissing -> "credential-missing" + SignerAvailability.StoreUnavailable -> "store-unavailable" + SignerAvailability.NotRequired -> "not-required" + } + +private fun relayName(value: RelayConnectionState): String = + when (value) { + RelayConnectionState.Disconnected -> "disconnected" + RelayConnectionState.Connecting -> "connecting" + RelayConnectionState.Connected -> "connected" + RelayConnectionState.Degraded -> "degraded" + RelayConnectionState.Error -> "error" + } + +private fun profileStateName(value: ProfileLoadState): String = + when (value) { + ProfileLoadState.Empty -> "empty" + ProfileLoadState.Loading -> "loading" + ProfileLoadState.Cached -> "cached" + ProfileLoadState.Fresh -> "fresh" + ProfileLoadState.Error -> "error" + } + +private fun errorCodeName(value: ApplicationErrorCode): String = value.name + +private fun errorCategoryName(value: ApplicationErrorCategory): String = value.name + +private fun recoveryName(value: RecoveryAction): String = value.name + +private fun commandName(value: ApplicationCommand): String = + when (value) { + is ApplicationCommand.AcknowledgeGeneratedIdentity -> "acknowledge-generated" + is ApplicationCommand.CancelGeneratedIdentity -> "cancel-generated" + is ApplicationCommand.ImportLocalIdentity -> "import" + is ApplicationCommand.SelectIdentity -> "select" + is ApplicationCommand.ActivateIdentity -> "activate" + ApplicationCommand.SignOut -> "sign-out" + ApplicationCommand.RefreshActiveProfile -> "refresh-profile" + is ApplicationCommand.ConfirmIdentityRemoval -> "confirm-removal" + } diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/VerifySharedBoundary.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/VerifySharedBoundary.kt @@ -0,0 +1,36 @@ +package org.harvestcircle.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +abstract class VerifySharedBoundary : DefaultTask() { + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val commonSources: ConfigurableFileCollection + + @TaskAction + fun verify() { + val forbidden = + listOf( + "org.harvestcircle." + "ffi", + "com.sun." + "jna", + "java." + "awt", + "javax." + "swing", + "java." + "io", + "java." + "nio", + ) + val findings = + commonSources.files + .filter { it.isFile && it.extension == "kt" } + .flatMap { file -> + forbidden + .filter(file.readText()::contains) + .map { token -> "${file.name}: prohibited common-source dependency $token" } + } + check(findings.isEmpty()) { findings.sorted().joinToString("\n") } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ owasp-dependency-check = "12.2.2" compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose" } compose-ui-test-junit4 = { module = "org.jetbrains.compose.ui:ui-test-junit4", version.ref = "compose" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } -kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm", version.ref = "coroutines" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } [plugins]