feat(machine): pairing review step with a relay-reachability test

A well-formed but unreachable relay (localhost baked into a seed for a remote
machine, a wrong LAN IP, a relay that's down) parses fine and only fails later
as a NIP-46 connect crash-loop. Give the operator a way to catch it on-machine
before committing (bitspire-#70).

The wizard no longer commits immediately on a good scan: it now parses (without
persisting) and shows a review step with the decoded spire + relay(s), a "Test
relay" button (opens a WebSocket + NIP-01 REQ, reports reachable/latency or
unreachable), and Pair / Rescan. Only on "Pair" does it persist + relaunch into
the real pairing path.

- parseScannedSeed: validate-only split of ingestScannedSeed (no persist).
- testRelay: WebSocket reachability probe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-01 23:43:28 +02:00
commit d094cf090c
4 changed files with 232 additions and 13 deletions

View file

@ -10,15 +10,18 @@
* Capture is abstracted behind PairingSource, so NFC (or a HAL scanner) can be * Capture is abstracted behind PairingSource, so NFC (or a HAL scanner) can be
* offered later without changing this view. * offered later without changing this view.
*/ */
import { onMounted, onUnmounted, ref, shallowRef } from 'vue' import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
import { import {
availablePairingSources, availablePairingSources,
ingestScannedSeed, ingestScannedSeed,
parseScannedSeed,
testRelay,
type PairingSource, type PairingSource,
type RelayTestResult,
type StopCapture, type StopCapture,
} from '@/services/pairing' } from '@/services/pairing'
type Phase = 'probing' | 'scanning' | 'no-source' | 'pairing' | 'error' type Phase = 'probing' | 'scanning' | 'review' | 'no-source' | 'pairing' | 'error'
const phase = ref<Phase>('probing') const phase = ref<Phase>('probing')
const errorMessage = ref('') const errorMessage = ref('')
@ -28,6 +31,19 @@ const sources = shallowRef<PairingSource[]>([])
const activeSource = shallowRef<PairingSource | null>(null) const activeSource = shallowRef<PairingSource | null>(null)
let stopCapture: StopCapture | null = null let stopCapture: StopCapture | null = null
// Review-step state: the scanned-but-not-yet-committed seed + relay tests.
const scannedRaw = ref('')
const previewSpire = ref('')
const previewRelays = ref<string[]>([])
type RelayState = { status: 'idle' | 'testing' | 'done'; result?: RelayTestResult }
const relayTests = ref<Record<string, RelayState>>({})
const testingRelays = ref(false)
const committing = ref(false)
const anyRelayFailed = computed(() =>
Object.values(relayTests.value).some((s) => s.status === 'done' && s.result != null && !s.result.ok),
)
async function startWith(source: PairingSource) { async function startWith(source: PairingSource) {
await teardown() await teardown()
activeSource.value = source activeSource.value = source
@ -50,18 +66,58 @@ let handling = false
async function handleScan(raw: string) { async function handleScan(raw: string) {
if (handling) return if (handling) return
handling = true handling = true
const result = await ingestScannedSeed(raw) // Validate only — don't commit yet. Show a review step with the decoded
if (result.ok) { // relay + a "test relay" button so a well-formed but unreachable relay is
// saveSpireSeed succeeded; relaunch is in flight — hold a friendly screen. // caught before we relaunch into a pairing crash-loop (aiolabs/bitspire#70).
phase.value = 'pairing' const preview = parseScannedSeed(raw)
if (preview.ok) {
await teardown() // camera off during review
scannedRaw.value = raw.trim()
previewSpire.value = preview.spirePubkey
previewRelays.value = preview.relays
relayTests.value = Object.fromEntries(preview.relays.map((r) => [r, { status: 'idle' }]))
errorMessage.value = ''
phase.value = 'review'
return return
} }
// Reject non-seed scans (a stray QR) and resume scanning. // Reject non-seed / malformed scans (a stray QR, a corrupted relay) and resume.
console.warn('[Pairing] rejected scan:', result.reason, result.message) console.warn('[Pairing] rejected scan:', preview.reason, preview.message)
errorMessage.value = errorMessage.value = 'That code is not a valid pairing code. Show the operator pairing QR.'
result.reason === 'invalid-seed' handling = false
? 'That code is not a pairing code. Show the operator pairing QR.' if (activeSource.value) await startWith(activeSource.value)
: result.message }
/** Probe every relay in the scanned seed and record reachability. */
async function testRelays() {
testingRelays.value = true
await Promise.all(
previewRelays.value.map(async (url) => {
relayTests.value[url] = { status: 'testing' }
const result = await testRelay(url)
relayTests.value[url] = { status: 'done', result }
}),
)
testingRelays.value = false
}
/** Commit the reviewed seed: persist + relaunch into the real pairing path. */
async function confirmPair() {
committing.value = true
const result = await ingestScannedSeed(scannedRaw.value)
if (result.ok) {
phase.value = 'pairing' // relaunch in flight
return
}
committing.value = false
errorMessage.value = result.message
phase.value = 'error'
}
/** Discard the scan and go back to scanning. */
async function rescan() {
scannedRaw.value = ''
previewRelays.value = []
relayTests.value = {}
handling = false handling = false
if (activeSource.value) await startWith(activeSource.value) if (activeSource.value) await startWith(activeSource.value)
} }
@ -121,6 +177,67 @@ onUnmounted(teardown)
<p class="text-base lg:text-2xl text-muted-foreground">Pairing accepted — restarting…</p> <p class="text-base lg:text-2xl text-muted-foreground">Pairing accepted — restarting…</p>
</div> </div>
<!-- Review: confirm the scanned relay is reachable before committing -->
<div v-if="phase === 'review'" class="flex w-full max-w-md flex-col items-center gap-5">
<p class="text-base lg:text-2xl text-muted-foreground">
Pairing code scanned. Test the relay, then pair.
</p>
<div class="w-full rounded-xl border border-border p-4 text-left">
<p class="text-xs uppercase text-muted-foreground">Spire</p>
<p class="mb-3 break-all font-mono text-sm">{{ previewSpire.slice(0, 16) }}…</p>
<p class="text-xs uppercase text-muted-foreground">Relay(s)</p>
<ul class="flex flex-col gap-2">
<li
v-for="url in previewRelays"
:key="url"
class="flex items-center justify-between gap-3"
>
<span class="break-all font-mono text-xs">{{ url }}</span>
<span class="shrink-0 text-sm">
<template v-if="relayTests[url]?.status === 'testing'">
<span class="text-muted-foreground">testing…</span>
</template>
<template v-else-if="relayTests[url]?.status === 'done'">
<span v-if="relayTests[url]?.result?.ok" class="text-green-500"
>✓ {{ relayTests[url]?.result?.ms }}ms</span
>
<span v-else class="text-destructive">✗ unreachable</span>
</template>
</span>
</li>
</ul>
</div>
<div class="flex flex-wrap justify-center gap-3">
<button
class="rounded-lg border border-border px-4 py-2 text-sm disabled:opacity-50"
:disabled="testingRelays || committing"
@click="testRelays"
>
{{ testingRelays ? 'Testing…' : 'Test relay' }}
</button>
<button
class="rounded-lg border border-border px-4 py-2 text-sm disabled:opacity-50"
:disabled="committing"
@click="rescan"
>
Rescan
</button>
<button
class="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
:disabled="committing"
@click="confirmPair"
>
{{ committing ? 'Pairing…' : 'Pair this machine' }}
</button>
</div>
<p v-if="anyRelayFailed" class="max-w-md text-center text-sm text-warning">
A relay looks unreachable from this machine — pairing will fail unless it can reach the
relay. Check the URL/network, or rescan a corrected code.
</p>
</div>
<p <p
v-if="phase === 'no-source'" v-if="phase === 'no-source'"
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground" class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"

View file

@ -14,8 +14,10 @@ import type { PairingSource } from './types'
export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types' export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types'
export { QrPairingSource } from './qr-source' export { QrPairingSource } from './qr-source'
export { NfcPairingSource } from './nfc-source' export { NfcPairingSource } from './nfc-source'
export { ingestScannedSeed } from './ingest' export { ingestScannedSeed, parseScannedSeed } from './ingest'
export type { IngestResult } from './ingest' export type { IngestResult, SeedPreview } from './ingest'
export { testRelay } from './relay-test'
export type { RelayTestResult } from './relay-test'
/** All sources in preference order, regardless of availability. */ /** All sources in preference order, regardless of availability. */
export function allPairingSources(): PairingSource[] { export function allPairingSources(): PairingSource[] {

View file

@ -21,6 +21,37 @@ export type IngestResult =
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] } | { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
| { ok: false; reason: 'invalid-seed' | 'no-bridge' | 'persist-failed'; message: string } | { ok: false; reason: 'invalid-seed' | 'no-bridge' | 'persist-failed'; message: string }
export type SeedPreview =
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
| { ok: false; reason: 'invalid-seed'; message: string }
/**
* Validate-only: parse a scanned payload as a spire-seed WITHOUT persisting or
* relaunching. The wizard uses this to show a review step (decoded relay + a
* "test relay" button) before committing, so a well-formed but unreachable
* relay is caught before the machine relaunches into a pairing crash-loop.
* `parseSpireSeed` already rejects a malformed relay (e.g. a QR misread of
* `ws://` → `As://`); this surfaces that as an invalid-seed rejection.
*/
export function parseScannedSeed(raw: string): SeedPreview {
const trimmed = (raw || '').trim()
try {
const seed = parseSpireSeed(trimmed)
return {
ok: true,
spirePubkey: seed.spirePubkey,
fingerprint: seedFingerprint(trimmed),
relays: seed.relays,
}
} catch (e) {
return {
ok: false,
reason: 'invalid-seed',
message: e instanceof Error ? e.message : 'Not a valid pairing code',
}
}
}
export async function ingestScannedSeed(raw: string): Promise<IngestResult> { export async function ingestScannedSeed(raw: string): Promise<IngestResult> {
const trimmed = (raw || '').trim() const trimmed = (raw || '').trim()

View file

@ -0,0 +1,69 @@
/**
* Relay reachability probe for the pairing wizard (aiolabs/bitspire#70).
*
* `parseSpireSeed` catches a MALFORMED relay (e.g. a QR misread of `ws://` into
* `As://`), but a well-formed-yet-unreachable relay — `ws://localhost:…` baked
* into a seed for a remote machine, a wrong LAN IP, or a relay that's simply
* down — still parses fine and would only fail later as a NIP-46 connect
* crash-loop. This opens a WebSocket to the relay (and sends a NIP-01 REQ so a
* real relay answers) so the operator can confirm reachability on-machine,
* before committing the pairing.
*/
export interface RelayTestResult {
url: string
ok: boolean
/** Round-trip time to open (ms), when reachable. */
ms?: number
/** True when the relay answered our REQ — i.e. it's actually a nostr relay. */
answered?: boolean
error?: string
}
/** Open a WebSocket to `url` and report whether it connects within `timeoutMs`. */
export function testRelay(url: string, timeoutMs = 6000): Promise<RelayTestResult> {
return new Promise((resolve) => {
const start = Date.now()
let ws: WebSocket | null = null
let settled = false
const finish = (r: Omit<RelayTestResult, 'url'>): void => {
if (settled) return
settled = true
clearTimeout(timer)
try {
ws?.close()
} catch {
/* already closing */
}
resolve({ url, ...r })
}
const timer = setTimeout(
() => finish({ ok: false, error: `timed out after ${timeoutMs}ms` }),
timeoutMs,
)
try {
ws = new WebSocket(url)
} catch (e) {
finish({ ok: false, error: e instanceof Error ? e.message : 'invalid relay URL' })
return
}
ws.onopen = () => {
// Connected. Probe it as a nostr relay; a genuine relay replies (EOSE /
// notice). If it stays silent we still count the open as reachable.
try {
ws?.send(JSON.stringify(['REQ', 'bitspire-relay-test', { limit: 0 }]))
} catch {
/* send failed, but the socket opened → still reachable */
}
const graceMs = Math.min(600, timeoutMs)
setTimeout(() => finish({ ok: true, ms: Date.now() - start, answered: false }), graceMs)
}
ws.onmessage = () => finish({ ok: true, ms: Date.now() - start, answered: true })
ws.onerror = () =>
finish({ ok: false, error: 'connection failed (unreachable or not a relay)' })
})
}