diff --git a/CHANGELOG.md b/CHANGELOG.md index db8b4f3..8b318c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-07-11 + +### Added + +- **Per-worker fleet keys.** The app now enrolls automatically on its first heartbeat against a CashPilot server (v1.0.0+): it receives its own per-worker key, persists it, and authenticates every subsequent heartbeat with it. The configured fleet API key becomes an enrollment-only bootstrap credential. No setup change is needed — existing devices re-enroll on their next heartbeat. Interoperates with both the CashPilot web UI and CashPilot-Desktop's fleet server. If the server ever rejects the per-worker key (HTTP 401), the app clears it and automatically re-enrolls on the next heartbeat. Heartbeats also keep working on the per-worker key even if the shared bootstrap key is later removed from Settings. + ## [0.1.0] - 2026-03-31 ### Added diff --git a/app/src/main/java/com/cashpilot/android/model/Heartbeat.kt b/app/src/main/java/com/cashpilot/android/model/Heartbeat.kt index c2be3a9..a63605c 100644 --- a/app/src/main/java/com/cashpilot/android/model/Heartbeat.kt +++ b/app/src/main/java/com/cashpilot/android/model/Heartbeat.kt @@ -47,3 +47,19 @@ data class AppStatus( @SerialName("net_rx_24h") val netRx24h: Long = 0, @SerialName("last_active") val lastActive: String? = null, ) + +/** + * Response from POST /api/workers/heartbeat. + * + * On this device's first (enrollment) heartbeat — and re-delivered on each + * subsequent heartbeat until the device confirms it — the server returns a + * per-worker key in [workerKey]. The client persists it and authenticates with + * it thereafter (the shared key becomes enrollment-only). [workerKey] is absent + * once the device is enrolled and confirmed. + */ +@Serializable +data class WorkerHeartbeatResponse( + val status: String = "", + @SerialName("worker_id") val workerId: Long? = null, + @SerialName("worker_key") val workerKey: String? = null, +) diff --git a/app/src/main/java/com/cashpilot/android/model/Settings.kt b/app/src/main/java/com/cashpilot/android/model/Settings.kt index 50c5ecf..4646ec8 100644 --- a/app/src/main/java/com/cashpilot/android/model/Settings.kt +++ b/app/src/main/java/com/cashpilot/android/model/Settings.kt @@ -7,4 +7,16 @@ data class Settings( val heartbeatIntervalSeconds: Int = 30, val enabledSlugs: Set = KnownApps.all.map { it.slug }.toSet(), val setupCompleted: Boolean = false, -) + /** + * Per-worker fleet key issued by the server on enrollment. Empty until this + * device has enrolled; once set it is used for auth instead of [apiKey] (the + * shared key becomes an enrollment-only bootstrap credential). + * + * Kept LAST so it does not shift the positional component order used by + * `Settings` destructuring in tests. + */ + val workerKey: String = "", +) { + /** The key to authenticate heartbeats with: our own once enrolled, else the shared bootstrap key. */ + val activeKey: String get() = workerKey.ifBlank { apiKey } +} diff --git a/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt b/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt index 43b456e..1362a75 100644 --- a/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt +++ b/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt @@ -15,8 +15,10 @@ import com.cashpilot.android.model.AppContainer import com.cashpilot.android.model.Settings import com.cashpilot.android.model.SystemInfo import com.cashpilot.android.model.WorkerHeartbeat +import com.cashpilot.android.model.WorkerHeartbeatResponse import com.cashpilot.android.util.SettingsStore import io.ktor.client.HttpClient +import io.ktor.client.call.body import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation @@ -71,7 +73,7 @@ class HeartbeatService : Service() { heartbeatJob = scope.launch { while (true) { val settings = SettingsStore.settings(applicationContext).first() - if (settings.serverUrl.isNotBlank() && settings.apiKey.isNotBlank()) { + if (settings.serverUrl.isNotBlank() && settings.activeKey.isNotBlank()) { sendHeartbeat(settings) } // Exponential backoff on consecutive failures (30s → 60s → 120s, max 5min) @@ -122,7 +124,7 @@ class HeartbeatService : Service() { val url = settings.serverUrl.trimEnd('/') + "/api/workers/heartbeat" val response: HttpResponse = httpClient.post(url) { contentType(ContentType.Application.Json) - bearerAuth(settings.apiKey) + bearerAuth(settings.activeKey) setBody(heartbeat) } @@ -130,6 +132,16 @@ class HeartbeatService : Service() { consecutiveFailures = 0 _lastHeartbeat.value = System.currentTimeMillis() _lastHeartbeatFailed.value = false + // Enrollment: on first contact (and re-delivered until we confirm it + // by using it) the server returns this device's own fleet key. Persist + // and adopt it, so subsequent heartbeats authenticate with our own key. + val body = runCatching { response.body() }.getOrNull() + if (body != null) { + settingsAfterHeartbeat(settings, body)?.let { updated -> + SettingsStore.update(applicationContext) { updated } + Log.i(TAG, "Enrolled: received and persisted this device's own fleet key") + } + } val runningCount = apps.count { it.running } updateNotification("$runningCount/${apps.size} apps running") } else { @@ -137,6 +149,10 @@ class HeartbeatService : Service() { _lastHeartbeatFailed.value = true Log.w(TAG, "Heartbeat rejected: HTTP ${response.status.value}") updateNotification("Server rejected heartbeat (${response.status.value})") + if (response.status.value == 401 && settings.workerKey.isNotBlank()) { + SettingsStore.update(applicationContext) { it.copy(workerKey = "") } + Log.i(TAG, "Auth rejected (401) — clearing per-worker key to re-enroll") + } } } catch (e: Exception) { consecutiveFailures++ @@ -198,5 +214,21 @@ class HeartbeatService : Service() { /** Whether the last heartbeat attempt failed. */ private val _lastHeartbeatFailed = MutableStateFlow(false) val lastHeartbeatFailed: StateFlow = _lastHeartbeatFailed.asStateFlow() + + /** + * The per-worker key to newly persist, given the currently stored key and the + * one the server returned on this heartbeat — or `null` if nothing should + * change (no key issued, blank, or unchanged). Pure, so it is unit-tested. + */ + fun keyToPersist(current: String, issued: String?): String? = + issued?.takeIf { it.isNotBlank() && it != current } + + /** + * The [Settings] to persist after a successful heartbeat, given the current + * settings and the parsed response body — or `null` if nothing should change. + * Pure, so the response→persist wiring is unit-tested without Robolectric. + */ + fun settingsAfterHeartbeat(settings: Settings, body: WorkerHeartbeatResponse): Settings? = + keyToPersist(settings.workerKey, body.workerKey)?.let { newKey -> settings.copy(workerKey = newKey) } } } diff --git a/app/src/main/java/com/cashpilot/android/util/SettingsStore.kt b/app/src/main/java/com/cashpilot/android/util/SettingsStore.kt index ca2e217..cde2b76 100644 --- a/app/src/main/java/com/cashpilot/android/util/SettingsStore.kt +++ b/app/src/main/java/com/cashpilot/android/util/SettingsStore.kt @@ -19,6 +19,7 @@ private val Context.dataStore: DataStore by preferencesDataStore(na object SettingsStore { private val SERVER_URL = stringPreferencesKey("server_url") private val API_KEY = stringPreferencesKey("api_key") + private val WORKER_KEY = stringPreferencesKey("worker_key") private val HEARTBEAT_INTERVAL = intPreferencesKey("heartbeat_interval") private val ENABLED_SLUGS = stringSetPreferencesKey("enabled_slugs") private val SETUP_COMPLETED = booleanPreferencesKey("setup_completed") @@ -28,6 +29,7 @@ object SettingsStore { Settings( serverUrl = prefs[SERVER_URL] ?: "", apiKey = prefs[API_KEY] ?: "", + workerKey = prefs[WORKER_KEY] ?: "", heartbeatIntervalSeconds = prefs[HEARTBEAT_INTERVAL] ?: 30, enabledSlugs = prefs[ENABLED_SLUGS] ?: KnownApps.all.map { it.slug }.toSet(), // Migration: mark as completed only if both URL and key were configured before this field existed @@ -41,6 +43,7 @@ object SettingsStore { val current = Settings( serverUrl = prefs[SERVER_URL] ?: "", apiKey = prefs[API_KEY] ?: "", + workerKey = prefs[WORKER_KEY] ?: "", heartbeatIntervalSeconds = prefs[HEARTBEAT_INTERVAL] ?: 30, enabledSlugs = prefs[ENABLED_SLUGS] ?: KnownApps.all.map { it.slug }.toSet(), setupCompleted = prefs[SETUP_COMPLETED] @@ -49,6 +52,7 @@ object SettingsStore { val updated = transform(current) prefs[SERVER_URL] = updated.serverUrl prefs[API_KEY] = updated.apiKey + prefs[WORKER_KEY] = updated.workerKey prefs[HEARTBEAT_INTERVAL] = updated.heartbeatIntervalSeconds prefs[ENABLED_SLUGS] = updated.enabledSlugs prefs[SETUP_COMPLETED] = updated.setupCompleted diff --git a/app/src/test/java/com/cashpilot/android/PerWorkerKeyTest.kt b/app/src/test/java/com/cashpilot/android/PerWorkerKeyTest.kt new file mode 100644 index 0000000..c8377cd --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/PerWorkerKeyTest.kt @@ -0,0 +1,89 @@ +package com.cashpilot.android + +import com.cashpilot.android.model.Settings +import com.cashpilot.android.model.WorkerHeartbeatResponse +import com.cashpilot.android.service.HeartbeatService +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** Per-worker fleet key: active-key selection, enrollment capture, response parsing. */ +class PerWorkerKeyTest { + private val json = Json { ignoreUnknownKeys = true } + + // --- Settings.activeKey ------------------------------------------------- + + @Test + fun `activeKey uses the shared api key before enrollment`() { + assertEquals("shared", Settings(apiKey = "shared", workerKey = "").activeKey) + } + + @Test + fun `activeKey prefers the per-worker key once enrolled`() { + assertEquals("own", Settings(apiKey = "shared", workerKey = "own").activeKey) + } + + // --- HeartbeatService.keyToPersist ------------------------------------- + + @Test + fun `keyToPersist adopts a newly issued key`() { + assertEquals("issued", HeartbeatService.keyToPersist(current = "", issued = "issued")) + } + + @Test + fun `keyToPersist ignores an unchanged key`() { + assertNull(HeartbeatService.keyToPersist(current = "own", issued = "own")) + } + + @Test + fun `keyToPersist ignores absent or blank keys`() { + assertNull(HeartbeatService.keyToPersist(current = "own", issued = null)) + assertNull(HeartbeatService.keyToPersist(current = "own", issued = "")) + assertNull(HeartbeatService.keyToPersist(current = "own", issued = " ")) + } + + // --- HeartbeatService.settingsAfterHeartbeat ---------------------------- + + @Test + fun `settingsAfterHeartbeat adopts a newly issued worker key`() { + val settings = Settings(apiKey = "shared", workerKey = "") + val body = WorkerHeartbeatResponse(status = "ok", workerKey = "issued") + assertEquals("issued", HeartbeatService.settingsAfterHeartbeat(settings, body)?.workerKey) + } + + @Test + fun `settingsAfterHeartbeat returns null when the worker key is unchanged, blank, or absent`() { + val settings = Settings(apiKey = "shared", workerKey = "own") + assertNull(HeartbeatService.settingsAfterHeartbeat(settings, WorkerHeartbeatResponse(status = "ok", workerKey = "own"))) + assertNull(HeartbeatService.settingsAfterHeartbeat(settings, WorkerHeartbeatResponse(status = "ok", workerKey = ""))) + assertNull(HeartbeatService.settingsAfterHeartbeat(settings, WorkerHeartbeatResponse(status = "ok", workerKey = null))) + } + + @Test + fun `settingsAfterHeartbeat compares against the current worker key, not the api key`() { + val settings = Settings(apiKey = "different", workerKey = "own") + val body = WorkerHeartbeatResponse(status = "ok", workerKey = "own") + assertNull(HeartbeatService.settingsAfterHeartbeat(settings, body)) + } + + // --- WorkerHeartbeatResponse deserialization --------------------------- + + @Test + fun `response parses worker_key and worker_id when present`() { + val r = json.decodeFromString( + """{"status":"ok","worker_id":1,"worker_key":"abc123"}""", + ) + assertEquals("abc123", r.workerKey) + assertEquals(1L, r.workerId) + } + + @Test + fun `response has null worker_key when absent and ignores unknown fields`() { + val r = json.decodeFromString( + """{"status":"ok","worker_id":2,"some_future_field":"ignored"}""", + ) + assertNull(r.workerKey) + assertEquals(2L, r.workerId) + } +} diff --git a/docs/AUTOPILOT-WORKLOG.md b/docs/AUTOPILOT-WORKLOG.md new file mode 100644 index 0000000..b8b768a --- /dev/null +++ b/docs/AUTOPILOT-WORKLOG.md @@ -0,0 +1,25 @@ +# Autopilot Worklog — per-worker keys for Android + Desktop-fleet compat + +Append-only. Newest at the bottom. Every "done" needs evidence (test/build/commit). + +--- + +### 2026-07-11 — kickoff +- Goal (docs/GOAL.md): per-worker fleet keys for **CashPilot-android** (client) + **CashPilot-Desktop** + fleet_server (server), interoperable with the web UI v1.0.0 protocol. +- Branches: android `feat/per-worker-keys`; desktop `feat/fleet-per-worker-keys` (off origin/main). +- Beads filed: + - android: CashPilot-android-btz (client enrollment, P1), -a5d (response model), -nvn (tests), -20v (changelog) + - desktop: CashPilot-Desktop-jet (fleet server enrollment, P1), -xkf (store+migration, P1), -10u (tests), -rxx (docs) +- Protocol contract (from web UI v1.0.0): heartbeat with own key once enrolled, else shared bootstrap key; + server returns `worker_key` once on enroll; reissue until confirmed; reject shared key for confirmed device. +- Next: implement android client (btz/a5d) then desktop server (jet/xkf), verify each. + +### 2026-07-11 — both sides implemented + PRs open +- Android (client): PR #37 — Settings.workerKey/activeKey, SettingsStore persist, WorkerHeartbeatResponse, + HeartbeatService capture via pure keyToPersist; v0.2.0; PerWorkerKeyTest. Beads btz/a5d/nvn/20v closed. + CI compile break (Settings destructuring order) fixed by moving workerKey last (commit bacf9f1). +- Desktop (server): PR #91 — store api_key_hash+key_confirmed + guarded migration; classifyFleetAuth + drives handleWorkerHeartbeat (enroll/reissue/confirm/reject); tests. Beads jet/xkf/10u/rxx closed. + Verified locally: go build/vet clean, go test -race ./... green. +- Next: confirm both CIs green, address CodeRabbit, then architect review + cancel. diff --git a/docs/GOAL.md b/docs/GOAL.md new file mode 100644 index 0000000..b77f878 --- /dev/null +++ b/docs/GOAL.md @@ -0,0 +1,33 @@ +# GOAL + +Started: 2026-07-11 + +## Directive (verbatim) + +that app. Also it should be compatible with cashpilot-desktop, so file beads there and start implementing extensively and thoroughly with ralph + +## Established context (from the preceding conversation) + +"That app" = **CashPilot-android** (the mobile heartbeat client). The CashPilot **web** UI already +shipped **per-worker fleet keys (v1.0.0)**: on a worker's first heartbeat the server issues it a unique +key (returned once as `worker_key`), which the client persists and uses thereafter; the shared +`CASHPILOT_API_KEY` becomes enrollment-only, and is rejected for a confirmed worker. There is a +re-delivery ("reissue") safety net so a dropped enrollment response can't lock a client out. + +**Goal:** bring **CashPilot-android** onto the same per-worker-key protocol as a client (mirror the +Docker worker's client side), AND make **CashPilot-Desktop** compatible — its `fleet_server.go` must +implement the server side of the same enrollment protocol (enroll → issue key → confirm → reject shared +key), so the desktop's fleet server and the android client interoperate. File beads in both repos. + +Client side (android, Kotlin/Ktor, DataStore): +- On heartbeat, use the persisted per-worker key if present, else the shared key (bootstrap). +- Capture `worker_key` from the heartbeat response, persist it (DataStore), use it thereafter. + +Server side (desktop, Go, fleet_server.go): +- Enrollment + per-worker key issuance/storage + confirm + reject-shared-once-confirmed + reissue, + matching the web UI's protocol so any client (android, docker worker) works against it. + +## Working rules +- Per-repo conventions: android = Gradle/Kotlin, `./gradlew test` + ktlint/detekt if present; desktop = + Go, `go build/vet/test -race`. Keep CI green. Conventional commits. PR workflow; never merge without + Sergio's approval. No AI attribution. Beads via `bd`, committed `.beads`, commit via PR. diff --git a/gradle.properties b/gradle.properties index 8dc1a7a..08abae3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,5 +2,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true -VERSION_NAME=0.1.3 -VERSION_CODE=103 +VERSION_NAME=0.2.0 +VERSION_CODE=200