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:
parent
786789f517
commit
883c599835
7 changed files with 224 additions and 33 deletions
69
apps/machine/electron/__tests__/state-store-bunker.test.ts
Normal file
69
apps/machine/electron/__tests__/state-store-bunker.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue