From e46076535599ec3dbd32b54ce68ee89f800237c7 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 27 Feb 2026 17:39:12 -0500 Subject: [PATCH] =?UTF-8?q?feat(machine):=20production=20safety=20?= =?UTF-8?q?=E2=80=94=20disable=20mock=20fallback=20and=20ndebit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/machine/.env.example | 9 + apps/machine/electron/main.ts | 1 + apps/machine/electron/preload.ts | 1 + apps/machine/src/App.vue | 243 ++++++++++++++----------- apps/machine/src/services/lightning.ts | 24 ++- apps/machine/src/stores/atm.ts | 87 +++++++-- apps/machine/src/types/electron.d.ts | 1 + 7 files changed, 242 insertions(+), 124 deletions(-) diff --git a/apps/machine/.env.example b/apps/machine/.env.example index ce1a2cb..cf0ce22 100644 --- a/apps/machine/.env.example +++ b/apps/machine/.env.example @@ -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 # ============================================================================= diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index ac7fa39..03d7abd 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -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', } }) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 8f1f1a2..db4ce4b 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -24,6 +24,7 @@ export interface RuntimeConfig { validatorDevice?: string dispenserDevice?: string cassettes?: string + allowMockFallback: boolean } // Expose protected methods to renderer diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index 56e87f3..b353984 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -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() { diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 00948b8..aa4ee5f 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -768,7 +768,9 @@ type PaymentReceivedCallback = (preimage: string) => void /** * Initialize Lightning services */ -export async function initializeLightningServices(): Promise { +export async function initializeLightningServices(options?: { + strict?: boolean +}): Promise { console.log('[Lightning] Initializing services...') // Load configuration (async for Electron runtime config) @@ -777,6 +779,26 @@ export async function initializeLightningServices(): Promise 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( diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 27f0914..0943262 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -134,6 +134,8 @@ export const useAtmStore = defineStore('atm', () => { const snapshot = ref | 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(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, diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 89643b1..db728ad 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -15,6 +15,7 @@ export interface RuntimeConfig { validatorDevice?: string dispenserDevice?: string cassettes?: string + allowMockFallback: boolean } declare global {