From 430bc2f371ad8120cad6d26ad9870fbb836169e5 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 20 May 2026 18:32:01 +0200 Subject: [PATCH] Add spec for watch multi-profile build mechanism Three named profiles (brummel-dev, brummel-minerva, krey-minerva) with hard-locked watch-serial whitelist in run.sh, to enable safe parallel testing on Brummel's and Krey's watches against dev and minerva servers. refs #3 --- .../2026-05-20-watch-multi-profile-design.md | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 docs/specs/2026-05-20-watch-multi-profile-design.md diff --git a/docs/specs/2026-05-20-watch-multi-profile-design.md b/docs/specs/2026-05-20-watch-multi-profile-design.md new file mode 100644 index 0000000..1951ea2 --- /dev/null +++ b/docs/specs/2026-05-20-watch-multi-profile-design.md @@ -0,0 +1,341 @@ +# Watch Multi-Profile Build Mechanism + +**Status:** Draft +**Date:** 2026-05-20 +**Related issues:** #1 (BLOCKER, prerequisite), #3 (this spec), #4 (follow-up hardening), #5 (long-term replacement) + +## Context + +The Wear OS app today bakes a single `SERVER_URL` and `API_KEY` into the APK at +build time via `BuildConfig` fields (see `clients/wearos/app/build.gradle.kts`). +The values come from one of three sources in order: + +1. Gradle CLI property (`-Pdoctate.serverUrl=…`, `-Pdoctate.apiKey=…`) +2. `clients/wearos/local.properties` (gitignored, sticky default) +3. Hardcoded fallback (`http://10.0.2.2:3000`, `MISSING_API_KEY`) + +`clients/wearos/run.sh` injects the Gradle properties for the `watch` target +based on the `DOCTATE_DEV_SERVER` and `DOCTATE_API_KEY` environment variables. + +This mechanism handles a single developer talking to a single backend. It does +not cover the three scenarios that the project now needs: + +1. **Brummel-Watch ↔ dev server** (Laptop, `http://192.168.178.27:3000`) — + for server and watch co-development. +2. **Brummel-Watch ↔ minerva** (`http://minerva.lan:3000`) — for deployment + smoke-testing on the alpha host. +3. **Krey-Watch ↔ minerva** — for Dr. Krey's clinical alpha use; this is + **production data** (real patient dictations). + +Switching today means manually editing `local.properties` or juggling env +vars, with no safeguard against the worst-case mistake: installing the wrong +profile on the wrong watch. Krey's production data is the failure mode that +forces an explicit defensive design. + +## Goals + +- **Three named profiles** addressable by short, explicit names: + `brummel-dev`, `brummel-minerva`, `krey-minerva`. +- **One-command switch**: `./run.sh watch install` builds and installs + with the chosen profile in one step. +- **Hard refusal** when a profile targets a watch that is not in its allow-list + — this is the load-bearing safeguard against cross-contamination. +- **Zero external runtime dependencies** for `run.sh`: bash + adb + gradle only. + No Python, no `jq`, no `yq`. +- **Single source of truth** per profile: server URL, API key, allowed ADB + serials live in one place each, gitignored. + +## Non-Goals + +- **No self-service install for Krey** — APK deployment is physical via ADB on + Brummel's laptop. A pairing flow (Issue #5) is explicitly out of scope. +- **No release-signed APKs / no signed artifact handoff** — debug builds over + ADB are sufficient for the current testing tier. +- **No coexistence on a single watch** — one APK per watch, profile switches + reinstall. Coexistence via `applicationIdSuffix` was considered and rejected + as unneeded. +- **No server-side validation that an api_key is not a bcrypt hash** — that is + Issue #4 and lives in a separate follow-up. +- **No runtime profile selection on the watch** — build-time only; runtime + selection would require a full pairing/settings UI (Issue #5). + +## Design + +### Profile data structure + +One file per profile under `clients/wearos/profiles.d/.sh`, sourced as +bash. All profile files are gitignored; a sibling `*.sh.example` is checked in +as a format template. + +```bash +# clients/wearos/profiles.d/brummel-dev.sh (gitignored) +SERVER_URL="http://192.168.178.27:3000" +API_KEY="" +ALLOWED_SERIALS=( + "adb-XXXXXXXX-yyyy._adb-tls-connect._tcp" +) +``` + +```bash +# clients/wearos/profiles.d/brummel-minerva.sh (gitignored) +SERVER_URL="http://minerva.lan:3000" +API_KEY="<plaintext key from minerva's users.toml under slug 'brummel'>" +ALLOWED_SERIALS=( + "adb-XXXXXXXX-yyyy._adb-tls-connect._tcp" +) +``` + +```bash +# clients/wearos/profiles.d/krey-minerva.sh (gitignored) +SERVER_URL="http://minerva.lan:3000" +API_KEY="<plaintext key from minerva's users.toml under slug 'krey'>" +ALLOWED_SERIALS=( + "adb-ZZZZZZZZ-wwww._adb-tls-connect._tcp" +) +``` + +`.gitignore` rule: + +``` +clients/wearos/profiles.d/*.sh +!clients/wearos/profiles.d/*.sh.example +``` + +A bash-source format was chosen over TOML/JSON to keep `run.sh` zero-dependency. +The risk of code execution via a profile file is acceptable because the file is +single-user, gitignored, and authored by the developer themselves. A move to +TOML would become attractive if profiles ever come from elsewhere or the user +base grows. + +`ALLOWED_SERIALS` is an array, not a scalar, so a future watch swap can list +both old and new serials during a transition without a schema change. + +### CLI form + +``` +./run.sh watch <profile> <subcmd> [args] +./run.sh emulator <subcmd> [args] # unchanged, no profile +``` + +The profile name is a required second positional argument for the `watch` +target. The `emulator` target keeps its existing hardcoded loopback config. + +Existing subcommands (`install`, `build`, `start`, `stop`, `logcat`, `shot`, +`test`) all accept the profile in the new position. + +### run.sh internals + +New helper functions: + +```bash +load_profile() { + local name="$1" + local file="$SCRIPT_DIR/profiles.d/$name.sh" + if [[ ! -f "$file" ]]; then + local available + available="$(cd "$SCRIPT_DIR/profiles.d" 2>/dev/null && \ + ls *.sh 2>/dev/null | sed 's/\.sh$//' | tr '\n' ' ')" + [[ -z "$available" ]] && available="<none — create one in profiles.d/>" + die "Unknown profile '$name'. Available: ${available}" + fi + # Reset to detect missing fields after sourcing. + SERVER_URL=""; API_KEY=""; ALLOWED_SERIALS=() + # shellcheck disable=SC1090 + source "$file" + [[ -n "$SERVER_URL" ]] || die "Profile '$name': SERVER_URL missing" + [[ -n "$API_KEY" ]] || die "Profile '$name': API_KEY missing" + [[ "${#ALLOWED_SERIALS[@]}" -gt 0 ]] || die "Profile '$name': ALLOWED_SERIALS empty" +} + +verify_serial_allowed() { + local serial="$1" + local s + for s in "${ALLOWED_SERIALS[@]}"; do + [[ "$s" == "$serial" ]] && return 0 + done + die "Watch serial '$serial' is NOT in ALLOWED_SERIALS for profile '$DOCTATE_PROFILE'. +Allowed: ${ALLOWED_SERIALS[*]} +This refusal protects against installing the wrong key on the wrong watch +(e.g. Brummel-key on Krey's watch → patient data goes to dev server)." +} +``` + +Updated `apply_target` for the `watch` target: + +```bash +case "$DOCTATE_TARGET" in + watch) + [[ -n "$DOCTATE_PROFILE" ]] || die \ + "Watch target requires a profile. Usage: ./run.sh watch <profile> <subcmd>" + load_profile "$DOCTATE_PROFILE" + if [[ -z "$ADB_SERIAL" ]]; then + resolve_watch_serial + ADB_SERIAL="$RESOLVED_SERIAL" + fi + verify_serial_allowed "$ADB_SERIAL" + GRADLE_PROPS+=( + "-Pdoctate.serverUrl=$SERVER_URL" + "-Pdoctate.apiKey=$API_KEY" + "-Pdoctate.profileName=$DOCTATE_PROFILE" + ) + info "Target: watch (profile=$DOCTATE_PROFILE, serial=$ADB_SERIAL, server=$SERVER_URL)" + ;; + emulator) + # existing logic, unchanged + ;; +esac +``` + +The `verify_serial_allowed` check runs BEFORE Gradle is invoked, so a mismatch +fails fast without burning a minute-long build. + +Argument-parsing changes: the second positional after `watch` becomes the +profile name; subcmd shifts to the third position. The `emulator` target keeps +the second-positional-is-subcmd shape. + +Removed (breaking change inside `run.sh`, no callers outside): + +- `DOCTATE_DEV_SERVER` env var and `DEFAULT_DEV_SERVER` constant +- `DOCTATE_API_KEY` env var +- `doctate.apiKey=…` line in `local.properties` (the file becomes empty or + deleted; profile data is canonical) + +Help-text block at the top of `run.sh` is rewritten to document the new form. + +### BuildConfig and visual badge (nice-to-have) + +`clients/wearos/app/build.gradle.kts` resolves two new build config fields: + +```kotlin +val profileName = resolveProp("doctate.profileName", "unknown") +val isDevProfile = profileName.endsWith("-dev") + +buildConfigField("String", "PROFILE_NAME", "\"$profileName\"") +buildConfigField("boolean", "IS_DEV_PROFILE", "$isDevProfile") + +manifestPlaceholders["appLabel"] = if (isDevProfile) "Doctate (Dev)" else "Doctate" +``` + +`AndroidManifest.xml` uses the placeholder: + +```xml +<application android:label="${appLabel}" ...> +``` + +A `DevBadge` composable in `presentation/components/DevBadge.kt` renders a small +red "DEV" tag, but only when `BuildConfig.IS_DEV_PROFILE` is true: + +```kotlin +@Composable +fun DevBadge(modifier: Modifier = Modifier) { + if (!BuildConfig.IS_DEV_PROFILE) return + Text( + text = "DEV", + color = Color(0xFFFF5252), + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold), + modifier = modifier + .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(4.dp)) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) +} +``` + +Mounted in `CaseListScreen` (top-right) and `RecordingScreen` (top-right next to +the `mm:ss` counter). Production profiles render no badge — Krey's UI stays +clean. + +This visual layer is classified as nice-to-have. The hard-lock in `run.sh` +prevents the worst-case misinstall regardless; the badge protects against the +softer case where Brummel forgets which profile is currently installed. + +## Prerequisites + +These are external to the Watch code but must be done before the watch profiles +work end-to-end. + +### V1 — Resolve Issue #1 (BLOCKER) + +Edit `/opt/stacks/doctate-server/config/users.toml` on minerva: replace bcrypt +hashes in `api_key` fields with fresh plaintext values (one per user, e.g. +`openssl rand -base64 24`). Reload the container. + +The server's auth path (`server/src/auth.rs:52`) does a plaintext HashMap +lookup; bcrypt-shaped values in `api_key` never match the plaintext the watch +sends, producing silent 401s. + +### V2 — Add Krey to minerva's users.toml + +Create the `krey` user entry on minerva with: + +- `slug = "krey"` +- `display_name = "Dr. Krey"` (or her preferred form) +- `api_key = "<fresh plaintext, openssl rand -base64 24>"` +- `web_password = "<bcrypt hash via the hash-password CLI>"` +- `role = "doctor"` (not admin — Krey should not see reset/purge controls) + +### V3 — Collect ADB serials + +For each watch in turn: + +1. Pair via `./run.sh connect <host:port>` (Pixel Watch over ADB-WiFi). +2. List the attached devices via `./run.sh devices` and copy the watch's + serial, which has the shape `adb-XXXXXXXX-yyyy._adb-tls-connect._tcp`. +3. Paste it into the corresponding profile file's `ALLOWED_SERIALS` array. + +A serial can appear in more than one profile (e.g. Brummel's watch is allowed +under both `brummel-dev` and `brummel-minerva`). Krey's serial appears only +under `krey-minerva`. + +## Roll-out + +```mermaid +graph TD + V1[V1: minerva users.toml plaintext keys<br/>= Issue #1 fix] --> V2 + V2[V2: add Krey user to minerva] --> S1 + V3[V3: collect ADB serials of both watches] --> S1 + + S1[S1: profiles.d/ mechanism in run.sh] --> S2 + S2[S2: create three profile files<br/>brummel-dev, brummel-minerva, krey-minerva] --> S3 + S3[S3: BuildConfig PROFILE_NAME + IS_DEV_PROFILE<br/>+ manifest appLabel] --> S4 + S4[S4: visual DEV badge (optional)] --> S5 + S5[S5: smoke-test matrix] +``` + +V1, V2, V3 can run in parallel. S1–S5 are sequential. S4 may be deferred +without blocking the rest. + +## Smoke-test matrix + +After implementation, run each row once and confirm the expected outcome: + +| Command | Watch attached | Expected | +|---|---|---| +| `./run.sh watch brummel-dev install` | Brummel | Build + install, app talks to laptop IP, `/api/oneliners` → 200 | +| `./run.sh watch brummel-dev install` | Krey | Hard-refuse with allowed_serials mismatch message | +| `./run.sh watch brummel-minerva install` | Brummel | App talks to `minerva.lan:3000`, 200 | +| `./run.sh watch krey-minerva install` | Krey | App talks to `minerva.lan:3000` under slug `krey`, 200 | +| `./run.sh watch krey-minerva install` | Brummel | Hard-refuse | +| `./run.sh watch unknown-profile install` | any | Error listing available profiles | +| `./run.sh watch brummel-dev` | any | Error: subcommand missing | +| `./run.sh emulator install` | (emulator) | Unchanged behavior, no profile required | + +Visual checks for the optional badge layer: + +- Brummel-Watch with `brummel-dev` installed: launcher shows "Doctate (Dev)", + CaseListScreen and RecordingScreen show red "DEV" badge. +- Brummel-Watch with `brummel-minerva` installed: launcher shows "Doctate", + no badge anywhere. +- Krey-Watch with `krey-minerva` installed: launcher shows "Doctate", + no badge anywhere. + +## Out of scope (deliberate) + +- **Issue #4 (Server sanity-check for bcrypt-shaped api_key)** — natural + follow-up after V1, before S1. Tracked separately. Same risk surface; a + single PR would mix concerns. +- **Issue #5 (Watch pairing flow)** — the principled long-term replacement + for build-time profile baking. Out of reach for this spec. +- **APK distribution to Krey via filesharing / release signing** — not needed + while Brummel installs physically via ADB. +- **Theme tinting / boot splash per profile** — judged unnecessary given the + launcher label and optional badge already cover the visibility need.