feat(machine): re-pair UX on bunker deauth at boot

A revoked / TTL-expired / off-policy bunker binding now surfaces a dedicated
"Pairing Required" screen instead of a raw error, and a signer/relay timeout
shows "Signer Unreachable" (transient). Shared classifyInitError() maps the
typed BunkerRejectedError / BunkerTimeoutError (by name, so it survives bundle
boundaries) to maintenance-screen sentinels, used at every store init catch +
the App.vue fallback. App.vue's nested-ternary screen copy refactored to a
keyed map (cleaner, and the new screens drop in).

Scope: boot-time detection (covers the dominant restart-after-revoke case).
Mid-session re-pair detection (flipping the screen when a sign fails during a
live flow) is a deliberate follow-up.

Part of Phase D, aiolabs/bitspire#52.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-19 23:34:07 +02:00 • committed by padreug
commit 78d54cdc94
4 changed files with 96 additions and 23 deletions

View file

@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { useAtmStore } from '@/stores/atm'
import { useTheme } from '@/composables/useTheme'
import { setBranding } from '@/composables/useBranding'
import { classifyInitError } from '@/services/init-error'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Sun, Moon } from 'lucide-vue-next'
@ -20,6 +21,46 @@ function formatSats(sats: number): string {
return sats.toLocaleString()
}
/**
* Maintenance-screen copy keyed by the `initError` sentinel. Falls back to a
* generic out-of-service message (the raw error text shows under debug only).
*/
const MAINTENANCE_SCREENS: Record<string, { title: string; message: string }> = {
maintenance: {
title: 'Under Service',
message: 'This machine is currently being serviced. We will be back shortly.',
},
'awaiting-fees': {
title: 'Awaiting Configuration',
message:
'Awaiting fee configuration from operator. Contact operator to publish initial fee config.',
},
unpaired: {
title: 'Pairing Required',
message:
'This machine needs to be re-paired by the operator before it can accept transactions.',
},
'signer-unreachable': {
title: 'Signer Unreachable',
message: 'Cannot reach the signing service right now. This usually resolves shortly.',
},
}
const GENERIC_SCREEN = {
title: 'ATM Unavailable',
message:
'This machine is temporarily out of service. Please try again later or use another machine.',
}
const maintenanceScreen = computed(() =>
atmStore.initError ? (MAINTENANCE_SCREENS[atmStore.initError] ?? GENERIC_SCREEN) : GENERIC_SCREEN
)
/** True when the screen is a known sentinel (hide the raw debug error line). */
const isKnownMaintenanceScreen = computed(
() => !!atmStore.initError && atmStore.initError in MAINTENANCE_SCREENS
)
const formattedBtcPrice = computed(() => {
if (atmStore.btcPrice === null) return null
const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}`
@ -96,7 +137,7 @@ onMounted(async () => {
atmStore.startPricePolling()
} catch (error) {
console.error('[App] Initialization failed:', error)
atmStore.initError = error instanceof Error ? error.message : 'Initialization failed'
atmStore.initError = classifyInitError(error)
}
})
@ -141,29 +182,13 @@ function toggleLiveServices() {
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<h1 class="text-2xl lg:text-[3.5rem] font-bold">
{{
atmStore.initError === 'maintenance'
? 'Under Service'
: atmStore.initError === 'awaiting-fees'
? 'Awaiting Configuration'
: 'ATM Unavailable'
}}
{{ maintenanceScreen.title }}
</h1>
<p class="max-w-md text-center text-base lg:text-2xl text-muted-foreground">
{{
atmStore.initError === 'maintenance'
? 'This machine is currently being serviced. We will be back shortly.'
: atmStore.initError === 'awaiting-fees'
? 'Awaiting fee configuration from operator. Contact operator to publish initial fee config.'
: 'This machine is temporarily out of service. Please try again later or use another machine.'
}}
{{ maintenanceScreen.message }}
</p>
<p
v-if="
atmStore.debugMode &&
atmStore.initError !== 'maintenance' &&
atmStore.initError !== 'awaiting-fees'
"
v-if="atmStore.debugMode && !isKnownMaintenanceScreen"
class="max-w-lg text-center font-mono text-sm text-destructive"
>
{{ atmStore.initError }}

View file

@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { BunkerRejectedError, BunkerTimeoutError } from '@bitSpire/nostr-client'
import { classifyInitError } from '../init-error.js'
describe('classifyInitError', () => {
it('maps a bunker rejection (revoke / TTL / off-policy) to "unpaired"', () => {
expect(classifyInitError(new BunkerRejectedError('revoked'))).toBe('unpaired')
})
it('maps a bunker timeout to "signer-unreachable"', () => {
expect(classifyInitError(new BunkerTimeoutError('no response'))).toBe('signer-unreachable')
})
it('classifies by error name across bundle boundaries (no instanceof)', () => {
// A structurally-equivalent error from a different module copy still maps.
const lookalike = Object.assign(new Error('x'), { name: 'BunkerRejectedError' })
expect(classifyInitError(lookalike)).toBe('unpaired')
})
it('surfaces a generic error message unchanged', () => {
expect(classifyInitError(new Error('relay down'))).toBe('relay down')
})
it('uses the fallback for non-Error throws', () => {
expect(classifyInitError('boom', 'Lightning initialization failed')).toBe(
'Lightning initialization failed'
)
expect(classifyInitError(undefined)).toBe('Initialization failed')
})
})

View file

@ -0,0 +1,17 @@
/**
* Classify an initialization failure into a maintenance-screen sentinel
* (see App.vue's MAINTENANCE_SCREENS).
*
* Bunker failures (aiolabs/bitspire#52) get dedicated screens:
* - `BunkerRejectedError` (revoked / TTL-expired / off-policy binding) →
* `unpaired` — the operator must re-pair the machine.
* - `BunkerTimeoutError` (signer/relay unreachable) → `signer-unreachable`,
* a transient condition.
* Everything else surfaces its raw message (or the caller's fallback).
*/
export function classifyInitError(error: unknown, fallback = 'Initialization failed'): string {
const name = (error as { name?: string } | null)?.name
if (name === 'BunkerRejectedError') return 'unpaired'
if (name === 'BunkerTimeoutError') return 'signer-unreachable'
return error instanceof Error ? error.message : fallback
}

View file

@ -10,6 +10,7 @@ import {
type ATMMachine,
} from '@bitSpire/state-machine'
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
import { classifyInitError } from '@/services/init-error'
import {
startOperatorConfigService,
type OperatorConfigService,
@ -706,7 +707,7 @@ export const useAtmStore = defineStore('atm', () => {
useLiveServices.value = false
initialize(mockServices)
} else {
initError.value = error instanceof Error ? error.message : 'Lightning initialization failed'
initError.value = classifyInitError(error, 'Lightning initialization failed')
}
}
}
@ -1019,7 +1020,7 @@ export const useAtmStore = defineStore('atm', () => {
useLiveServices.value = false
initialize(mockServices)
} else {
initError.value = error instanceof Error ? error.message : 'HAL initialization failed'
initError.value = classifyInitError(error, 'HAL initialization failed')
}
}
}
@ -1330,7 +1331,7 @@ export const useAtmStore = defineStore('atm', () => {
initialize(mockServices)
}
} else {
initError.value = error instanceof Error ? error.message : 'Hardware initialization failed'
initError.value = classifyInitError(error, 'Hardware initialization failed')
}
}
}