Skip to content

feat: a way to actually turn pairing on - #111

Merged
GeiserX merged 1 commit into
mainfrom
feat/pairing-settings-form
Aug 5, 2026
Merged

feat: a way to actually turn pairing on#111
GeiserX merged 1 commit into
mainfrom
feat/pairing-settings-form

Conversation

@GeiserX

@GeiserX GeiserX commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Pairing shipped in #108 with no way to configure it. The config field existed, the keychain storage existed, the heartbeat loop ran on startup — and no screen rendered an input. The only way to pair was to hand-edit config.json and write a keychain entry.

Working code nobody can reach is the same defect as code nobody wired up — the exact mistake I'd just finished fixing on Android, where a whole earnings pipeline shipped unreferenced.

Two fields in the existing form, not the rewrite

Desktop-806 (the 1641-line frontend → Preact) is a separate and much larger change. Gating a shipped feature's only entry point on it would leave that feature unreachable for however long the rewrite takes.

Three things that needed care

Empty is meaningful here, unlike every other field in SaveSettings. Standalone is the supported default, so "" means unpair. The surrounding fields all key on != "" — copying that pattern would have made unpairing impossible. This keys on presence instead.

Repointing at a different server discards the per-worker key. That credential belongs to the server that issued it: presenting it to another one fails authentication with no obvious cause, and keeping it leaves a live secret for a machine you've stopped talking to. Saving the same URL keeps it, so an unrelated settings save cannot silently re-enrol the machine — both directions are tested.

The key goes to the keychain, not config.json — with a test asserting the token does not appear in that file. The display value round-trips like the existing fleet key rather than inventing a mask-and-detect scheme no other secret here uses; consistency beats a bespoke convention for one field.

Pairing also takes effect immediatelystartUpstream stops any previous loop first, so this cannot leave two loops reporting under one identity. The fleet listener still needs a restart, and still says so.

The harness builds the real graph

SaveSettings goes through a.ready(), which needs cfg, catalog, store, runtime and services. The test harness now constructs all of them, so these tests drive the real settings path. Carving out a narrower helper to make the tests easy would have left the path users actually hit untested.

Evidence

Full Go suite green, go vet and gofmt clean. Four negative controls fire:

Mutation
keep the old worker key on a repoint 4 fail
treat an empty URL as no-change 4 fail
drop the fields from the form 5 fail
never write the key to the keychain 2 fail

Frontend typechecked with the real compiler (tsc --noEmit in Docker on the build host). Worth noting: npx tsc in this repo silently installs a bogus tsc@2.0.4 package that typechecks nothing and exits 0 — my first "typecheck passed" was meaningless.

Summary by CodeRabbit

  • New Features
    • Added settings for pairing with an upstream server using a server URL and pairing key.
    • Pairing changes now take effect immediately after saving.
    • Clearing the server URL unpairs the application and returns it to standalone mode.
    • Pairing credentials are securely stored and managed when switching servers.
    • Added environment-variable support for configuring upstream connection details.

Pairing shipped in #108 with no way to configure it. The config field existed,
the keychain storage existed, the heartbeat loop ran on startup -- and no screen
rendered an input, so the only way to pair was to hand-edit config.json and
write a keychain entry. Working code nobody can reach is the same defect as code
nobody wired up, which is the exact mistake this loop had just finished fixing
on Android.

Two fields in the existing settings form. Not the Preact rewrite (bead 806) --
that is a separate, much larger change, and gating a shipped feature's only
entry point on it would leave it unreachable for however long that takes.

THREE THINGS THAT NEEDED CARE:

EMPTY IS MEANINGFUL HERE, unlike every other field in SaveSettings. Standalone is
the supported default, so "" means unpair -- the surrounding fields all key on
`!= ""`, and copying that pattern would have made unpairing impossible.

REPOINTING AT A DIFFERENT SERVER DISCARDS THE PER-WORKER KEY. That credential
belongs to the server that issued it; presenting it to another one fails
authentication with no obvious cause, and keeping it leaves a live secret for a
machine the user has stopped talking to. Saving the SAME url keeps it, so an
unrelated settings save cannot silently re-enrol the machine.

THE KEY GOES TO THE KEYCHAIN, not config.json, with a test asserting the token
does not appear in that file. The display value round-trips like the existing
fleet key rather than inventing a mask-and-detect scheme no other secret here
uses -- consistency beats a bespoke convention for one field.

Pairing takes effect immediately (startUpstream stops any previous loop first,
so this cannot leave two loops reporting under one identity), unlike the fleet
listener, which still needs a restart and says so.

The test harness now builds the full app graph -- catalog, runtime, services --
because SaveSettings goes through a.ready(). Carving out a narrower helper to
make the tests easy would have left the path users actually hit untested.

Four negative controls fire: keeping the old worker key on a repoint, treating
an empty URL as no-change, dropping the fields from the form, and never writing
the key to the keychain.

Frontend typechecked with the real compiler (tsc --noEmit in Docker on the build
host) -- `npx tsc` here silently installs a bogus `tsc@2.0.4` package that
typechecks nothing.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Settings now support an upstream pairing URL and enrollment key. Saving settings trims and persists the URL, securely stores the key, handles unpairing, resets or retains worker keys as needed, and restarts upstream reporting. Frontend mappings and integration tests cover the flow.

Changes

Upstream pairing settings

Layer / File(s) Summary
Pairing settings state and storage
app.go, upstream_client.go
Settings expose the upstream URL and enrollment key. Persistence supports pairing, unpairing, worker-key reset or retention, and secure enrollment-key storage.
Form wiring and reporting restart
frontend/src/main.ts, app.go
Frontend mappings accept the upstream URL and key. Saving settings restarts upstream reporting immediately.
Pairing settings integration tests
upstream_client_test.go
Tests cover form fields, standalone guidance, URL normalization, unpairing, worker-key behavior, complete application wiring, and secure key storage.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes enabling the existing pairing feature through the settings form.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/pairing-settings-form

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@GeiserX
GeiserX merged commit 44bd0f7 into main Aug 5, 2026
3 of 4 checks passed
@GeiserX
GeiserX deleted the feat/pairing-settings-form branch August 5, 2026 20:40
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 60.00000% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.62%. Comparing base (d80e88b) to head (8bdcc87).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
upstream_client.go 60.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #111      +/-   ##
==========================================
- Coverage   73.64%   73.62%   -0.02%     
==========================================
  Files          17       17              
  Lines        3612     3617       +5     
==========================================
+ Hits         2660     2663       +3     
- Misses        760      761       +1     
- Partials      192      193       +1     
Files with missing lines Coverage Δ
upstream_client.go 32.25% <60.00%> (+1.57%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app.go`:
- Around line 702-727: Make the pairing update flow in the settings handler
failure-atomic across SetUpstreamWorkerKey, SetUpstreamEnrolmentKey, and
a.cfg.Save(cfg): stage or record the previous URL and secret values, persist
cfg, and roll back every secret/config mutation if any later operation fails.
Preserve existing clearing behavior when switching upstreamUrl and updating
upstreamKey, and add a regression test covering a.cfg.Save failure that verifies
the prior pairing state remains intact.

In `@upstream_client.go`:
- Around line 200-204: Update upstreamEnrolmentKey and the settings SaveSettings
flow to distinguish a missing pairing key from an unreadable keychain read,
using an explicit unavailable state instead of returning an empty string for
failures. Preserve the stored upstreamKey during unrelated settings saves when
the key is unavailable, and only omit or clear it when the key is intentionally
absent or secret storage is readable.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: abe92611-c02f-49bf-902d-6e616af6bdcf

📥 Commits

Reviewing files that changed from the base of the PR and between d80e88b and 8bdcc87.

📒 Files selected for processing (4)
  • app.go
  • frontend/src/main.ts
  • upstream_client.go
  • upstream_client_test.go

Comment thread app.go
Comment on lines +702 to +727
if value, present := values["upstreamUrl"]; present {
next := strings.TrimRight(strings.TrimSpace(value), "/")
if next != cfg.UpstreamURL {
// Repointing at a DIFFERENT server must discard the per-worker key
// the previous one issued. It is that server's credential and means
// nothing to the new one; presenting it would fail authentication
// with no obvious cause, and keeping it around is a stale secret for
// a machine the user has stopped talking to.
if err := config.SetUpstreamWorkerKey(a.cfg.AppDir(), ""); err != nil {
return SettingsState{}, fmt.Errorf("clearing the previous worker key: %w", err)
}
a.upstream.mu.Lock()
a.upstream.workerKey = ""
a.upstream.mu.Unlock()
}
cfg.UpstreamURL = next
}
if value, present := values["upstreamKey"]; present {
// Stored verbatim, including empty -- clearing the field is how a user
// unpairs the credential. Like the existing fleet key, the real value
// round-trips to the form (rendered type="password"); this file does not
// invent a mask-and-detect scheme that no other secret here uses.
if err := config.SetUpstreamEnrolmentKey(a.cfg.AppDir(), strings.TrimSpace(value)); err != nil {
return SettingsState{}, fmt.Errorf("storing the pairing key: %w", err)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make pairing updates failure-atomic.

Lines 710 and 724 mutate secret storage before Line 751 persists cfg. If a.cfg.Save(cfg) fails, the old UpstreamURL can remain configured after the worker key was deleted or the enrolment key was replaced. The next upstream start then uses inconsistent pairing state.

Coordinate config and secret updates with rollback or staged writes. Add a regression test for a config-save failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app.go` around lines 702 - 727, Make the pairing update flow in the settings
handler failure-atomic across SetUpstreamWorkerKey, SetUpstreamEnrolmentKey, and
a.cfg.Save(cfg): stage or record the previous URL and secret values, persist
cfg, and roll back every secret/config mutation if any later operation fails.
Preserve existing clearing behavior when switching upstreamUrl and updating
upstreamKey, and add a regression test covering a.cfg.Save failure that verifies
the prior pairing state remains intact.

Comment thread upstream_client.go
Comment on lines +200 to +204
func (a *App) upstreamEnrolmentKey() string {
key, err := config.UpstreamEnrolmentKey(a.cfg.AppDir())
if err != nil {
return ""
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline frontend/src/main.ts --items all --type function
rg -n -C 10 'function saveSettingsFromForm|function renderEnvSetting|envInputName|upstreamKey|SaveSettings\(' frontend/src/main.ts

Repository: GeiserX/CashPilot-Desktop

Length of output: 8499


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files matching upstream_client.go and app.go:\n'
fd -a '^(upstream_client|app)\.go$' . | sed 's#^\./##'

printf '\nupstream_client.go outline:\n'
ast-grep outline upstream_client.go --items all --view expanded || true

printf '\nRelevant upstream_client.go sections:\n'
rg -n -C 12 'func \(a \*App\) upstreamEnrolmentKey|upstreamEnrolmentKey|SaveSettings|func .*SaveSettings' upstream_client.go
sed -n '1,280p' upstream_client.go

printf '\nRelevant app.go sections around SaveSettings and SettingsState helpers:\n'
rg -n -C 8 'func .*SaveSettings|SettingsState|CurrentSettings|currentSettings|enrolment|UpstreamEnrolmentKey|upstreamKey' app.go
sed -n '1,260p' app.go

Repository: GeiserX/CashPilot-Desktop

Length of output: 27232


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Inspect config.UpstreamEnrolmentKey implementation if present.
rg -n -C 6 'func UpstreamEnrolmentKey|UpstreamEnrolmentKey|masterKey|GetCredentials|RenameCredentials|DeleteCredentials' internal/config internal/store app.go upstream_client.go

Repository: GeiserX/CashPilot-Desktop

Length of output: 13601


Preserve unreadable pairing keys.

upstreamEnrolmentKey returns "" for both a missing key and keychain read failure. The settings form submits that displayed value as upstreamKey, and SaveSettings stores empty verbatim; an unrelated settings save can delete the stored pairing key.

Use an explicit unavailable state while keychain reads fail, and only omit/clear upstreamKey when the key is intentionally missing or secret storage becomes readable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@upstream_client.go` around lines 200 - 204, Update upstreamEnrolmentKey and
the settings SaveSettings flow to distinguish a missing pairing key from an
unreadable keychain read, using an explicit unavailable state instead of
returning an empty string for failures. Preserve the stored upstreamKey during
unrelated settings saves when the key is unavailable, and only omit or clear it
when the key is intentionally absent or secret storage is readable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant