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 { 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 }}
|
||||
|
|
|
|||
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,
|
||||
} 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue