feat(machine): source LNbits transport from the pairing seed, not just env

Completes the consumer half of bitspire-#70: a paired machine gets its LNbits
transport relay(s) + server pubkey from the pairing, so a blank-.env unit reaches
the backend after scanning a seed — no VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY
provisioning.

- resolveSigner now returns { signer, transport }. transport (relays +
  lnbitsServerPubkey) comes from the seed on a fresh pair / seeded resume, and
  from the binding on a seedless resume. It's threaded out of resolveSigner
  rather than re-parsed in loadLightningConfig because the seed arrives over the
  one-shot get-atm-secrets IPC — a second consumer would break that contract.
- bunker_binding persists relays + lnbits_server_pubkey (state.db v11→v12,
  nullable so pre-#70 bindings resume and fall back to env). Mirrored into
  BunkerBindingRecord (preload + electron.d.ts).
- initializeLightningServices resolves effective transport with env-wins
  precedence (explicit env override for dev, else pairing, else a dev-only
  localhost relay), mutating CONFIG to a single source of truth and building the
  Nostr/LNbits/CLINK clients from the full relay list. Strict + required-config
  validation now run on the resolved values.

state.db round-trip test covers the new columns + their absence on a pre-#70
binding. Renderer + electron typechecks and all 38 machine tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-01 21:09:50 +02:00 • committed by padreug
commit 883c599835
7 changed files with 224 additions and 33 deletions

View file

@ -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
)
}