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:
parent
fc2b5d915a
commit
b59b4ea1d4
4 changed files with 96 additions and 23 deletions
|
|
@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
|
||||||
import { useAtmStore } from '@/stores/atm'
|
import { useAtmStore } from '@/stores/atm'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
import { setBranding } from '@/composables/useBranding'
|
import { setBranding } from '@/composables/useBranding'
|
||||||
|
import { classifyInitError } from '@/services/init-error'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Sun, Moon } from 'lucide-vue-next'
|
import { Sun, Moon } from 'lucide-vue-next'
|
||||||
|
|
@ -20,6 +21,46 @@ function formatSats(sats: number): string {
|
||||||
return sats.toLocaleString()
|
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(() => {
|
const formattedBtcPrice = computed(() => {
|
||||||
if (atmStore.btcPrice === null) return null
|
if (atmStore.btcPrice === null) return null
|
||||||
const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}`
|
const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}`
|
||||||
|
|
@ -96,7 +137,7 @@ onMounted(async () => {
|
||||||
atmStore.startPricePolling()
|
atmStore.startPricePolling()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[App] Initialization failed:', 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" />
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
</svg>
|
</svg>
|
||||||
<h1 class="text-2xl lg:text-[3.5rem] font-bold">
|
<h1 class="text-2xl lg:text-[3.5rem] font-bold">
|
||||||
{{
|
{{ maintenanceScreen.title }}
|
||||||
atmStore.initError === 'maintenance'
|
|
||||||
? 'Under Service'
|
|
||||||
: atmStore.initError === 'awaiting-fees'
|
|
||||||
? 'Awaiting Configuration'
|
|
||||||
: 'ATM Unavailable'
|
|
||||||
}}
|
|
||||||
</h1>
|
</h1>
|
||||||
<p class="max-w-md text-center text-base lg:text-2xl text-muted-foreground">
|
<p class="max-w-md text-center text-base lg:text-2xl text-muted-foreground">
|
||||||
{{
|
{{ maintenanceScreen.message }}
|
||||||
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.'
|
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-if="
|
v-if="atmStore.debugMode && !isKnownMaintenanceScreen"
|
||||||
atmStore.debugMode &&
|
|
||||||
atmStore.initError !== 'maintenance' &&
|
|
||||||
atmStore.initError !== 'awaiting-fees'
|
|
||||||
"
|
|
||||||
class="max-w-lg text-center font-mono text-sm text-destructive"
|
class="max-w-lg text-center font-mono text-sm text-destructive"
|
||||||
>
|
>
|
||||||
{{ atmStore.initError }}
|
{{ atmStore.initError }}
|
||||||
|
|
|
||||||
30
apps/machine/src/services/__tests__/init-error.test.ts
Normal file
30
apps/machine/src/services/__tests__/init-error.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
17
apps/machine/src/services/init-error.ts
Normal file
17
apps/machine/src/services/init-error.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
type ATMMachine,
|
type ATMMachine,
|
||||||
} from '@bitSpire/state-machine'
|
} from '@bitSpire/state-machine'
|
||||||
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
||||||
|
import { classifyInitError } from '@/services/init-error'
|
||||||
import {
|
import {
|
||||||
startOperatorConfigService,
|
startOperatorConfigService,
|
||||||
type OperatorConfigService,
|
type OperatorConfigService,
|
||||||
|
|
@ -706,7 +707,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
useLiveServices.value = false
|
useLiveServices.value = false
|
||||||
initialize(mockServices)
|
initialize(mockServices)
|
||||||
} else {
|
} 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
|
useLiveServices.value = false
|
||||||
initialize(mockServices)
|
initialize(mockServices)
|
||||||
} else {
|
} 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)
|
initialize(mockServices)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
initError.value = error instanceof Error ? error.message : 'Hardware initialization failed'
|
initError.value = classifyInitError(error, 'Hardware initialization failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue