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:
parent
34179a4e34
commit
e460765355
7 changed files with 238 additions and 120 deletions
|
|
@ -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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
1
apps/machine/src/types/electron.d.ts
vendored
1
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue