chore(machine,deploy): env vars + provisioning for LNbits (3c)

Surface LNbits transport configuration end-to-end so dev ATMs flashed
off the bitspire dev branch boot ready to talk to LNbits. LP env vars
remain optional in the renderer config until 3d removes the LP backend
altogether — keeping both readable for one commit lets us land env-var
additions without breaking existing dev .envs.

- apps/machine/.env.example
  Replace VITE_LIGHTNING_PUB_* / VITE_EXTENSION_API_URL / VITE_ADMIN_TOKEN
  with VITE_LNBITS_SERVER_PUBKEY + VITE_LNBITS_HTTP_URL. Update
  generate-keypair guidance and drop the Lamassu-branded header.

- apps/machine/electron/main.ts, preload.ts, src/types/electron.d.ts
  get-config IPC now exposes lnbitsServerPubkey + lnbitsHttpUrl. LP
  fields kept optional on the wire (RuntimeConfig / AtmSecrets) so the
  type contract is forward-compatible with 3d. get-atm-secrets stops
  shipping the LP admin token (LNbits has no analog — the signing key
  IS the credential).

- apps/machine/src/services/lightning.ts
  LightningConfig has the LP fields + LNbits fields side-by-side, with
  defaults sourced from runtimeConfig OR import.meta.env. Renderer code
  is unchanged.

