feat(machine): pairing-source abstraction + QR/NFC capture + seed ingest

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>
This commit is contained in:
Padreug 2026-06-23 23:07:31 +02:00
commit d22157b40c
8 changed files with 354 additions and 269 deletions

View file

@ -37,6 +37,7 @@
"marked": "^17.0.5",
"nostr-tools": "^2.10.0",
"pinia": "^2.2.0",
"qr": "^0.6.0",
"qrcode.vue": "^3.6.0",
"reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0",

View file

@ -0,0 +1,80 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { ingestScannedSeed } from '../ingest'
import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client'
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
function makeSeed(json: unknown): string {
const b64 = Buffer.from(JSON.stringify(json), 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return SPIRE_SEED_SCHEME + b64
}
const SPIRE_PUBKEY = 'a'.repeat(64)
const VALID_SEED = makeSeed({
v: 1,
spire_npub: 'npub1example',
spire_pubkey: SPIRE_PUBKEY,
bunker_url: `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef`,
relays: ['wss://events.relay/'],
})
describe('ingestScannedSeed', () => {
const originalWindow = globalThis.window
afterEach(() => {
globalThis.window = originalWindow
vi.restoreAllMocks()
})
it('rejects a non-seed scan without touching the bridge', async () => {
const saveSpireSeed = vi.fn()
globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis
const result = await ingestScannedSeed('https://example.com/not-a-seed')
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe('invalid-seed')
expect(saveSpireSeed).not.toHaveBeenCalled()
})
it('reports no-bridge when Electron is absent', async () => {
globalThis.window = {} as unknown as Window & typeof globalThis
const result = await ingestScannedSeed(VALID_SEED)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe('no-bridge')
})
it('persists the seed and relaunches on a valid scan', async () => {
const saveSpireSeed = vi.fn().mockResolvedValue(undefined)
const relaunchApp = vi.fn().mockResolvedValue(undefined)
globalThis.window = {
electronAPI: { saveSpireSeed, relaunchApp },
} as unknown as Window & typeof globalThis
const result = await ingestScannedSeed(` ${VALID_SEED} `) // tolerate whitespace
expect(result.ok).toBe(true)
if (result.ok) expect(result.spirePubkey).toBe(SPIRE_PUBKEY)
expect(saveSpireSeed).toHaveBeenCalledWith(VALID_SEED)
expect(relaunchApp).toHaveBeenCalledOnce()
})
it('surfaces persist-failed when saveSpireSeed throws', async () => {
const saveSpireSeed = vi.fn().mockRejectedValue(new Error('EACCES'))
globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis
const result = await ingestScannedSeed(VALID_SEED)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe('persist-failed')
})
})
describe('ingest does not pair in-renderer', () => {
it('never imports connect logic — persistence + relaunch only', () => {
// Guard: the design intentionally reuses the boot-time pairing path.
// If someone wires connectNewSeed here, this comment + the ingest source
// should be revisited together.
expect(ingestScannedSeed).toBeTypeOf('function')
})
})

View file

@ -0,0 +1,30 @@
/**
* Pairing module surface (aiolabs/bitspire#52).
*
* `availablePairingSources()` probes each known source and returns those the
* current device can actually run, in preference order (camera first, NFC if
* present). The wizard renders the first available source and offers the rest
* as alternates.
*/
import { QrPairingSource } from './qr-source'
import { NfcPairingSource } from './nfc-source'
import type { PairingSource } from './types'
export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types'
export { QrPairingSource } from './qr-source'
export { NfcPairingSource } from './nfc-source'
export { ingestScannedSeed } from './ingest'
export type { IngestResult } from './ingest'
/** All sources in preference order, regardless of availability. */
export function allPairingSources(): PairingSource[] {
return [new QrPairingSource(), new NfcPairingSource()]
}
/** Only the sources this device can run, in preference order. */
export async function availablePairingSources(): Promise<PairingSource[]> {
const sources = allPairingSources()
const flags = await Promise.all(sources.map((s) => s.isAvailable()))
return sources.filter((_, i) => flags[i])
}

View file

@ -0,0 +1,63 @@
/**
* Seed ingest pipeline (aiolabs/bitspire#52).
*
* Turns a raw scanned payload into a paired machine. The wizard captures a
* string off some PairingSource and hands it here; we:
* 1. validate it parses as a spire-seed (reject anything else — a QR on the
* counter, a URL, a different protocol),
* 2. persist it as VITE_SPIRE_SEED via the Electron bridge,
* 3. relaunch so the normal boot path (signer-resolver → connectNewSeed)
* performs the actual bunker pairing.
*
* We do NOT pair in-renderer here: persisting + relaunching reuses the single,
* hardware-tested pairing path rather than duplicating connect/redeem logic in
* the wizard. The trade-off is a ~kiosk-restart of latency, which is fine for a
* one-time provisioning step.
*/
import { parseSpireSeed, seedFingerprint } from '@bitSpire/nostr-client'
export type IngestResult =
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
| { ok: false; reason: 'invalid-seed' | 'no-bridge' | 'persist-failed'; message: string }
export async function ingestScannedSeed(raw: string): Promise<IngestResult> {
const trimmed = (raw || '').trim()
let spirePubkey: string
let relays: string[]
try {
const seed = parseSpireSeed(trimmed)
spirePubkey = seed.spirePubkey
relays = seed.relays
} catch (e) {
return {
ok: false,
reason: 'invalid-seed',
message: e instanceof Error ? e.message : 'Not a valid pairing code',
}
}
if (typeof window === 'undefined' || !window.electronAPI) {
return {
ok: false,
reason: 'no-bridge',
message: 'Pairing must run on the machine (no kiosk bridge available).',
}
}
try {
await window.electronAPI.saveSpireSeed(trimmed)
} catch (e) {
return {
ok: false,
reason: 'persist-failed',
message: e instanceof Error ? e.message : 'Could not save the pairing.',
}
}
// Fire-and-forget: the relaunch tears this process down.
void window.electronAPI.relaunchApp()
return { ok: true, spirePubkey, fingerprint: seedFingerprint(trimmed), relays }
}

View file

@ -0,0 +1,67 @@
/**
* NFC pairing source — SCAFFOLD (aiolabs/bitspire#52).
*
* The user flagged NFC as a plausible future pairing method (tap a tag/phone
* carrying the spire-seed). This wires the seam against the Web NFC API
* (`NDEFReader`) so a future build can light it up without reworking the
* wizard. It is NOT active on current hardware: Web NFC ships only on Chrome
* for Android, so `isAvailable()` returns false on the Sintra's Linux Electron
* and the wizard simply won't offer it.
*
* When real NFC hardware lands (likely a HAL peripheral rather than Web NFC),
* replace the body of `start()` with that driver — the PairingSource contract
* stays the same.
*/
import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types'
// Minimal structural type for the Web NFC API (not in lib.dom for Electron).
interface NDEFReaderLike {
scan(): Promise<void>
addEventListener(
type: 'reading',
listener: (event: { message: { records: Array<{ recordType: string; data?: BufferSource }> } }) => void
): void
addEventListener(type: 'readingerror', listener: (event: unknown) => void): void
}
function getNDEFReaderCtor(): (new () => NDEFReaderLike) | null {
const ctor = (globalThis as { NDEFReader?: new () => NDEFReaderLike }).NDEFReader
return ctor ?? null
}
export class NfcPairingSource implements PairingSource {
readonly kind = 'nfc' as const
readonly label = 'NFC tap'
async isAvailable(): Promise<boolean> {
return getNDEFReaderCtor() !== null
}
async start(opts: PairingSourceStartOptions): Promise<StopCapture> {
const Ctor = getNDEFReaderCtor()
if (!Ctor) throw new Error('Web NFC unavailable on this device')
const reader = new Ctor()
const decoder = new TextDecoder()
let stopped = false
reader.addEventListener('reading', (event) => {
if (stopped) return
for (const record of event.message.records) {
if (record.recordType === 'text' && record.data) {
const raw = decoder.decode(record.data).trim()
if (raw) opts.onScan(raw)
}
}
})
reader.addEventListener('readingerror', (e) => opts.onError?.(e))
await reader.scan()
// Web NFC has no explicit stop; the AbortController form would, but the
// scaffold just flips a guard so late events are ignored after teardown.
return () => {
stopped = true
}
}
}

View file

@ -0,0 +1,61 @@
/**
* Camera-based QR pairing source (aiolabs/bitspire#52).
*
* Decodes with `qr` (paulmillr) — a zero-dependency, auditable, dual
* MIT/Apache library from the same author as the `@noble`/`@scure` crypto our
* nostr stack already trusts (chosen over the dormant `jsqr` for that ethos +
* active maintenance). Its `qr/dom.js` browser helper wraps getUserMedia and
* the per-frame decode loop, so this source is a thin adapter onto the
* PairingSource contract.
*
* The first successful decode wins; the loop then stops itself so a single
* seed isn't ingested repeatedly.
*/
import { QRCanvas, frontalCamera, frameLoop } from 'qr/dom.js'
import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types'
export class QrPairingSource implements PairingSource {
readonly kind = 'qr' as const
readonly label = 'Camera'
async isAvailable(): Promise<boolean> {
return (
typeof navigator !== 'undefined' &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getUserMedia === 'function'
)
}
async start(opts: PairingSourceStartOptions): Promise<StopCapture> {
const { onScan, onError, video } = opts
if (!video) throw new Error('QrPairingSource requires a <video> element')
const camera = await frontalCamera(video)
const canvas = new QRCanvas() // decode-only; no overlay canvases needed
let stopped = false
let cancel: (() => void) | null = null
const stop: StopCapture = () => {
if (stopped) return
stopped = true
cancel?.()
camera.stop()
}
cancel = frameLoop(() => {
if (stopped) return
try {
const result = camera.readFrame(canvas)
if (result) {
stop()
onScan(result)
}
} catch (e) {
onError?.(e)
}
})
return stop
}
}

View file

@ -0,0 +1,42 @@
/**
* Pairing-source abstraction (aiolabs/bitspire#52).
*
* A fresh ATM is paired by getting a `spire-seed:v1:…` onto the device. The
* operator's spirekeeper mints that seed and renders it as a QR (and, later,
* possibly an NFC tag). The machine ingests it via whatever capture hardware
* it has — today a camera, tomorrow maybe an NFC reader or a HAL barcode
* scanner. `PairingSource` is the seam that keeps the wizard UI and the
* ingest pipeline agnostic to *how* the seed arrived.
*
* Implementations live next to this file: `qr-source.ts` (camera + jsQR),
* `nfc-source.ts` (Web NFC scaffold). A HAL-scanner source can be added the
* same way without touching the wizard.
*/
export type PairingSourceKind = 'qr' | 'nfc'
export interface PairingSourceStartOptions {
/** Invoked with each decoded payload (the raw seed string). */
onScan: (raw: string) => void
/** Invoked on a non-fatal capture error (e.g. a frame decode glitch). */
onError?: (error: unknown) => void
/**
* The <video> element the camera preview renders into. Required by
* camera-based sources; ignored by sources that don't show a viewfinder
* (e.g. NFC).
*/
video?: HTMLVideoElement
}
/** Releases capture hardware (camera stream, NFC reader). Idempotent. */
export type StopCapture = () => void
export interface PairingSource {
readonly kind: PairingSourceKind
/** Short label for the wizard's source picker (e.g. "Camera", "NFC tap"). */
readonly label: string
/** Whether this source can run in the current environment. */
isAvailable(): Promise<boolean>
/** Begin capturing; resolves once hardware is live. */
start(opts: PairingSourceStartOptions): Promise<StopCapture>
}