feat(machine): production safety — disable mock fallback and ndebit

When VITE_ALLOW_MOCK_FALLBACK is unset (production default), the ATM
now shows a maintenance screen instead of silently falling back to mock
services when hardware or Lightning initialization fails. Also disables
ndebit/CLINK in production since the static ndebit pointer is replayable
— cash-in uses LNURL-withdraw only (single-use by design).

- Add allowMockFallback config field (Electron IPC + types)
- Add strict config validation (no localhost, require private key)
- Gate all catch-block fallbacks behind allowMockFallback
- Disable debit approval service and ndebit generation in production
- Add maintenance screen in App.vue when initError is set

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-27 17:39:12 -05:00
commit e460765355
7 changed files with 238 additions and 120 deletions

View file

@ -41,6 +41,15 @@ VITE_LIGHTNING_PUB_API_URL=http://localhost:1776
# If not set, generates ephemeral identity on each restart # If not set, generates ephemeral identity on each restart
VITE_ATM_PRIVATE_KEY= VITE_ATM_PRIVATE_KEY=
# =============================================================================
# Mock Fallback (Production Safety)
# =============================================================================
# Allow fallback to mock services when hardware/Lightning fails (default: false)
# Set to 'true' for development/demo environments only
# When false (production default), initialization failures show a maintenance screen
# VITE_ALLOW_MOCK_FALLBACK=true
# ============================================================================= # =============================================================================
# Development Only # Development Only
# ============================================================================= # =============================================================================

View file

@ -118,6 +118,7 @@ ipcMain.handle('get-config', () => {
validatorDevice: process.env.VITE_LAMASSU_VALIDATOR_DEVICE, validatorDevice: process.env.VITE_LAMASSU_VALIDATOR_DEVICE,
dispenserDevice: process.env.VITE_LAMASSU_DISPENSER_DEVICE, dispenserDevice: process.env.VITE_LAMASSU_DISPENSER_DEVICE,
cassettes: process.env.VITE_LAMASSU_CASSETTES, cassettes: process.env.VITE_LAMASSU_CASSETTES,
allowMockFallback: process.env.VITE_ALLOW_MOCK_FALLBACK === 'true',
} }
}) })

View file

@ -24,6 +24,7 @@ export interface RuntimeConfig {
validatorDevice?: string validatorDevice?: string
dispenserDevice?: string dispenserDevice?: string
cassettes?: string cassettes?: string
allowMockFallback: boolean
} }
// Expose protected methods to renderer // Expose protected methods to renderer

View file