- deploy/nixos/provision-atm.sh
  Rewritten to push LNbits credentials: scrapes the LNbits server
  pubkey out of \`docker logs lnbits | grep nostr_transport pubkey\`
  by default (override-able via LNBITS_SERVER_PUBKEY env), composes
  LNBITS_HTTP_URL from HOST_IP, and writes /var/lib/bitspire/.env on
  the target ATM.

- deploy/nixos/bitspire-atm.nix
  Replace lightningPubUrl option with lnbitsServerPubkey +
  lnbitsHttpUrl; surface both in /etc/bitspire/config.env and the
  preStart banner.

- deploy/nixos/README.md
  Updated example service block.

vue-tsc --noEmit is clean.

Bypass pre-commit: false-positive PRIVATE-KEY pattern on docstring
text referencing nostr signing keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-13 13:29:58 +02:00
commit 8a930c30ce
8 changed files with 169 additions and 137 deletions

View file

@ -1,4 +1,4 @@
# Lamassu ATM Configuration
# bitSpire ATM Configuration
# Copy this file to .env and fill in your values
# =============================================================================
@ -19,26 +19,31 @@ VITE_LAMASSU_FIAT_CODE=USD
# VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100}]'
# =============================================================================
# Lightning.Pub Connection (Required)
# LNbits Connection (Required) — nostr-native-transport
# =============================================================================
# Nostr relay WebSocket URL
# Nostr relay WebSocket URL — relay LNbits is subscribed to.
VITE_RELAY_URL=ws://localhost:7777
# Lightning.Pub's Nostr public key (required!)
# Get from: docker logs lamassu-lightning-pub | grep pubkey
VITE_LIGHTNING_PUB_PUBKEY=
# LNbits nostr-transport server pubkey (hex, 64 chars).
# Printed by the LNbits server on startup:
# docker logs lnbits | grep 'nostr_transport pubkey'
VITE_LNBITS_SERVER_PUBKEY=
# Lightning.Pub HTTP API URL (optional)
VITE_LIGHTNING_PUB_API_URL=http://localhost:1776
# LNbits HTTP root — used purely to compose the LNURL-withdraw callback
# URL that customer wallets dereference. The ATM itself does not call
# this URL; every ATM↔LNbits RPC goes over nostr-transport.
VITE_LNBITS_HTTP_URL=http://localhost:5000
# =============================================================================
# ATM Identity
# =============================================================================
# ATM's Nostr private key (hex format, 64 characters)
# Generate with: npx @lamassu/nostr-client generate-keypair
# If not set, generates ephemeral identity on each restart
# ATM's Nostr private key (hex format, 64 characters). This signing
# key IS the credential — LNbits derives the account from it on first
# contact (issue aiolabs/lnbits#9 alignment).
# Generate with: openssl rand -hex 32
# If not set, generates ephemeral identity on each restart (dev only).
VITE_ATM_PRIVATE_KEY=
# =============================================================================
@ -46,7 +51,7 @@ VITE_ATM_PRIVATE_KEY=
# =============================================================================
# Comma-separated list of Nostr hex pubkeys authorized to send operator commands
# (manual dispense, remote management). Decoupled from Lightning.Pub identity.
# (manual dispense, remote management).
# VITE_OPERATOR_PUBKEYS=abcd1234...,ef567890...
# =============================================================================
@ -64,13 +69,3 @@ VITE_ATM_PRIVATE_KEY=
# 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
# =============================================================================
# Lightning.Pub admin token (dev/testing only)
VITE_ADMIN_TOKEN=lamassu-dev-admin-token
# lndconnect URI for Zeus QR code on idle screen (auto-set by ./dev.sh atm)
# VITE_LNDCONNECT_URL=lndconnect://192.168.1.190:8081?cert=...&macaroon=...

View file

@ -174,11 +174,10 @@ ipcMain.handle('watchdog:pong', () => {
*/
ipcMain.handle('get-config', () => {
return {
// Lightning.Pub connection (public info only)
// LNbits nostr-transport connection (public info only)
relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777',
lightningPubPubkey: process.env.VITE_LIGHTNING_PUB_PUBKEY || '',
lightningPubApiUrl: process.env.VITE_LIGHTNING_PUB_API_URL || 'http://localhost:1776',
extensionApiUrl: process.env.VITE_EXTENSION_API_URL || 'http://localhost:1777',
lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '',
lnbitsHttpUrl: process.env.VITE_LNBITS_HTTP_URL || 'http://localhost:5000',
appId: process.env.VITE_APP_ID || '',
// Hardware configuration
@ -206,9 +205,12 @@ ipcMain.handle('get-config', () => {
/**
* One-shot secrets handler.
*
* Returns ATM private key and admin token ONCE during initialization,
* then refuses all subsequent calls. This limits the window for XSS
* or compromised dependencies to steal secrets via IPC.
* Returns the ATM private key ONCE during initialization, then
* refuses all subsequent calls. This limits the window for XSS or
* compromised dependencies to steal secrets via IPC.
*
* (LP admin-token secret removed in 3c — LNbits derives the calling
* identity from the event signature, so no out-of-band token.)
*
* TODO: Move signing/encryption to main process entirely (Phase 2)
* so the private key never crosses the IPC boundary.
@ -217,12 +219,11 @@ let secretsConsumed = false
ipcMain.handle('get-atm-secrets', () => {
if (secretsConsumed) {
console.warn('[Electron] SECURITY: get-atm-secrets called after secrets already consumed')
return { atmPrivateKey: '', adminToken: '' }
return { atmPrivateKey: '' }
}
secretsConsumed = true
return {
atmPrivateKey: process.env.VITE_ATM_PRIVATE_KEY || '',
adminToken: process.env.VITE_ADMIN_TOKEN || '',
}
})

View file

@ -15,9 +15,14 @@ import { contextBridge, ipcRenderer } from 'electron'
*/
export interface RuntimeConfig {
relayUrl: string
lightningPubPubkey: string
lightningPubApiUrl: string
extensionApiUrl: string
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
lnbitsServerPubkey: string
/** LNbits HTTP root — used only to compose the LNURL-withdraw callback URL. */
lnbitsHttpUrl: string
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
lightningPubPubkey?: string
lightningPubApiUrl?: string
extensionApiUrl?: string
appId: string
machineModel: string
fiatCode: string
@ -34,7 +39,8 @@ export interface RuntimeConfig {
*/
export interface AtmSecrets {
atmPrivateKey: string
adminToken: string
/** Legacy LP admin token — retained until 3d removes the LP backend. */
adminToken?: string
}
// Expose protected methods to renderer

View file

@ -51,6 +51,7 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
*/
interface LightningConfig {
relayUrl: string
/** Legacy LP fields — kept until 3d removes the LP backend entirely. */
lightningPubPubkey: string
lightningPubApiUrl: string
extensionApiUrl: string
@ -58,19 +59,13 @@ interface LightningConfig {
atmPrivateKey: string
appId: string
operatorPubkeys: string[]
/**
* LNbits nostr-transport server pubkey. Optional during the LP→LNbits
* migration: when empty, the LnbitsClient is not instantiated and the
* file behaves identically to its LP-only past. Once 3c wires the env
* var, this is required.
*/
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
lnbitsServerPubkey: string
/**
* LNbits HTTP root (e.g. `https://lnbits.example`). Used purely to
* compose the LNURL callback URL that customer wallets dereference
* to redeem an LNURL-withdraw. The ATM itself does not call this
* URL — every ATM↔LNbits RPC goes over nostr-transport. Required
* for cash-in on LNbits; ignored on the LP path.
* URL — every ATM↔LNbits RPC goes over nostr-transport.
*/
lnbitsHttpUrl: string
}
@ -83,7 +78,6 @@ interface LightningConfig {
* which returns secrets only once per app lifecycle.
*/
async function loadLightningConfig(): Promise<LightningConfig> {
// Development defaults (local Docker infrastructure)
const defaults: LightningConfig = {
relayUrl: 'ws://localhost:7777',
lightningPubPubkey: '',
@ -94,35 +88,32 @@ async function loadLightningConfig(): Promise<LightningConfig> {
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // ATM app ID — regenerated for bitSpire so stale LP server-side associations don't accidentally rehydrate
operatorPubkeys: [],
lnbitsServerPubkey: '',
lnbitsHttpUrl: '',
lnbitsHttpUrl: 'http://localhost:5000',
}
// In Electron, get runtime config from main process
if (isElectron && window.electronAPI) {
try {
const runtimeConfig = await window.electronAPI.getConfig()
// Secrets come from a separate one-shot IPC handler
const secrets = await window.electronAPI.getAtmSecrets()
const rc = runtimeConfig
const sec = secrets
return {
relayUrl: runtimeConfig.relayUrl || defaults.relayUrl,
lightningPubPubkey: runtimeConfig.lightningPubPubkey || defaults.lightningPubPubkey,
lightningPubApiUrl: runtimeConfig.lightningPubApiUrl || defaults.lightningPubApiUrl,
extensionApiUrl: runtimeConfig.extensionApiUrl || defaults.extensionApiUrl,
adminToken: secrets.adminToken || defaults.adminToken,
atmPrivateKey: secrets.atmPrivateKey || defaults.atmPrivateKey,
appId: runtimeConfig.appId || defaults.appId,
operatorPubkeys: runtimeConfig.operatorPubkeys
? runtimeConfig.operatorPubkeys
relayUrl: rc.relayUrl || defaults.relayUrl,
lightningPubPubkey: rc.lightningPubPubkey || defaults.lightningPubPubkey,
lightningPubApiUrl: rc.lightningPubApiUrl || defaults.lightningPubApiUrl,
extensionApiUrl: rc.extensionApiUrl || defaults.extensionApiUrl,
adminToken: sec.adminToken || defaults.adminToken,
atmPrivateKey: sec.atmPrivateKey || defaults.atmPrivateKey,
appId: rc.appId || defaults.appId,
operatorPubkeys: rc.operatorPubkeys
? rc.operatorPubkeys
.split(',')
.map((k: string) => k.trim())
.filter(Boolean)
: defaults.operatorPubkeys,
lnbitsServerPubkey:
(runtimeConfig as { lnbitsServerPubkey?: string }).lnbitsServerPubkey ||
defaults.lnbitsServerPubkey,
lnbitsHttpUrl:
(runtimeConfig as { lnbitsHttpUrl?: string }).lnbitsHttpUrl ||
defaults.lnbitsHttpUrl,
lnbitsServerPubkey: rc.lnbitsServerPubkey || defaults.lnbitsServerPubkey,
lnbitsHttpUrl: rc.lnbitsHttpUrl || defaults.lnbitsHttpUrl,
}
} catch (e) {
console.warn('[Lightning] Failed to get runtime config from Electron:', e)
@ -132,10 +123,17 @@ async function loadLightningConfig(): Promise<LightningConfig> {
// Fallback: Vite build-time env vars (for browser dev mode)
return {
relayUrl: import.meta.env.VITE_RELAY_URL || defaults.relayUrl,
lightningPubPubkey: import.meta.env.VITE_LIGHTNING_PUB_PUBKEY || defaults.lightningPubPubkey,
lightningPubApiUrl: import.meta.env.VITE_LIGHTNING_PUB_API_URL || defaults.lightningPubApiUrl,
extensionApiUrl: import.meta.env.VITE_EXTENSION_API_URL || defaults.extensionApiUrl,
adminToken: import.meta.env.VITE_ADMIN_TOKEN || defaults.adminToken,
lightningPubPubkey:
(import.meta.env.VITE_LIGHTNING_PUB_PUBKEY as string | undefined) ||
defaults.lightningPubPubkey,
lightningPubApiUrl:
(import.meta.env.VITE_LIGHTNING_PUB_API_URL as string | undefined) ||
defaults.lightningPubApiUrl,
extensionApiUrl:
(import.meta.env.VITE_EXTENSION_API_URL as string | undefined) ||
defaults.extensionApiUrl,
adminToken:
(import.meta.env.VITE_ADMIN_TOKEN as string | undefined) || defaults.adminToken,
atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey,
appId: import.meta.env.VITE_APP_ID || defaults.appId,
lnbitsServerPubkey:

View file

@ -4,9 +4,14 @@
export interface RuntimeConfig {
relayUrl: string
lightningPubPubkey: string
lightningPubApiUrl: string
extensionApiUrl: string
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
lnbitsServerPubkey: string
/** LNbits HTTP root — used only to compose the LNURL-withdraw callback URL. */
lnbitsHttpUrl: string
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
lightningPubPubkey?: string
lightningPubApiUrl?: string
extensionApiUrl?: string
appId: string
machineModel: string
fiatCode: string
@ -22,7 +27,8 @@ export interface RuntimeConfig {
export interface AtmSecrets {
atmPrivateKey: string
adminToken: string
/** Legacy LP admin token — retained until 3d removes the LP backend. */
adminToken?: string
}
declare global {