diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index 652f708..48cfc8a 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -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 = { + 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() {

- {{ - atmStore.initError === 'maintenance' - ? 'Under Service' - : atmStore.initError === 'awaiting-fees' - ? 'Awaiting Configuration' - : 'ATM Unavailable' - }} + {{ maintenanceScreen.title }}

- {{ - 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 }}

{{ atmStore.initError }} diff --git a/apps/machine/src/services/__tests__/init-error.test.ts b/apps/machine/src/services/__tests__/init-error.test.ts new file mode 100644 index 0000000..fc4215b --- /dev/null +++ b/apps/machine/src/services/__tests__/init-error.test.ts @@ -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') + }) +}) diff --git a/apps/machine/src/services/init-error.ts b/apps/machine/src/services/init-error.ts new file mode 100644 index 0000000..f9b0c84 --- /dev/null +++ b/apps/machine/src/services/init-error.ts @@ -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 +} diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index efc80cf..ca22847 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -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') } } }