bitspire/apps/machine/electron/preload.ts
Padreug 4f68ddc40b refactor(machine): drop VITE_LNBITS_HTTP_URL — lnurl now arrives populated from LNbits (#57 gap 2)
Closes gap 2 from coord log 2026-06-01T18:30Z. The LNbits withdraw
extension's nostr-transport RPC now populates `link.lnurl` from
`settings.lnbits_baseurl` (aiolabs/withdraw#1 / commit e9d911e), so the
ATM no longer needs a separate HTTP URL on the wire to compose the
LNURL-withdraw callback itself.

What goes:

- `VITE_LNBITS_HTTP_URL` env var (renderer + Electron main)
- `lnbitsHttpUrl` field on `LightningConfig`, `RuntimeConfig`, and the
  Window mirror in `src/types/electron.d.ts`
- The manual `${lnbitsHttpUrl}/withdraw/api/v1/lnurl/${unique_hash}`
  composition in `generateLnurlWithdraw`
- The `encodeLnurl` bech32 helper in `lightning.ts` (LNbits returns
  bech32-encoded; we just `.toUpperCase()` to match BOLT/LNURL convention)
- `@scure/base` dep from `apps/machine/package.json` (only used by the
  removed helper; clink still uses it directly)
- The `lnbitsHttpUrl` option + `LNBITS_HTTP_URL=…` env var + boot echo
  in `deploy/nixos/bitspire-atm.nix`
- Doc references in CLAUDE.md, README.md, deploy/nixos/README.md,
  docs/architecture-comparison.md, and the lightning-check skill

What stays:

- `link.lnurl` consumption, with an explicit error if LNbits returns
  null (which signals `LNBITS_BASEURL` is unset on the server side —
  better to fail clearly than silently)
- The receiver-side bech32 uppercasing (LNbits returns lowercase per
  the standard library)

Why this is a net win:

- Removes a config-drift surface — if LNbits's external URL moved
  (DNS, port, reverse-proxy rewrite), every ATM in the field would
  stop issuing redeemable LNURL-withdraw QRs until reconfigured.
  Now LNbits derives its own URL from `settings.lnbits_baseurl`,
  one source of truth.
- Removes an extra provisioning step. No more `LNBITS_HTTP_URL=…`
  before running `provision-atm.sh`; the relay + server pubkey suffice.
- Removes the misleading boot echo that triggered the §`18:30Z`
  smoke triage confusion ("LNbits HTTP: <url>" read like ATM-→-LNbits
  connectivity, when it was only ever a URL embedded in customer QRs).

Also adds a `# pragma: allowlist secret` marker above the
`VITE_ATM_PRIVATE_KEY` doc block in `.env.example` so the global
secret scanner stops false-positiving on the documentation prose.

Workspace typecheck + 24/24 apps/machine tests still green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 20:33:28 +02:00

257 lines
9.9 KiB
TypeScript

/**
* Electron Preload Script
*
* Exposes a secure API to the renderer process via contextBridge.
* This is the only way for the Vue app to communicate with the main process.
*/
import { contextBridge, ipcRenderer } from 'electron'
/**
* Runtime configuration interface (public info only)
* These values are read from environment variables at runtime (not build time)
*
* SECURITY: Secrets are NOT included here. Use getAtmSecrets() instead.
*/
export interface RuntimeConfig {
relayUrl: string
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
lnbitsServerPubkey: string
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
lightningPubPubkey?: string
lightningPubApiUrl?: string
extensionApiUrl?: string
appId: string
machineModel: string
fiatCode: string
validatorDevice?: string
dispenserDevice?: string
cassettes?: string
allowMockFallback: boolean
operatorPubkeys: string
maintenanceMode: boolean
branding: BrandingConfig | null
}
export interface BrandingConfig {
title: string | null
theme: string | null
customColors?: Record<string, string>
customColorsDark?: Record<string, string>
logoDataUrl: string | null
logoDarkDataUrl: string | null
}
/**
* ATM secrets — returned once by getAtmSecrets(), then empty on subsequent calls.
*/
export interface AtmSecrets {
atmPrivateKey: string
/** Legacy LP admin token — retained until 3d removes the LP backend. */
adminToken?: string
}
// Expose protected methods to renderer
contextBridge.exposeInMainWorld('electronAPI', {
// Get app version
getVersion: (): Promise<string> => ipcRenderer.invoke('get-version'),
// Get runtime configuration (read from environment at runtime)
getConfig: (): Promise<RuntimeConfig> => ipcRenderer.invoke('get-config'),
// Get ATM secrets (one-shot: returns secrets once, then empty)
getAtmSecrets: (): Promise<AtmSecrets> => ipcRenderer.invoke('get-atm-secrets'),
// State persistence
loadCassettes: () => ipcRenderer.invoke('state:load-cassettes'),
setCassettes: (cassettes: { denomination: number; count: number }[]) =>
ipcRenderer.invoke('state:set-cassettes', cassettes),
getInventory: () => ipcRenderer.invoke('state:get-inventory'),
getCashbox: () => ipcRenderer.invoke('state:get-cashbox'),
recordTransaction: (tx: {
txid: string
type: 'cash_in' | 'cash_out' | 'manual_dispense'
status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
fiatCents: number
sats: number
feeSats: number
feeFraction: number
exchangeRate: number
currency: string
bills: { denomination: number; count: number }[]
cassettes?: {
name: string
position: number
denomination: number
provisioned: number
dispensed: number
rejected: number
}[]
error?: string | null
}) => ipcRenderer.invoke('state:record-transaction', tx),
emptyCashbox: () => ipcRenderer.invoke('state:empty-cashbox'),
remediateTransaction: (txid: string, remediatedByTxid: string): Promise<boolean> =>
ipcRenderer.invoke('state:remediate-transaction', txid, remediatedByTxid),
// Operator-config consumer (aiolabs/lamassu-next#56)
getLastKnownConfigCreatedAt: (): Promise<number> =>
ipcRenderer.invoke('state:get-last-known-config-created-at'),
getBootstrapPublishedAt: (): Promise<number | null> =>
ipcRenderer.invoke('state:get-bootstrap-published-at'),
markBootstrapPublished: (unixTimestamp: number): Promise<void> =>
ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp),
applyOperatorCassettesConfig: (
payload: {
positions: Record<string, { denomination: number; count: number }>
},
eventCreatedAt: number
): Promise<{ applied: true } | { applied: false; reason: string }> =>
ipcRenderer.invoke('state:apply-operator-cassettes-config', payload, eventCreatedAt),
// Operator-fees consumer (aiolabs/lamassu-next#57)
getFeeConfig: (): Promise<{
cashInFeeFraction: number
cashOutFeeFraction: number
schemaVersion: number
eventCreatedAt: number
appliedAt: number
} | null> => ipcRenderer.invoke('state:get-fee-config'),
getLastKnownFeeConfigCreatedAt: (): Promise<number> =>
ipcRenderer.invoke('state:get-last-known-fee-config-created-at'),
applyFeeConfig: (
payload: {
cashInFeeFraction: number
cashOutFeeFraction: number
schemaVersion: number
},
eventCreatedAt: number
): Promise<{ applied: true } | { applied: false; reason: string }> =>
ipcRenderer.invoke('state:apply-fee-config', payload, eventCreatedAt),
// Support pages
getSupportPages: (): Promise<{ id: string; title: string; content: string }[]> =>
ipcRenderer.invoke('support:get-pages'),
// HAL hardware (runs in main process, exposed via IPC)
halInit: (config: any): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('hal:init', config),
halDispense: (amounts: any): Promise<any> => ipcRenderer.invoke('hal:dispense', amounts),
halEnableValidator: (): Promise<void> => ipcRenderer.invoke('hal:enable-validator'),
halDisableValidator: (): Promise<void> => ipcRenderer.invoke('hal:disable-validator'),
halStackBill: (): Promise<void> => ipcRenderer.invoke('hal:stack-bill'),
halRejectBill: (): Promise<void> => ipcRenderer.invoke('hal:reject-bill'),
halGetInventory: (): Promise<Record<number, number>> => ipcRenderer.invoke('hal:get-inventory'),
halReloadCassettes: (
cassettes: { position: number; denomination: number; count?: number }[]
): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('hal:reload-cassettes', cassettes),
halCleanup: (): Promise<void> => ipcRenderer.invoke('hal:cleanup'),
// HAL event listeners (main process → renderer)
onHalBillRead: (callback: (denomination: number) => void) => {
ipcRenderer.on('hal:bill-read', (_event, denomination) => callback(denomination))
},
onHalBillInserted: (callback: (denomination: number) => void) => {
ipcRenderer.on('hal:bill-inserted', (_event, denomination) => callback(denomination))
},
onHalBillRejected: (callback: (reason: string) => void) => {
ipcRenderer.on('hal:bill-rejected', (_event, reason) => callback(reason))
},
onHalError: (callback: (error: string) => void) => {
ipcRenderer.on('hal:error', (_event, error) => callback(error))
},
// Watchdog heartbeat (main process → renderer → main process)
onWatchdogPing: (callback: () => void) => {
ipcRenderer.on('watchdog:ping', () => callback())
},
watchdogPong: (): Promise<void> => ipcRenderer.invoke('watchdog:pong'),
// Platform info
platform: process.platform,
})
// Type declaration for the exposed API
declare global {
interface Window {
electronAPI: {
getVersion: () => Promise<string>
getConfig: () => Promise<RuntimeConfig>
getAtmSecrets: () => Promise<AtmSecrets>
loadCassettes: () => Promise<{ denomination: number; count: number; position: number }[]>
setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise<void>
getInventory: () => Promise<Record<number, number>>
getCashbox: () => Promise<{
totalBills: number
totalFiatCents: number
lastEmptiedAt: number | null
}>
recordTransaction: (tx: {
txid: string
type: 'cash_in' | 'cash_out'
status: 'complete' | 'dispense_error' | 'partial'
fiatCents: number
sats: number
feeSats: number
feeFraction: number
exchangeRate: number
currency: string
bills: { denomination: number; count: number }[]
cassettes?: {
name: string
position: number
denomination: number
provisioned: number
dispensed: number
rejected: number
}[]
error?: string | null
}) => Promise<void>
emptyCashbox: () => Promise<void>
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
getLastKnownConfigCreatedAt: () => Promise<number>
getBootstrapPublishedAt: () => Promise<number | null>
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
applyOperatorCassettesConfig: (
payload: { positions: Record<string, { denomination: number; count: number }> },
eventCreatedAt: number
) => Promise<{ applied: true } | { applied: false; reason: string }>
getFeeConfig: () => Promise<{
cashInFeeFraction: number
cashOutFeeFraction: number
schemaVersion: number
eventCreatedAt: number
appliedAt: number
} | null>
getLastKnownFeeConfigCreatedAt: () => Promise<number>
applyFeeConfig: (
payload: {
cashInFeeFraction: number
cashOutFeeFraction: number
schemaVersion: number
},
eventCreatedAt: number
) => Promise<{ applied: true } | { applied: false; reason: string }>
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
// HAL hardware IPC
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
halDispense: (amounts: any) => Promise<any>
halEnableValidator: () => Promise<void>
halDisableValidator: () => Promise<void>
halStackBill: () => Promise<void>
halRejectBill: () => Promise<void>
halGetInventory: () => Promise<Record<number, number>>
halReloadCassettes: (
cassettes: { position: number; denomination: number; count?: number }[]
) => Promise<{ ok: boolean; error?: string }>
halCleanup: () => Promise<void>
onHalBillRead: (callback: (denomination: number) => void) => void
onHalBillInserted: (callback: (denomination: number) => void) => void
onHalBillRejected: (callback: (reason: string) => void) => void
onHalError: (callback: (error: string) => void) => void
onWatchdogPing: (callback: () => void) => void
watchdogPong: () => Promise<void>
platform: NodeJS.Platform
}
}
}