feat(machine): on-machine QR-pairing wizard #68

Merged
padreug merged 6 commits from qr-pairing-wizard into dev 2026-06-24 22:40:16 +00:00
Owner

Closes the last consumer-side gap in the bunker migration (aiolabs/bitspire#52): an unpaired machine can now be paired on the machine by scanning a spire-seed QR, instead of only by provisioning VITE_SPIRE_SEED into .env.

Flow

Unpaired (fresh, or binding revoked/expired) → boot lands on unpaired → under Electron, App.vue renders PairingWizard instead of the static "Pairing Required" card. The operator shows spirekeeper's /pair QR to the camera; the wizard:

  1. captures + decodes via a PairingSource (src/services/pairing/),
  2. validates the scan parses as a spire-seed (ingestScannedSeed) — a stray QR is rejected with a hint and scanning resumes,
  3. persists it as VITE_SPIRE_SEED (state:save-spire-seed IPC) and relaunches (app:relaunch).

Pairing itself is not done in the wizard — relaunch lets the normal boot path (signer-resolver → connectNewSeed) redeem the one-shot token, so there's exactly one tested pairing path. Provisioning the seed up front still works and skips the wizard.

Capture abstraction

PairingSource is the seam so the wizard is agnostic to how the seed arrives:

  • QrPairingSource — camera + decode. Uses qr (paulmillr), chosen over the dormant/unmaintained jsqr: zero-dependency, auditable, dual MIT/Apache, actively maintained, and authored by the same person as the @noble/@scure crypto our nostr stack already trusts. Its qr/dom.js helper wraps getUserMedia + the per-frame decode loop.
  • NfcPairingSource — Web NFC scaffold; inert on the Sintra's Linux Electron (isAvailable() → false), there for the NFC pairing method the user flagged as plausible-future without reworking the wizard later.

Notes

  • A revoked/TTL-expired binding maps to the same unpaired wizard (re-pair = scan a fresh seed).
  • Browser dev (no Electron bridge) falls back to the static card — the wizard needs the persist + relaunch IPC.
  • Hardware caveat to confirm on-device: this assumes a getUserMedia webcam. If the unit's scanner is a HAL peripheral instead, only QrPairingSource changes — the seam, ingest, and UI are unaffected.

Verification

  • vue-tsc --noEmit clean; full machine build (vite + electron tsc + fund-atm bundle) green.
  • New ingestScannedSeed unit tests (invalid-seed / no-bridge / persist-failed / happy path); all 34 machine tests pass.

Commits

  1. feat: persist scanned spire-seed + signal unpaired state for wizard — IPC + NoPairingError → unpaired.
  2. feat: pairing-source abstraction + QR/NFC capture + seed ingest — src/services/pairing/ + qr dep + tests.
  3. feat: render QR-pairing wizard for unpaired machines — PairingWizard.vue + App.vue wiring.
  4. docs: document the on-machine QR-pairing wizard.

🤖 Generated with Claude Code

Closes the last consumer-side gap in the bunker migration (aiolabs/bitspire#52): an unpaired machine can now be paired **on the machine** by scanning a spire-seed QR, instead of only by provisioning `VITE_SPIRE_SEED` into `.env`. ## Flow Unpaired (fresh, or binding revoked/expired) → boot lands on `unpaired` → under Electron, App.vue renders `PairingWizard` instead of the static "Pairing Required" card. The operator shows spirekeeper's `/pair` QR to the camera; the wizard: 1. **captures + decodes** via a `PairingSource` (`src/services/pairing/`), 2. **validates** the scan parses as a spire-seed (`ingestScannedSeed`) — a stray QR is rejected with a hint and scanning resumes, 3. **persists** it as `VITE_SPIRE_SEED` (`state:save-spire-seed` IPC) and **relaunches** (`app:relaunch`). Pairing itself is **not** done in the wizard — relaunch lets the normal boot path (`signer-resolver` → `connectNewSeed`) redeem the one-shot token, so there's exactly one tested pairing path. Provisioning the seed up front still works and skips the wizard. ## Capture abstraction `PairingSource` is the seam so the wizard is agnostic to *how* the seed arrives: - **`QrPairingSource`** — camera + decode. Uses **`qr` (paulmillr)**, chosen over the dormant/unmaintained `jsqr`: zero-dependency, auditable, dual MIT/Apache, actively maintained, and authored by the same person as the `@noble`/`@scure` crypto our nostr stack already trusts. Its `qr/dom.js` helper wraps getUserMedia + the per-frame decode loop. - **`NfcPairingSource`** — Web NFC scaffold; inert on the Sintra's Linux Electron (`isAvailable()` → false), there for the NFC pairing method the user flagged as plausible-future without reworking the wizard later. ## Notes - A revoked/TTL-expired binding maps to the same `unpaired` wizard (re-pair = scan a fresh seed). - Browser dev (no Electron bridge) falls back to the static card — the wizard needs the persist + relaunch IPC. - Hardware caveat to confirm on-device: this assumes a `getUserMedia` webcam. If the unit's scanner is a HAL peripheral instead, only `QrPairingSource` changes — the seam, ingest, and UI are unaffected. ## Verification - `vue-tsc --noEmit` clean; full machine build (vite + electron tsc + fund-atm bundle) green. - New `ingestScannedSeed` unit tests (invalid-seed / no-bridge / persist-failed / happy path); all 34 machine tests pass. ## Commits 1. `feat: persist scanned spire-seed + signal unpaired state for wizard` — IPC + `NoPairingError` → `unpaired`. 2. `feat: pairing-source abstraction + QR/NFC capture + seed ingest` — `src/services/pairing/` + `qr` dep + tests. 3. `feat: render QR-pairing wizard for unpaired machines` — `PairingWizard.vue` + App.vue wiring. 4. `docs: document the on-machine QR-pairing wizard`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Foundation for the on-machine QR-pairing wizard (aiolabs/bitspire#52). An
unpaired ATM can now have a seed planted at runtime rather than only via
provisioning:

- electron IPC `state:save-spire-seed` writes VITE_SPIRE_SEED into the runtime
  .env (0600), and `app:relaunch` restarts the kiosk so the normal boot path
  (signer-resolver → connectNewSeed) does the actual bunker pairing. We
  deliberately do NOT pair in-renderer — persist + relaunch reuses the single,
  hardware-tested pairing path.
- signer-resolver throws a typed `NoPairingError` (distinct `.name`, survives
  the bundle boundary) when there's no seed and no binding, instead of a
  generic Error.
- init-error maps NoPairingError → `unpaired`, so the renderer can route a
  fresh machine to the interactive wizard (next commit) rather than a
  dead-end fault screen. Revoked/TTL bindings already map there too — re-pair
  is the same scan-a-fresh-seed flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The capture half of the QR-pairing wizard (aiolabs/bitspire#52), behind a
`PairingSource` seam so the wizard UI stays agnostic to how the seed arrives:

- `QrPairingSource` — camera capture + decode via `qr` (paulmillr). Chosen
  over the dormant, unmaintained `jsqr`: `qr` is zero-dependency, auditable,
  dual MIT/Apache, actively maintained, and authored by the same person as the
  `@noble`/`@scure` crypto our nostr stack already trusts. Its `qr/dom.js`
  helper wraps getUserMedia + the per-frame decode loop.
- `NfcPairingSource` — Web NFC scaffold; `isAvailable()` is false on the
  Sintra's Linux Electron, so it's inert until real NFC hardware lands (the
  user flagged NFC as a plausible future pairing method).
- `ingestScannedSeed` — validates the scan parses as a spire-seed (rejecting a
  stray QR), persists it, and relaunches. Covered by unit tests
  (invalid-seed / no-bridge / persist-failed / happy path).
- `availablePairingSources()` probes each source and returns the runnable ones
  in preference order (camera first).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wires the capture + ingest pieces into a screen (aiolabs/bitspire#52). When
the machine boots `unpaired` (fresh, or binding revoked/expired) and runs
under Electron, App.vue renders `PairingWizard` in place of the static
"Pairing Required" card.

The wizard probes available sources, shows the camera viewfinder, and on a
valid scan persists + relaunches. A stray/non-seed QR is rejected with a hint
and scanning resumes. NFC (when present) appears as an alternate source
button. Browser dev (no Electron bridge) still falls back to the static card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Note the wizard flow next to VITE_SPIRE_SEED + a dedicated Pairing section
(aiolabs/bitspire#52): unpaired → scan seed off camera → persist + relaunch →
normal boot pairs. Records the qr-over-jsqr choice rationale by reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adding `qr` (and dropping `jsqr`) changed pnpm-lock.yaml, invalidating the
fixed-output hash for the vendored pnpm store. Without this the NixOS build
of the ATM app fails at the FOD before activation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The camera pairing source decoded frames at the <video> element's CSS box
size (qr's readFrame default) rather than the intrinsic frame, and let the
stream stay at the panel-bound ~720p that frontalCamera negotiates from the
screen size. On the 1280x800 kiosk with a fixed-focus 5MP scan camera that
left far too few pixels-per-module for a dense spire-seed QR, so a centered,
in-square code never decoded.

Decode the intrinsic frame (readFrame fullSize=true) and pin a deliberate
1280x960 capture via applyConstraints. lamassu-machine caps QR scanning at
640x480 for decode speed (megapixels only slow the per-frame decode); our
seed QR is denser than a lightning invoice, so 1280x960 balances
pixels-per-module against latency and keeps auto-exposure from blowing out a
frame-filling phone screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
padreug deleted branch qr-pairing-wizard 2026-06-24 22:40:16 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/bitspire!68
No description provided.