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:
parent
9935807f8c
commit
d22157b40c
8 changed files with 354 additions and 269 deletions
|
|
@ -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",
|
||||
|
|
|
|||
80
apps/machine/src/services/pairing/__tests__/ingest.test.ts
Normal file
80
apps/machine/src/services/pairing/__tests__/ingest.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
30
apps/machine/src/services/pairing/index.ts
Normal file
30
apps/machine/src/services/pairing/index.ts
Normal 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])
|
||||
}
|
||||
63
apps/machine/src/services/pairing/ingest.ts
Normal file
63
apps/machine/src/services/pairing/ingest.ts
Normal 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 }
|
||||
}
|
||||
67
apps/machine/src/services/pairing/nfc-source.ts
Normal file
67
apps/machine/src/services/pairing/nfc-source.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
61
apps/machine/src/services/pairing/qr-source.ts
Normal file
61
apps/machine/src/services/pairing/qr-source.ts
Normal 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
|
||||
}
|
||||
}
|
||||
42
apps/machine/src/services/pairing/types.ts
Normal file
42
apps/machine/src/services/pairing/types.ts
Normal 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>
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue