feat(machine): persist scanned spire-seed + signal unpaired state for wizard

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>
This commit is contained in:
Padreug 2026-06-23 23:07:18 +02:00
commit 9935807f8c
5 changed files with 72 additions and 5 deletions

View file

@ -119,6 +119,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'),
// QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed,
// then relaunch so the normal boot flow pairs it.
saveSpireSeed: (seed: string): Promise<void> => ipcRenderer.invoke('state:save-spire-seed', seed),
relaunchApp: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
applyOperatorCassettesConfig: (
payload: {
positions: Record<string, { denomination: number; count: number }>
@ -234,6 +239,8 @@ declare global {
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void>
resetBootstrapGate: () => Promise<void>
saveSpireSeed: (seed: string) => Promise<void>
relaunchApp: () => Promise<void>
applyOperatorCassettesConfig: (
payload: { positions: Record<string, { denomination: number; count: number }> },
eventCreatedAt: number