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 () => {
try {
if (isElectron) { if (isElectron) {
await atmStore.initializeForProduction() await atmStore.initializeForProduction()
} else { } else {
await atmStore.initializeWithLightning() await atmStore.initializeWithLightning()
} }
atmStore.startPricePolling() atmStore.startPricePolling()
} catch (error) {
console.error('[App] Initialization failed:', error)
atmStore.initError = error instanceof Error ? error.message : 'Initialization failed'
}
}) })
onUnmounted(() => { onUnmounted(() => {
@ -50,6 +55,37 @@ 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">
<!-- Maintenance screen: shown when initialization fails in production -->
<div
v-if="atmStore.initError"
class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
>
<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"
>
<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>
<template v-else>
<router-view /> <router-view />
<!-- Connection status + balance (top right) --> <!-- Connection status + balance (top right) -->
@ -161,5 +197,6 @@ function toggleLiveServices() {
<pre class="text-muted-foreground">{{ JSON.stringify(atmStore.context, null, 2) }}</pre> <pre class="text-muted-foreground">{{ JSON.stringify(atmStore.context, null, 2) }}</pre>
</div> </div>
</div> </div>
</template>
</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'
if (allowMockFallback.value) {
// Fall back to mock services // Fall back to mock services
console.log('[ATM] Falling back to mock services') console.log('[ATM] Falling back to mock services')
useLiveServices.value = false useLiveServices.value = false
initialize(mockServices) 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'
if (allowMockFallback.value) {
// Fall back to mock services // Fall back to mock services
console.log('[ATM] Falling back to mock services') console.log('[ATM] Falling back to mock services')
useLiveServices.value = false useLiveServices.value = false
initialize(mockServices) 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,6 +864,7 @@ export const useAtmStore = defineStore('atm', () => {
} catch (error) { } catch (error) {
console.error('[ATM] HAL initialization failed:', error) console.error('[ATM] HAL initialization failed:', error)
if (allowMockFallback.value) {
// Fall back to Lightning-only mode (real Lightning, mock hardware) // Fall back to Lightning-only mode (real Lightning, mock hardware)
console.log('[ATM] Falling back to Lightning-only mode (mock hardware)') console.log('[ATM] Falling back to Lightning-only mode (mock hardware)')
try { try {
@ -833,6 +875,9 @@ export const useAtmStore = defineStore('atm', () => {
useLiveServices.value = false useLiveServices.value = false
initialize(mockServices) 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 {