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
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
# =============================================================================

View file

@ -118,6 +118,7 @@ ipcMain.handle('get-config', () => {
validatorDevice: process.env.VITE_LAMASSU_VALIDATOR_DEVICE,
dispenserDevice: process.env.VITE_LAMASSU_DISPENSER_DEVICE,
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
dispenserDevice?: string
cassettes?: string
allowMockFallback: boolean
}
// Expose protected methods to renderer

View file

@ -25,12 +25,17 @@ const formattedBtcPrice = computed(() => {
})
onMounted(async () => {
if (isElectron) {
await atmStore.initializeForProduction()
} else {
await atmStore.initializeWithLightning()
try {
if (isElectron) {
await atmStore.initializeForProduction()
} 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(() => {
@ -50,116 +55,148 @@ function toggleLiveServices() {
<template>
<div class="flex h-dvh w-screen flex-col overflow-hidden bg-background font-sans text-foreground">
<router-view />
<!-- Connection status + balance (top right) -->
<!-- Maintenance screen: shown when initialization fails in production -->
<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
v-if="formattedBtcPrice"
class="bg-primary/10 font-mono text-primary border border-primary/30 text-xs sm:text-sm"
<svg
xmlns="http://www.w3.org/2000/svg"
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 }}
</Badge>
<Badge
v-if="atmStore.balanceSats !== null"
class="bg-bitcoin/20 font-mono text-bitcoin border border-bitcoin/30 text-xs sm:text-sm"
>
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>
<path
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"
/>
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<h1 class="text-3xl font-bold">ATM Unavailable</h1>
<p class="max-w-md text-center text-lg text-muted-foreground">
This machine is temporarily out of service. Please try again later or use another machine.
</p>
<p v-if="atmStore.debugMode" class="max-w-lg text-center font-mono text-sm text-destructive">
{{ atmStore.initError }}
</p>
</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
variant="ghost"
size="sm"
class="h-5 px-1 text-xs text-muted-foreground"
@click="debugExpanded = !debugExpanded"
<template v-else>
<router-view />
<!-- Connection status + balance (top right) -->
<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"
>
<Badge
v-if="formattedBtcPrice"
class="bg-primary/10 font-mono text-primary border border-primary/30 text-xs sm:text-sm"
>
{{ 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">
{{ formattedBtcPrice }}
</Badge>
<Badge
v-if="atmStore.balanceSats !== null"
class="bg-bitcoin/20 font-mono text-bitcoin border border-bitcoin/30 text-xs sm:text-sm"
>
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
v-for="theme in themes"
:key="theme.id"
:variant="currentTheme === theme.id ? 'default' : 'outline'"
variant="ghost"
size="sm"
class="h-6 text-xs"
@click="currentTheme = theme.id"
class="h-5 px-1 text-xs text-muted-foreground"
@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>
</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 v-if="debugExpanded" class="max-h-48 overflow-auto px-4 pb-4">
<pre class="text-muted-foreground">{{ JSON.stringify(atmStore.context, null, 2) }}</pre>
</div>
<Button
variant="ghost"
size="sm"
class="ml-auto h-6 text-xs text-muted-foreground"
@click="atmStore.toggleDebug"
>
Hide Debug
</Button>
</div>
<div v-if="debugExpanded" class="max-h-48 overflow-auto px-4 pb-4">
<pre class="text-muted-foreground">{{ JSON.stringify(atmStore.context, null, 2) }}</pre>
</div>
</div>
</template>
</div>
</template>

View file

@ -768,7 +768,9 @@ type PaymentReceivedCallback = (preimage: string) => void
/**
* Initialize Lightning services
*/
export async function initializeLightningServices(): Promise<LightningServices> {
export async function initializeLightningServices(options?: {
strict?: boolean
}): Promise<LightningServices> {
console.log('[Lightning] Initializing services...')
// 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] 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
if (!CONFIG.lightningPubPubkey) {
throw new Error(

View file

@ -134,6 +134,8 @@ export const useAtmStore = defineStore('atm', () => {
const snapshot = ref<SnapshotFrom<ATMMachine> | null>(null)
// Show mock bill simulator when no real hardware
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 useLiveServices = ref(false)
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>(
@ -274,7 +276,7 @@ export const useAtmStore = defineStore('atm', () => {
console.log('[ATM] Connecting to Lightning.Pub...')
try {
const services = await initializeLightningServices()
const services = await initializeLightningServices({ strict: !allowMockFallback.value })
useLiveServices.value = true
connectionStatus.value = 'connected'
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
const servicesWithInventory: ATMServices = {
...services.atmServices,
// In production, ndebit is disabled — return empty so QR shows LNURL only
...(!allowMockFallback.value && {
generateNdebit: async () => '',
}),
getInventory: async () => {
const fresh = await loadInventoryFromDb()
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)
connectionStatus.value = 'error'
// Fall back to mock services
console.log('[ATM] Falling back to mock services')
useLiveServices.value = false
initialize(mockServices)
if (allowMockFallback.value) {
// Fall back to mock services
console.log('[ATM] Falling back to mock services')
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')
// Initialize Lightning services
const lightning = await initializeLightningServices()
const lightning = await initializeLightningServices({ strict: !allowMockFallback.value })
useLiveServices.value = true
connectionStatus.value = 'connected'
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
const mergedServices: ATMServices = {
...lightning.atmServices,
...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
getInventory: async () => {
const fresh = await loadInventoryFromDb()
@ -604,10 +628,14 @@ export const useAtmStore = defineStore('atm', () => {
console.error('[ATM] HAL initialization failed:', error)
connectionStatus.value = 'error'
// Fall back to mock services
console.log('[ATM] Falling back to mock services')
useLiveServices.value = false
initialize(mockServices)
if (allowMockFallback.value) {
// Fall back to mock services
console.log('[ATM] Falling back to mock services')
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)
const runtimeConfig = await api.getConfig()
allowMockFallback.value = runtimeConfig.allowMockFallback
if (!allowMockFallback.value) {
debugMode.value = false
}
const model = (runtimeConfig.machineModel || 'sintra') as MachineModel
const runtimeFiatCode = runtimeConfig.fiatCode || 'USD'
fiatCode.value = runtimeFiatCode
@ -689,7 +721,7 @@ export const useAtmStore = defineStore('atm', () => {
console.log('[ATM] HAL initialized in main process')
// Initialize Lightning services
const lightning = await initializeLightningServices()
const lightning = await initializeLightningServices({ strict: !allowMockFallback.value })
useLiveServices.value = true
connectionStatus.value = 'connected'
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
const mergedServices: ATMServices = {
...lightning.atmServices,
...halAtmServices,
// In production, ndebit is disabled — return empty so QR shows LNURL only
...(!allowMockFallback.value && {
generateNdebit: async () => '',
}),
getInventory: halAtmServices.getInventory,
}
@ -823,15 +864,19 @@ export const useAtmStore = defineStore('atm', () => {
} catch (error) {
console.error('[ATM] HAL initialization failed:', error)
// Fall back to Lightning-only mode (real Lightning, mock hardware)
console.log('[ATM] Falling back to Lightning-only mode (mock hardware)')
try {
await initializeWithLightning()
} catch (lightningError) {
console.error('[ATM] Lightning also failed:', lightningError)
connectionStatus.value = 'error'
useLiveServices.value = false
initialize(mockServices)
if (allowMockFallback.value) {
// Fall back to Lightning-only mode (real Lightning, mock hardware)
console.log('[ATM] Falling back to Lightning-only mode (mock hardware)')
try {
await initializeWithLightning()
} catch (lightningError) {
console.error('[ATM] Lightning also failed:', lightningError)
connectionStatus.value = 'error'
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,
snapshot,
debugMode,
allowMockFallback,
initError,
fiatCode,
useLiveServices,
connectionStatus,

View file

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