diff --git a/apps/machine/electron/__tests__/state-store-bunker.test.ts b/apps/machine/electron/__tests__/state-store-bunker.test.ts new file mode 100644 index 0000000..a668059 --- /dev/null +++ b/apps/machine/electron/__tests__/state-store-bunker.test.ts @@ -0,0 +1,69 @@ +/** + * Tests for bunker-binding persistence in state-store (aiolabs/bitspire#52, + * transport config added in #70). + * + * Validates the round-trip of the binding singleton, including the v11→v12 + * transport columns (relays JSON + lnbits_server_pubkey) and their absence on + * a pre-#70 binding. + * + * Uses an in-memory SQLite database — fresh per test, no on-disk artifacts. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + clearBunkerBinding, + closeDatabase, + getBunkerBinding, + initDatabase, + saveBunkerBinding, + type StoredBunkerBinding, +} from '../state-store.js' + +const BASE: StoredBunkerBinding = { + clientSecretHex: 'aa'.repeat(32), + spirePubkey: 'bb'.repeat(32), + bunkerUrl: 'bunker://bb?relay=wss%3A%2F%2Fr%2F&secret=deadbeef', + seedFingerprint: 'cc'.repeat(32), + pairedAt: 1_780_000_000, +} + +beforeEach(() => { + initDatabase(':memory:') +}) +afterEach(() => { + closeDatabase() +}) + +describe('bunker binding persistence', () => { + it('round-trips a binding carrying transport config (#70)', () => { + const binding: StoredBunkerBinding = { + ...BASE, + relays: ['wss://one.relay/', 'wss://two.relay/'], + lnbitsServerPubkey: 'dd'.repeat(32), + } + saveBunkerBinding(binding) + expect(getBunkerBinding()).toEqual(binding) + }) + + it('round-trips a pre-#70 binding (no transport config) as undefined fields', () => { + saveBunkerBinding(BASE) + const got = getBunkerBinding() + expect(got).toEqual(BASE) + expect(got?.relays).toBeUndefined() + expect(got?.lnbitsServerPubkey).toBeUndefined() + }) + + it('upserts transport config in place (re-pair overwrites)', () => { + saveBunkerBinding({ ...BASE, relays: ['wss://old/'], lnbitsServerPubkey: 'ee'.repeat(32) }) + saveBunkerBinding({ ...BASE, relays: ['wss://new/'], lnbitsServerPubkey: 'ff'.repeat(32) }) + const got = getBunkerBinding() + expect(got?.relays).toEqual(['wss://new/']) + expect(got?.lnbitsServerPubkey).toBe('ff'.repeat(32)) + }) + + it('returns null after clear', () => { + saveBunkerBinding(BASE) + clearBunkerBinding() + expect(getBunkerBinding()).toBeNull() + }) +}) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index f1320fb..5483e46 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -51,6 +51,10 @@ export interface BunkerBindingRecord { bunkerUrl: string seedFingerprint: string pairedAt: number + /** LNbits transport relays from the seed (#70); absent on pre-#70 bindings. */ + relays?: string[] + /** LNbits nostr-transport server pubkey (hex) from the seed (#70). */ + lnbitsServerPubkey?: string } /** diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index d9282ac..7ea3e16 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -15,7 +15,7 @@ import fs from 'node:fs' let db: Database.Database | null = null -const SCHEMA_VERSION = '11' +const SCHEMA_VERSION = '12' function getDbPath(): string { const prodDir = '/var/lib/bitspire' @@ -121,7 +121,9 @@ export function initDatabase(dbPath?: string): void { spire_pubkey TEXT NOT NULL, bunker_url TEXT NOT NULL, seed_fingerprint TEXT NOT NULL, - paired_at INTEGER NOT NULL + paired_at INTEGER NOT NULL, + relays TEXT, + lnbits_server_pubkey TEXT ); `) @@ -352,6 +354,21 @@ export function initDatabase(dbPath?: string): void { `) db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('11', 'schema_version') console.log('[StateStore] Migrated schema v10 → v11 (added bunker_binding)') + existing.value = '11' + } + + if (existing && existing.value === '11') { + // Migration v11 → v12: carry the LNbits transport config in the binding + // (aiolabs/bitspire#70). relays (JSON array) + lnbits_server_pubkey let a + // paired machine reach the backend from the pairing alone — no VITE_RELAY_URL + // / VITE_LNBITS_SERVER_PUBKEY provisioning. Nullable: bindings written before + // this (the seed didn't carry them) resume fine and fall back to env. + db.exec(` + ALTER TABLE bunker_binding ADD COLUMN relays TEXT; + ALTER TABLE bunker_binding ADD COLUMN lnbits_server_pubkey TEXT; + `) + db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('12', 'schema_version') + console.log('[StateStore] Migrated schema v11 → v12 (bunker_binding transport config)') } // Defensive: a fresh install at SCHEMA_VERSION skips all migrations. @@ -428,6 +445,14 @@ export interface StoredBunkerBinding { seedFingerprint: string /** Unix seconds when the pairing was redeemed. */ pairedAt: number + /** + * LNbits transport relays from the pairing seed (aiolabs/bitspire#70). Lets a + * resumed (seedless) boot reach the backend without env provisioning. + * Undefined for bindings written before the seed carried them. + */ + relays?: string[] + /** LNbits nostr-transport server pubkey (hex) from the seed (#70). */ + lnbitsServerPubkey?: string } /** Read the persisted bunker binding, or null if the ATM is unpaired. */ @@ -435,7 +460,7 @@ export function getBunkerBinding(): StoredBunkerBinding | null { if (!db) throw new Error('Database not initialized') const row = db .prepare( - 'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at FROM bunker_binding WHERE id = 1' + 'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey FROM bunker_binding WHERE id = 1' ) .get() as | { @@ -444,6 +469,8 @@ export function getBunkerBinding(): StoredBunkerBinding | null { bunker_url: string seed_fingerprint: string paired_at: number + relays: string | null + lnbits_server_pubkey: string | null } | undefined if (!row) return null @@ -453,27 +480,47 @@ export function getBunkerBinding(): StoredBunkerBinding | null { bunkerUrl: row.bunker_url, seedFingerprint: row.seed_fingerprint, pairedAt: row.paired_at, + relays: parseRelaysColumn(row.relays), + lnbitsServerPubkey: row.lnbits_server_pubkey ?? undefined, } } +/** Decode the JSON-array `relays` column, tolerating null/legacy/garbage. */ +function parseRelaysColumn(value: string | null): string[] | undefined { + if (!value) return undefined + try { + const parsed = JSON.parse(value) + if (Array.isArray(parsed) && parsed.every((r) => typeof r === 'string')) { + return parsed as string[] + } + } catch { + // fall through + } + return undefined +} + /** Upsert the bunker binding after a successful (re-)pairing. */ export function saveBunkerBinding(binding: StoredBunkerBinding): void { if (!db) throw new Error('Database not initialized') db.prepare( - `INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at) - VALUES (1, ?, ?, ?, ?, ?) + `INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey) + VALUES (1, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET - client_secret_hex = excluded.client_secret_hex, - spire_pubkey = excluded.spire_pubkey, - bunker_url = excluded.bunker_url, - seed_fingerprint = excluded.seed_fingerprint, - paired_at = excluded.paired_at` + client_secret_hex = excluded.client_secret_hex, + spire_pubkey = excluded.spire_pubkey, + bunker_url = excluded.bunker_url, + seed_fingerprint = excluded.seed_fingerprint, + paired_at = excluded.paired_at, + relays = excluded.relays, + lnbits_server_pubkey = excluded.lnbits_server_pubkey` ).run( binding.clientSecretHex, binding.spirePubkey, binding.bunkerUrl, binding.seedFingerprint, - binding.pairedAt + binding.pairedAt, + binding.relays ? JSON.stringify(binding.relays) : null, + binding.lnbitsServerPubkey ?? null ) } diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index f28b639..c7e1537 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -106,7 +106,8 @@ onMounted(async () => { const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL // Best-effort: resolve a signer (bunker resume / pairing, or dev nsec). // If the ATM isn't paired yet, skip the beacon rather than fail the screen. - const signer = await resolveSigner({ allowEphemeral: true }).catch(() => null) + const resolved = await resolveSigner({ allowEphemeral: true }).catch(() => null) + const signer = resolved?.signer ?? null if (signer && relayUrl) { const client = new NostrClient({ relays: [{ url: relayUrl }], signer }) await client.connect() diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index f673b24..039cf01 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -54,7 +54,10 @@ interface LightningConfig { */ async function loadLightningConfig(): Promise { const defaults: LightningConfig = { - relayUrl: 'ws://localhost:7777', + // Empty when unset (not the dev relay) so initializeLightningServices can + // tell "operator gave us a relay" from "fall back to the pairing seed". See + // aiolabs/bitspire#70 and DEV_DEFAULT_RELAY. + relayUrl: '', appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID operatorPubkeys: [], lnbitsServerPubkey: '', @@ -97,6 +100,9 @@ async function loadLightningConfig(): Promise { // Config is loaded async now - will be set in initializeLightningServices let CONFIG: LightningConfig +/** Dev-only relay used when neither env nor the pairing supplies one. */ +const DEV_DEFAULT_RELAY = 'ws://localhost:7777' + /** Safety timeout in ms (15 minutes) — absolute maximum LNURL session lifetime. * Sessions are normally cleaned up by the state machine on idle transition. * This is a safety net in case the state machine doesn't clean up properly. */ @@ -395,9 +401,6 @@ export async function initializeLightningServices(options?: { // Load configuration (async for Electron runtime config) CONFIG = await loadLightningConfig() - console.log('[Lightning] Relay URL:', CONFIG.relayUrl) - console.log('[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)') - // Resolve the signing identity BEFORE validating the LNbits transport // config. An unpaired machine must reach the QR-pairing wizard regardless // of relay/server-pubkey provisioning — pairing is what provides those — so @@ -410,17 +413,43 @@ export async function initializeLightningServices(options?: { // transport key; the operator's nsecbunkerd holds the signing key); in dev // it falls back to an in-process LocalSigner. The Phase-A Signer seam means // nothing downstream changes. See aiolabs/bitspire#52. - const signer: Signer = await resolveSigner({ allowEphemeral: !options?.strict }) + const { signer, transport } = await resolveSigner({ allowEphemeral: !options?.strict }) console.log('[Lightning] ATM pubkey:', signer.pubkey) - // Strict mode: validate config is production-ready (no localhost). + // Resolve the effective LNbits transport. Precedence: explicit env wins (dev + // + operator override), else the pairing (seed/binding) supplies it (#70) so + // a blank-.env paired machine reaches the backend from the seed alone, else a + // dev-only localhost fallback. CONFIG is mutated to the resolved values so + // downstream (and the exported CONFIG) see a single source of truth. + const envRelay = CONFIG.relayUrl + const envPubkey = CONFIG.lnbitsServerPubkey + const relays: string[] = envRelay + ? [envRelay] + : transport && transport.relays.length > 0 + ? transport.relays + : [DEV_DEFAULT_RELAY] + CONFIG.relayUrl = relays[0]! + CONFIG.lnbitsServerPubkey = envPubkey || transport?.lnbitsServerPubkey || '' + console.log( + '[Lightning] Relay(s):', + relays.join(', '), + envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)', + ) + console.log( + '[Lightning] LNbits server pubkey:', + CONFIG.lnbitsServerPubkey || '(not configured)', + envPubkey ? '(env)' : transport?.lnbitsServerPubkey ? '(pairing)' : '', + ) + + // Strict mode: validate the RESOLVED config is production-ready (no + // localhost). Values may come from env or the pairing seed (#70). if (options?.strict) { const errors: string[] = [] if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) { - errors.push('VITE_RELAY_URL contains localhost') + errors.push('relay resolves to localhost (VITE_RELAY_URL / seed relays)') } if (!CONFIG.lnbitsServerPubkey) { - errors.push('VITE_LNBITS_SERVER_PUBKEY is not set') + errors.push('no LNbits server pubkey (VITE_LNBITS_SERVER_PUBKEY / seed lnbits_npub)') } if (errors.length > 0) { throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- ')) @@ -429,17 +458,17 @@ export async function initializeLightningServices(options?: { // Validate required configuration. Reached only for a paired machine (an // unpaired one threw NoPairingError above) — it needs the LNbits server - // pubkey to talk to the transport. + // pubkey to talk to the transport, from either env or the pairing seed. if (!CONFIG.lnbitsServerPubkey) { throw new Error( - '[Lightning] VITE_LNBITS_SERVER_PUBKEY is required. ' + - 'Get it from: docker logs lnbits | grep nostr_transport pubkey', + '[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' + + 'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).', ) } // Create Nostr client const nostrClient = new NostrClient({ - relays: [{ url: CONFIG.relayUrl }], + relays: relays.map((url) => ({ url })), signer, }) @@ -449,7 +478,7 @@ export async function initializeLightningServices(options?: { // LNbits nostr-transport client. const lnbits = new LnbitsClient({ serverPubkey: CONFIG.lnbitsServerPubkey, - relays: [CONFIG.relayUrl], + relays, }) lnbits.initialize(nostrClient, signer) _lnbitsRef = lnbits @@ -472,7 +501,7 @@ export async function initializeLightningServices(options?: { nostrClient, signer, operatorPubkey: CONFIG.operatorPubkeys, - relays: [CONFIG.relayUrl], + relays, }) // Callbacks for events diff --git a/apps/machine/src/services/signer-resolver.ts b/apps/machine/src/services/signer-resolver.ts index 65f1647..2d29ab6 100644 --- a/apps/machine/src/services/signer-resolver.ts +++ b/apps/machine/src/services/signer-resolver.ts @@ -51,6 +51,25 @@ export interface ResolveSignerOptions { allowEphemeral: boolean } +/** LNbits transport config carried by the pairing (aiolabs/bitspire#70). */ +export interface TransportConfig { + /** LNbits transport relays (kind-21000 / 30078). */ + relays: string[] + /** LNbits nostr-transport server pubkey (hex). */ + lnbitsServerPubkey: string +} + +export interface ResolvedSigner { + signer: Signer + /** + * Transport config sourced from the pairing — the seed on a fresh pair / + * seeded resume, the binding on a seedless resume. Null when unavailable (an + * ephemeral dev signer, or a pre-#70 binding that never stored it); the + * caller then falls back to env provisioning. + */ + transport: TransportConfig | null +} + interface PairingState { spireSeed: string binding: BunkerBindingRecord | null @@ -65,7 +84,7 @@ async function loadPairingState(): Promise { return { spireSeed: (import.meta.env.VITE_SPIRE_SEED as string | undefined) || '', binding: null } } -export async function resolveSigner(opts: ResolveSignerOptions): Promise { +export async function resolveSigner(opts: ResolveSignerOptions): Promise { const { spireSeed, binding } = await loadPairingState() const resume = (b: BunkerBindingRecord): Promise => @@ -75,6 +94,18 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise bunkerUrl: b.bunkerUrl, }) + // Transport config from a binding — present only when the pairing seed + // carried it (post-#70) and it was persisted. Null on pre-#70 bindings. + const transportFromBinding = (b: BunkerBindingRecord): TransportConfig | null => + b.relays && b.relays.length > 0 && b.lnbitsServerPubkey + ? { relays: b.relays, lnbitsServerPubkey: b.lnbitsServerPubkey } + : null + + const transportFromSeed = (s: SpireSeed): TransportConfig => ({ + relays: s.relays, + lnbitsServerPubkey: s.lnbitsServerPubkey, + }) + if (spireSeed) { let seed: SpireSeed let fingerprint: string @@ -92,14 +123,16 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise '[Signer] Stored spire seed is unparseable; resuming from existing binding:', (err as Error).message, ) - return resume(binding) + return { signer: await resume(binding), transport: transportFromBinding(binding) } } throw err } if (binding && binding.seedFingerprint === fingerprint) { console.log('[Signer] Resuming bunker session for spire', seed.spirePubkey) - return resume(binding) + // Seed present + parsed → prefer its (fresh) transport config over the + // binding's, which may predate the seed carrying transport (pre-#70). + return { signer: await resume(binding), transport: transportFromSeed(seed) } } // First pair or re-pair: redeem the one-shot connect secret. @@ -111,23 +144,27 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise clientSecretHex: transport.secretHex, }) if (isElectron && window.electronAPI) { + // Persist the seed's transport config alongside the binding so a later + // seedless resume still reaches the backend without env provisioning. await window.electronAPI.saveBunkerBinding({ clientSecretHex: transport.secretHex, spirePubkey: seed.spirePubkey, bunkerUrl: seed.bunkerUrl, seedFingerprint: fingerprint, pairedAt: Math.floor(Date.now() / 1000), + relays: seed.relays, + lnbitsServerPubkey: seed.lnbitsServerPubkey, }) // Re-pair → re-publish the cassette-state hello to the new operator (#56). await window.electronAPI.resetBootstrapGate() } - return signer + return { signer, transport: transportFromSeed(seed) } } // No seed in this boot but a binding survives → resume. if (binding) { console.log('[Signer] Resuming bunker session from stored binding (no seed this boot)') - return resume(binding) + return { signer: await resume(binding), transport: transportFromBinding(binding) } } if (opts.allowEphemeral) { @@ -135,10 +172,10 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise const devKey = !isElectron ? (import.meta.env.VITE_ATM_PRIVATE_KEY as string | undefined) : '' if (devKey) { console.warn('[Signer] No bunker pairing — using LocalSigner from VITE_ATM_PRIVATE_KEY (dev)') - return new LocalSigner(loadIdentityFromHex(devKey)) + return { signer: new LocalSigner(loadIdentityFromHex(devKey)), transport: null } } console.warn('[Signer] No bunker pairing — generated ephemeral LocalSigner (dev only)') - return new LocalSigner(generateIdentity()) + return { signer: new LocalSigner(generateIdentity()), transport: null } } throw new NoPairingError() diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 64af629..3b17d0e 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -46,6 +46,10 @@ export interface BunkerBindingRecord { bunkerUrl: string seedFingerprint: string pairedAt: number + /** LNbits transport relays from the seed (#70); absent on pre-#70 bindings. */ + relays?: string[] + /** LNbits nostr-transport server pubkey (hex) from the seed (#70). */ + lnbitsServerPubkey?: string } export interface AtmSecrets {