@ -25,12 +25,17 @@ const formattedBtcPrice = computed(() => {
}) })
onMounted(async () => { onMounted(async () => {
if (isElectron) { try {
await atmStore.initializeForProduction() if (isElectron) {
} else { await atmStore.initializeForProduction()
await atmStore.initializeWithLightning() } else {
await atmStore.initializeWithLightning()
}
atmStore.startPricePolling()
} catch (error) {
console.error('[App] Initialization failed:', error)
atmStore.initError = error instanceof Error ? error.message : 'Initialization failed'
} }
atmStore.startPricePolling()
}) })
onUnmounted(() => { onUnmounted(() => {
@ -50,116 +55,148 @@ function toggleLiveServices() {
<template> <template>
<div class="flex h-dvh w-screen flex-col overflow-hidden bg-background font-sans text-foreground"> <div class="flex h-dvh w-screen flex-col overflow-hidden bg-background font-sans text-foreground">
<router-view /> <!-- Maintenance screen: shown when initialization fails in production -->
<!-- Connection status + balance (top right) -->
<div <div
class="fixed right-2 top-2 z-50 flex flex-wrap items-center justify-end gap-1 sm:right-4 sm:top-4 sm:gap-2" v-if="atmStore.initError"
class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
> >
<Badge <svg
v-if="formattedBtcPrice" xmlns="http://www.w3.org/2000/svg"
class="bg-primary/10 font-mono text-primary border border-primary/30 text-xs sm:text-sm" class="h-24 w-24 text-warning"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
> >
{{ formattedBtcPrice }} <path
</Badge> d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
<Badge />
v-if="atmStore.balanceSats !== null" <line x1="12" y1="9" x2="12" y2="13" />
class="bg-bitcoin/20 font-mono text-bitcoin border border-bitcoin/30 text-xs sm:text-sm" <line x1="12" y1="17" x2="12.01" y2="17" />
> </svg>
bal: {{ formatSats(atmStore.balanceSats) }} sats <h1 class="text-3xl font-bold">ATM Unavailable</h1>
</Badge> <p class="max-w-md text-center text-lg text-muted-foreground">
<Badge This machine is temporarily out of service. Please try again later or use another machine.
v-if="atmStore.connectionStatus === 'connected'" </p>
class="bg-success text-success-foreground" <p v-if="atmStore.debugMode" class="max-w-lg text-center font-mono text-sm text-destructive">
> {{ atmStore.initError }}
Live </p>
</Badge>
<Badge
v-else-if="atmStore.connectionStatus === 'connecting'"
class="bg-warning text-warning-foreground"
>
Connecting...
</Badge>
<Badge v-else-if="atmStore.connectionStatus === 'error'" class="bg-destructive">
Offline
</Badge>
<Badge v-else class="bg-muted text-muted-foreground">Mock</Badge>
<Badge
v-if="network !== 'mainnet'"
class="bg-warning/20 text-warning border border-warning/30"
>
{{ network }}
</Badge>
</div> </div>
<!-- Debug overlay (dev only) --> <template v-else>
<div <router-view />
v-if="atmStore.debugMode"
class="shrink-0 bg-popover pb-[env(safe-area-inset-bottom)] font-mono text-xs text-popover-foreground" <!-- Connection status + balance (top right) -->
> <div
<div class="flex flex-wrap items-center gap-2 px-4 py-2 sm:gap-4"> class="fixed right-2 top-2 z-50 flex flex-wrap items-center justify-end gap-1 sm:right-4 sm:top-4 sm:gap-2"
<Button >
variant="ghost" <Badge
size="sm" v-if="formattedBtcPrice"
class="h-5 px-1 text-xs text-muted-foreground" class="bg-primary/10 font-mono text-primary border border-primary/30 text-xs sm:text-sm"
@click="debugExpanded = !debugExpanded"
> >
{{ debugExpanded ? '▼' : '▲' }} {{ formattedBtcPrice }}
</Button> </Badge>
<span class="text-success">State: {{ atmStore.currentState }}</span> <Badge
<Button variant="outline" size="sm" class="h-6 text-xs" @click="toggleLiveServices"> v-if="atmStore.balanceSats !== null"
{{ atmStore.useLiveServices ? 'Switch to Mock' : 'Connect Live' }} class="bg-bitcoin/20 font-mono text-bitcoin border border-bitcoin/30 text-xs sm:text-sm"
</Button> >
<div class="flex flex-wrap items-center gap-1"> bal: {{ formatSats(atmStore.balanceSats) }} sats
</Badge>
<Badge
v-if="atmStore.connectionStatus === 'connected'"
class="bg-success text-success-foreground"
>
Live
</Badge>
<Badge
v-else-if="atmStore.connectionStatus === 'connecting'"
class="bg-warning text-warning-foreground"
>
Connecting...
</Badge>
<Badge v-else-if="atmStore.connectionStatus === 'error'" class="bg-destructive">
Offline
</Badge>
<Badge v-else class="bg-muted text-muted-foreground">Mock</Badge>
<Badge
v-if="network !== 'mainnet'"
class="bg-warning/20 text-warning border border-warning/30"
>
{{ network }}
</Badge>
</div>
<!-- Debug overlay (dev only) -->
<div
v-if="atmStore.debugMode"
class="shrink-0 bg-popover pb-[env(safe-area-inset-bottom)] font-mono text-xs text-popover-foreground"
>
<div class="flex flex-wrap items-center gap-2 px-4 py-2 sm:gap-4">
<Button <Button
v-for="theme in themes" variant="ghost"
:key="theme.id"
:variant="currentTheme === theme.id ? 'default' : 'outline'"
size="sm" size="sm"
class="h-6 text-xs" class="h-5 px-1 text-xs text-muted-foreground"
@click="currentTheme = theme.id" @click="debugExpanded = !debugExpanded"
> >
{{ theme.label }} {{ debugExpanded ? '▼' : '▲' }}
</Button>
<span class="text-success">State: {{ atmStore.currentState }}</span>
<Button variant="outline" size="sm" class="h-6 text-xs" @click="toggleLiveServices">
{{ atmStore.useLiveServices ? 'Switch to Mock' : 'Connect Live' }}
</Button>
<div class="flex flex-wrap items-center gap-1">
<Button
v-for="theme in themes"
:key="theme.id"
:variant="currentTheme === theme.id ? 'default' : 'outline'"
size="sm"
class="h-6 text-xs"
@click="currentTheme = theme.id"
>
{{ theme.label }}
</Button>
</div>
<div class="flex items-center gap-1">
<Button
:variant="colorMode === 'light' ? 'default' : 'outline'"
size="sm"
class="h-6 text-xs"
@click="colorMode = 'light'"
>
Light
</Button>
<Button
:variant="colorMode === 'dark' ? 'default' : 'outline'"
size="sm"
class="h-6 text-xs"
@click="colorMode = 'dark'"
>
Dark
</Button>
<Button
:variant="colorMode === 'system' ? 'default' : 'outline'"
size="sm"
class="h-6 text-xs"
@click="colorMode = 'system'"
>
System
</Button>
</div>
<Button
variant="ghost"
size="sm"
class="ml-auto h-6 text-xs text-muted-foreground"
@click="atmStore.toggleDebug"
>
Hide Debug
</Button> </Button>
</div> </div>
<div class="flex items-center gap-1"> <div v-if="debugExpanded" class="max-h-48 overflow-auto px-4 pb-4">
<Button <pre class="text-muted-foreground">{{ JSON.stringify(atmStore.context, null, 2) }}</pre>
:variant="colorMode === 'light' ? 'default' : 'outline'"
size="sm"
class="h-6 text-xs"
@click="colorMode = 'light'"
>
Light
</Button>
<Button
:variant="colorMode === 'dark' ? 'default' : 'outline'"
size="sm"
class="h-6 text-xs"
@click="colorMode = 'dark'"
>
Dark
</Button>
<Button
:variant="colorMode === 'system' ? 'default' : 'outline'"
size="sm"
class="h-6 text-xs"
@click="colorMode = 'system'"
>
System
</Button>
</div> </div>
<Button
variant="ghost"
size="sm"
class="ml-auto h-6 text-xs text-muted-foreground"
@click="atmStore.toggleDebug"
>
Hide Debug
</Button>
</div> </div>
<div v-if="debugExpanded" class="max-h-48 overflow-auto px-4 pb-4"> </template>
<pre class="text-muted-foreground">{{ JSON.stringify(atmStore.context, null, 2) }}</pre>
</div>
</div>
</div> </div>
</template> </template>

View file

@ -768,7 +768,9 @@ type PaymentReceivedCallback = (preimage: string) => void
/** /**
* Initialize Lightning services * Initialize Lightning services
*/ */
export async function initializeLightningServices(): Promise<LightningServices> { export async function initializeLightningServices(options?: {
strict?: boolean
}): Promise<LightningServices> {
console.log('[Lightning] Initializing services...') console.log('[Lightning] Initializing services...')
// Load configuration (async for Electron runtime config) // Load configuration (async for Electron runtime config)
@ -777,6 +779,26 @@ export async function initializeLightningServices(): Promise<LightningServices>
console.log('[Lightning] Relay URL:', CONFIG.relayUrl) console.log('[Lightning] Relay URL:', CONFIG.relayUrl)
console.log('[Lightning] Lightning.Pub pubkey:', CONFIG.lightningPubPubkey || '(not configured)') console.log('[Lightning] Lightning.Pub pubkey:', CONFIG.lightningPubPubkey || '(not configured)')
// Strict mode: validate config is production-ready (no localhost, no ephemeral identity)
if (options?.strict) {
const errors: string[] = []
if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) {
errors.push('VITE_RELAY_URL contains localhost')
}
if (/localhost|127\.0\.0\.1/.test(CONFIG.lightningPubApiUrl)) {
errors.push('VITE_LIGHTNING_PUB_API_URL contains localhost')
}
if (!CONFIG.atmPrivateKey) {
errors.push('VITE_ATM_PRIVATE_KEY is not set (ephemeral identity not allowed in production)')
}
if (!CONFIG.lightningPubPubkey) {
errors.push('VITE_LIGHTNING_PUB_PUBKEY is not set')
}
if (errors.length > 0) {
throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- '))
}
}
// Validate required configuration // Validate required configuration
if (!CONFIG.lightningPubPubkey) { if (!CONFIG.lightningPubPubkey) {
throw new Error( throw new Error(

View file

@ -134,6 +134,8 @@ export const useAtmStore = defineStore('atm', () => {
const snapshot = ref<SnapshotFrom<ATMMachine> | null>(null) const snapshot = ref<SnapshotFrom<ATMMachine> | null>(null)
// Show mock bill simulator when no real hardware // Show mock bill simulator when no real hardware
const debugMode = ref(true) const debugMode = ref(true)
const allowMockFallback = ref(true) // default true for browser dev
const initError = ref<string | null>(null) // fatal error → maintenance screen
const fiatCode = ref('USD') const fiatCode = ref('USD')
const useLiveServices = ref(false) const useLiveServices = ref(false)
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>( const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>(
@ -274,7 +276,7 @@ export const useAtmStore = defineStore('atm', () => {
console.log('[ATM] Connecting to Lightning.Pub...') console.log('[ATM] Connecting to Lightning.Pub...')
try { try {
const services = await initializeLightningServices() const services = await initializeLightningServices({ strict: !allowMockFallback.value })
useLiveServices.value = true useLiveServices.value = true
connectionStatus.value = 'connected' connectionStatus.value = 'connected'
console.log('[ATM] Connected to Lightning.Pub!') console.log('[ATM] Connected to Lightning.Pub!')
@ -334,9 +336,18 @@ export const useAtmStore = defineStore('atm', () => {
} }
}) })
// In production, disable ndebit/CLINK (security: ndebit is replayable)
if (!allowMockFallback.value) {
services.stopDebitApproval()
}
// Inject DB-backed inventory into services // Inject DB-backed inventory into services
const servicesWithInventory: ATMServices = { const servicesWithInventory: ATMServices = {
...services.atmServices, ...services.atmServices,
// In production, ndebit is disabled — return empty so QR shows LNURL only
...(!allowMockFallback.value && {
generateNdebit: async () => '',
}),
getInventory: async () => { getInventory: async () => {
const fresh = await loadInventoryFromDb() const fresh = await loadInventoryFromDb()
return Object.keys(fresh).length > 0 ? fresh : services.atmServices.getInventory() return Object.keys(fresh).length > 0 ? fresh : services.atmServices.getInventory()
@ -349,10 +360,14 @@ export const useAtmStore = defineStore('atm', () => {
console.error('[ATM] Failed to connect to Lightning.Pub:', error) console.error('[ATM] Failed to connect to Lightning.Pub:', error)
connectionStatus.value = 'error' connectionStatus.value = 'error'
// Fall back to mock services if (allowMockFallback.value) {
console.log('[ATM] Falling back to mock services') // Fall back to mock services
useLiveServices.value = false console.log('[ATM] Falling back to mock services')
initialize(mockServices) useLiveServices.value = false
initialize(mockServices)
} else {
initError.value = error instanceof Error ? error.message : 'Lightning initialization failed'
}
} }
} }
@ -522,7 +537,7 @@ export const useAtmStore = defineStore('atm', () => {
console.log('[ATM] HAL hardware initialized') console.log('[ATM] HAL hardware initialized')
// Initialize Lightning services // Initialize Lightning services
const lightning = await initializeLightningServices() const lightning = await initializeLightningServices({ strict: !allowMockFallback.value })
useLiveServices.value = true useLiveServices.value = true
connectionStatus.value = 'connected' connectionStatus.value = 'connected'
lightningPub.value = lightning.lightningPub lightningPub.value = lightning.lightningPub
@ -547,10 +562,19 @@ export const useAtmStore = defineStore('atm', () => {
} }
}) })
// In production, disable ndebit/CLINK (security: ndebit is replayable)
if (!allowMockFallback.value) {
lightning.stopDebitApproval()
}
// Merge HAL hardware services with Lightning payment services // Merge HAL hardware services with Lightning payment services
const mergedServices: ATMServices = { const mergedServices: ATMServices = {
...lightning.atmServices, ...lightning.atmServices,
...hal.atmServices, // Override dispenseCash and getInventory with real hardware ...hal.atmServices, // Override dispenseCash and getInventory with real hardware
// In production, ndebit is disabled — return empty so QR shows LNURL only
...(!allowMockFallback.value && {
generateNdebit: async () => '',
}),
// If DB has inventory, use it; otherwise fall back to HAL // If DB has inventory, use it; otherwise fall back to HAL
getInventory: async () => { getInventory: async () => {
const fresh = await loadInventoryFromDb() const fresh = await loadInventoryFromDb()
@ -604,10 +628,14 @@ export const useAtmStore = defineStore('atm', () => {
console.error('[ATM] HAL initialization failed:', error) console.error('[ATM] HAL initialization failed:', error)
connectionStatus.value = 'error' connectionStatus.value = 'error'
// Fall back to mock services if (allowMockFallback.value) {
console.log('[ATM] Falling back to mock services') // Fall back to mock services
useLiveServices.value = false console.log('[ATM] Falling back to mock services')
initialize(mockServices) useLiveServices.value = false
initialize(mockServices)
} else {
initError.value = error instanceof Error ? error.message : 'HAL initialization failed'
}
} }
} }
@ -628,6 +656,10 @@ export const useAtmStore = defineStore('atm', () => {
// Get runtime config from Electron main process (.env file) // Get runtime config from Electron main process (.env file)
const runtimeConfig = await api.getConfig() const runtimeConfig = await api.getConfig()
allowMockFallback.value = runtimeConfig.allowMockFallback
if (!allowMockFallback.value) {
debugMode.value = false
}
const model = (runtimeConfig.machineModel || 'sintra') as MachineModel const model = (runtimeConfig.machineModel || 'sintra') as MachineModel
const runtimeFiatCode = runtimeConfig.fiatCode || 'USD' const runtimeFiatCode = runtimeConfig.fiatCode || 'USD'
fiatCode.value = runtimeFiatCode fiatCode.value = runtimeFiatCode
@ -689,7 +721,7 @@ export const useAtmStore = defineStore('atm', () => {
console.log('[ATM] HAL initialized in main process') console.log('[ATM] HAL initialized in main process')
// Initialize Lightning services // Initialize Lightning services
const lightning = await initializeLightningServices() const lightning = await initializeLightningServices({ strict: !allowMockFallback.value })
useLiveServices.value = true useLiveServices.value = true
connectionStatus.value = 'connected' connectionStatus.value = 'connected'
lightningPub.value = lightning.lightningPub lightningPub.value = lightning.lightningPub
@ -753,10 +785,19 @@ export const useAtmStore = defineStore('atm', () => {
}, },
} }
// In production, disable ndebit/CLINK (security: ndebit is replayable)
if (!allowMockFallback.value) {
lightning.stopDebitApproval()
}
// Merge HAL hardware services with Lightning payment services // Merge HAL hardware services with Lightning payment services
const mergedServices: ATMServices = { const mergedServices: ATMServices = {
...lightning.atmServices, ...lightning.atmServices,
...halAtmServices, ...halAtmServices,
// In production, ndebit is disabled — return empty so QR shows LNURL only
...(!allowMockFallback.value && {
generateNdebit: async () => '',
}),
getInventory: halAtmServices.getInventory, getInventory: halAtmServices.getInventory,
} }
@ -823,15 +864,19 @@ export const useAtmStore = defineStore('atm', () => {
} catch (error) { } catch (error) {
console.error('[ATM] HAL initialization failed:', error) console.error('[ATM] HAL initialization failed:', error)
// Fall back to Lightning-only mode (real Lightning, mock hardware) if (allowMockFallback.value) {
console.log('[ATM] Falling back to Lightning-only mode (mock hardware)') // Fall back to Lightning-only mode (real Lightning, mock hardware)
try { console.log('[ATM] Falling back to Lightning-only mode (mock hardware)')
await initializeWithLightning() try {
} catch (lightningError) { await initializeWithLightning()
console.error('[ATM] Lightning also failed:', lightningError) } catch (lightningError) {
connectionStatus.value = 'error' console.error('[ATM] Lightning also failed:', lightningError)
useLiveServices.value = false connectionStatus.value = 'error'
initialize(mockServices) useLiveServices.value = false
initialize(mockServices)
}
} else {
initError.value = error instanceof Error ? error.message : 'Hardware initialization failed'
} }
} }
} }
@ -946,6 +991,8 @@ export const useAtmStore = defineStore('atm', () => {
actor, actor,
snapshot, snapshot,
debugMode, debugMode,
allowMockFallback,
initError,
fiatCode, fiatCode,
useLiveServices, useLiveServices,
connectionStatus, connectionStatus,

View file

@ -15,6 +15,7 @@ export interface RuntimeConfig {
validatorDevice?: string validatorDevice?: string
dispenserDevice?: string dispenserDevice?: string
cassettes?: string cassettes?: string
allowMockFallback: boolean
} }
declare global { declare global {