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:
parent
1e2a653ad1
commit
d094cf090c
4 changed files with 232 additions and 13 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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[] {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
69
apps/machine/src/services/pairing/relay-test.ts
Normal file
69
apps/machine/src/services/pairing/relay-test.ts
Normal 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)' })
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue