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
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -54,7 +54,10 @@ interface LightningConfig {
|
|||
*/
|
||||
async function loadLightningConfig(): Promise<LightningConfig> {
|
||||
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<LightningConfig> {
|
|||
// 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
|
||||
|
|
|
|||
|
|
@ -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<PairingState> {
|
|||
return { spireSeed: (import.meta.env.VITE_SPIRE_SEED as string | undefined) || '', binding: null }
|
||||
}
|
||||
|
||||
export async function resolveSigner(opts: ResolveSignerOptions): Promise<Signer> {
|
||||
export async function resolveSigner(opts: ResolveSignerOptions): Promise<ResolvedSigner> {
|
||||
const { spireSeed, binding } = await loadPairingState()
|
||||
|
||||
const resume = (b: BunkerBindingRecord): Promise<Signer> =>
|
||||
|
|
@ -75,6 +94,18 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Signer>
|
|||
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>
|
|||
'[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<Signer>
|
|||
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<Signer>
|
|||
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()
|
||||
|
|
|
|||
4
apps/machine/src/types/electron.d.ts
vendored
4
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue