Compare commits
17 commits
936fc9fb46
...
0cc48652aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cc48652aa | |||
| 2af3e29f15 | |||
| 4745790b40 | |||
| 1877959ab5 | |||
| 5f5257daa6 | |||
| 4a45181d12 | |||
| ee2e71d543 | |||
| a8b8b707be | |||
| f003483599 | |||
| 78804b4f89 | |||
| e20faa61ff | |||
| 675b6bfb7c | |||
| d094cf090c | |||
| 1e2a653ad1 | |||
| e57868020b | |||
| 549491a432 | |||
| 53a0c2db51 |
25 changed files with 882 additions and 322 deletions
|
|
@ -82,9 +82,9 @@ Renderer reads (Electron IPC or Vite `import.meta.env`):
|
||||||
|
|
||||||
| Var | Required | Notes |
|
| Var | Required | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) |
|
| `VITE_RELAY_URL` | no (seed-provided) | Relay both ATM and LNbits subscribe to. **Comes from the pairing seed** (aiolabs/bitspire#70); set this only as an override — it WINS over the seed via env-first precedence. Dev override: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) |
|
||||||
| `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) |
|
| `VITE_LNBITS_SERVER_PUBKEY` | no (seed-provided) | 64-char hex transport pubkey. **Comes from the seed's `lnbits_npub`** (#70); env override only. LNbits prints it on startup (`docker logs lnbits \| grep 'Public key (share this)'`) |
|
||||||
| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:<base64url>`) from spirekeeper. Carries a one-shot NIP-46 connect token + the spire signing pubkey + bunker URL. First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. Provisioning it up front is optional — an unpaired machine renders an on-screen QR-pairing wizard that scans the seed off the camera (see below). See aiolabs/bitspire#52. |
|
| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:<base64url>`) from spirekeeper. Carries the relay(s), the LNbits transport pubkey (`lnbits_npub`), the spire signing pubkey (`spire_npub`), and a one-shot NIP-46 connect token (#70 slimmed the shape). First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. Provisioning it up front is optional — an unpaired machine renders an on-screen QR-pairing wizard that scans the seed off the camera (see below). See aiolabs/bitspire#52. |
|
||||||
| `VITE_ATM_PRIVATE_KEY` | dev only | 64-char hex raw nsec fallback for running without a bunker. Ignored when `VITE_SPIRE_SEED` or a stored binding exists. |
|
| `VITE_ATM_PRIVATE_KEY` | dev only | 64-char hex raw nsec fallback for running without a bunker. Ignored when `VITE_SPIRE_SEED` or a stored binding exists. |
|
||||||
| `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands |
|
| `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,15 @@ VITE_LAMASSU_FIAT_CODE=USD
|
||||||
# VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100}]'
|
# VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100}]'
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# LNbits Connection (Required) — nostr-native-transport
|
# LNbits Connection (dev override — normally seed-provided) — nostr-native-transport
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
# On a real machine the pairing SEED (VITE_SPIRE_SEED) carries the relay AND the
|
||||||
|
# server pubkey (aiolabs/bitspire#70), so leave both blank there. Set them here
|
||||||
|
# only for browser dev without a seed/bunker — they WIN over the seed.
|
||||||
|
|
||||||
# Nostr relay WebSocket URL — relay LNbits is subscribed to.
|
# Nostr relay WebSocket URL. Dev stack uses LNbits's bundled nostrrelay:
|
||||||
VITE_RELAY_URL=ws://localhost:7777
|
# VITE_RELAY_URL=ws://localhost:5001/nostrrelay/test
|
||||||
|
VITE_RELAY_URL=
|
||||||
|
|
||||||
# LNbits nostr-transport server pubkey (hex, 64 chars).
|
# LNbits nostr-transport server pubkey (hex, 64 chars).
|
||||||
# Printed by the LNbits server on startup:
|
# Printed by the LNbits server on startup:
|
||||||
|
|
|
||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
getBootstrapPublishedAt,
|
getBootstrapPublishedAt,
|
||||||
markBootstrapPublished,
|
markBootstrapPublished,
|
||||||
resetBootstrapGate,
|
resetBootstrapGate,
|
||||||
|
resetForRepair,
|
||||||
applyOperatorCassettesConfig,
|
applyOperatorCassettesConfig,
|
||||||
getFeeConfig,
|
getFeeConfig,
|
||||||
getLastKnownFeeConfigCreatedAt,
|
getLastKnownFeeConfigCreatedAt,
|
||||||
|
|
@ -278,8 +279,11 @@ ipcMain.handle('watchdog:pong', () => {
|
||||||
// pragma: allowlist secret end
|
// pragma: allowlist secret end
|
||||||
ipcMain.handle('get-config', () => {
|
ipcMain.handle('get-config', () => {
|
||||||
return {
|
return {
|
||||||
// LNbits nostr-transport connection (public info only)
|
// LNbits nostr-transport connection (public info only). Empty when
|
||||||
relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777',
|
// unprovisioned — the renderer then falls through to the pairing seed's
|
||||||
|
// relay (aiolabs/bitspire#70). A non-empty default here would win via the
|
||||||
|
// env-first precedence and override the seed.
|
||||||
|
relayUrl: process.env.VITE_RELAY_URL || '',
|
||||||
lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '',
|
lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '',
|
||||||
appId: process.env.VITE_APP_ID || '',
|
appId: process.env.VITE_APP_ID || '',
|
||||||
|
|
||||||
|
|
@ -352,6 +356,9 @@ ipcMain.handle('state:clear-bunker-binding', (): void => {
|
||||||
ipcMain.handle('state:reset-bootstrap-gate', (): void => {
|
ipcMain.handle('state:reset-bootstrap-gate', (): void => {
|
||||||
resetBootstrapGate()
|
resetBootstrapGate()
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('state:reset-for-repair', (): void => {
|
||||||
|
resetForRepair()
|
||||||
|
})
|
||||||
|
|
||||||
// QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a
|
// QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a
|
||||||
// spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the
|
// spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,6 @@ export interface RuntimeConfig {
|
||||||
relayUrl: string
|
relayUrl: string
|
||||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||||
lnbitsServerPubkey: string
|
lnbitsServerPubkey: string
|
||||||
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
|
|
||||||
lightningPubPubkey?: string
|
|
||||||
lightningPubApiUrl?: string
|
|
||||||
extensionApiUrl?: string
|
|
||||||
appId: string
|
appId: string
|
||||||
machineModel: string
|
machineModel: string
|
||||||
fiatCode: string
|
fiatCode: string
|
||||||
|
|
@ -51,6 +47,10 @@ export interface BunkerBindingRecord {
|
||||||
bunkerUrl: string
|
bunkerUrl: string
|
||||||
seedFingerprint: string
|
seedFingerprint: string
|
||||||
pairedAt: number
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -118,6 +118,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
ipcRenderer.invoke('state:save-bunker-binding', binding),
|
ipcRenderer.invoke('state:save-bunker-binding', binding),
|
||||||
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
|
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
|
||||||
resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'),
|
resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'),
|
||||||
|
resetForRepair: (): Promise<void> => ipcRenderer.invoke('state:reset-for-repair'),
|
||||||
|
|
||||||
// QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed,
|
// QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed,
|
||||||
// then relaunch so the normal boot flow pairs it.
|
// then relaunch so the normal boot flow pairs it.
|
||||||
|
|
@ -239,6 +240,7 @@ declare global {
|
||||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||||
clearBunkerBinding: () => Promise<void>
|
clearBunkerBinding: () => Promise<void>
|
||||||
resetBootstrapGate: () => Promise<void>
|
resetBootstrapGate: () => Promise<void>
|
||||||
|
resetForRepair: () => Promise<void>
|
||||||
saveSpireSeed: (seed: string) => Promise<void>
|
saveSpireSeed: (seed: string) => Promise<void>
|
||||||
relaunchApp: () => Promise<void>
|
relaunchApp: () => Promise<void>
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import fs from 'node:fs'
|
||||||
|
|
||||||
let db: Database.Database | null = null
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
const SCHEMA_VERSION = '11'
|
const SCHEMA_VERSION = '12'
|
||||||
|
|
||||||
function getDbPath(): string {
|
function getDbPath(): string {
|
||||||
const prodDir = '/var/lib/bitspire'
|
const prodDir = '/var/lib/bitspire'
|
||||||
|
|
@ -121,7 +121,9 @@ export function initDatabase(dbPath?: string): void {
|
||||||
spire_pubkey TEXT NOT NULL,
|
spire_pubkey TEXT NOT NULL,
|
||||||
bunker_url TEXT NOT NULL,
|
bunker_url TEXT NOT NULL,
|
||||||
seed_fingerprint 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')
|
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('11', 'schema_version')
|
||||||
console.log('[StateStore] Migrated schema v10 → v11 (added bunker_binding)')
|
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.
|
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
|
||||||
|
|
@ -428,6 +445,14 @@ export interface StoredBunkerBinding {
|
||||||
seedFingerprint: string
|
seedFingerprint: string
|
||||||
/** Unix seconds when the pairing was redeemed. */
|
/** Unix seconds when the pairing was redeemed. */
|
||||||
pairedAt: number
|
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. */
|
/** 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')
|
if (!db) throw new Error('Database not initialized')
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.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
|
.get() as
|
||||||
| {
|
| {
|
||||||
|
|
@ -444,6 +469,8 @@ export function getBunkerBinding(): StoredBunkerBinding | null {
|
||||||
bunker_url: string
|
bunker_url: string
|
||||||
seed_fingerprint: string
|
seed_fingerprint: string
|
||||||
paired_at: number
|
paired_at: number
|
||||||
|
relays: string | null
|
||||||
|
lnbits_server_pubkey: string | null
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
|
|
@ -453,27 +480,47 @@ export function getBunkerBinding(): StoredBunkerBinding | null {
|
||||||
bunkerUrl: row.bunker_url,
|
bunkerUrl: row.bunker_url,
|
||||||
seedFingerprint: row.seed_fingerprint,
|
seedFingerprint: row.seed_fingerprint,
|
||||||
pairedAt: row.paired_at,
|
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. */
|
/** Upsert the bunker binding after a successful (re-)pairing. */
|
||||||
export function saveBunkerBinding(binding: StoredBunkerBinding): void {
|
export function saveBunkerBinding(binding: StoredBunkerBinding): void {
|
||||||
if (!db) throw new Error('Database not initialized')
|
if (!db) throw new Error('Database not initialized')
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at)
|
`INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey)
|
||||||
VALUES (1, ?, ?, ?, ?, ?)
|
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
client_secret_hex = excluded.client_secret_hex,
|
client_secret_hex = excluded.client_secret_hex,
|
||||||
spire_pubkey = excluded.spire_pubkey,
|
spire_pubkey = excluded.spire_pubkey,
|
||||||
bunker_url = excluded.bunker_url,
|
bunker_url = excluded.bunker_url,
|
||||||
seed_fingerprint = excluded.seed_fingerprint,
|
seed_fingerprint = excluded.seed_fingerprint,
|
||||||
paired_at = excluded.paired_at`
|
paired_at = excluded.paired_at,
|
||||||
|
relays = excluded.relays,
|
||||||
|
lnbits_server_pubkey = excluded.lnbits_server_pubkey`
|
||||||
).run(
|
).run(
|
||||||
binding.clientSecretHex,
|
binding.clientSecretHex,
|
||||||
binding.spirePubkey,
|
binding.spirePubkey,
|
||||||
binding.bunkerUrl,
|
binding.bunkerUrl,
|
||||||
binding.seedFingerprint,
|
binding.seedFingerprint,
|
||||||
binding.pairedAt
|
binding.pairedAt,
|
||||||
|
binding.relays ? JSON.stringify(binding.relays) : null,
|
||||||
|
binding.lnbitsServerPubkey ?? null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -493,6 +540,32 @@ export function resetBootstrapGate(): void {
|
||||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt')
|
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipe operator-scoped CONFIG/TRUST state on a re-pair to a new operator/backend,
|
||||||
|
* so stale policy from the previous pairing can't linger or silently reject the
|
||||||
|
* new operator's config.
|
||||||
|
*
|
||||||
|
* Clears the fee config and resets BOTH replay watermarks to 0. The watermark
|
||||||
|
* reset is the load-bearing part: without it, a new backend whose first config
|
||||||
|
* event has a lower `created_at` than the old operator's last event is silently
|
||||||
|
* dropped as a replay — the exact remnant trap where re-pairing a long-lived
|
||||||
|
* install to a fresh backend appears to "work" but never picks up new config.
|
||||||
|
*
|
||||||
|
* Deliberately does NOT touch cassettes / cashbox / transactions: those track
|
||||||
|
* PHYSICAL cash, which survives an operator handover. A full wipe (decommission
|
||||||
|
* or a truly-fresh test) is the factory-reset path, not this.
|
||||||
|
*/
|
||||||
|
export function resetForRepair(): void {
|
||||||
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
const database = db
|
||||||
|
database.transaction(() => {
|
||||||
|
database.prepare('DELETE FROM fee_config').run()
|
||||||
|
const setWatermark = database.prepare('UPDATE meta SET value = ? WHERE key = ?')
|
||||||
|
setWatermark.run('0', 'lastKnownFeeConfigCreatedAt')
|
||||||
|
setWatermark.run('0', 'lastKnownConfigCreatedAt')
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
export type OperatorCassettesPayload = {
|
export type OperatorCassettesPayload = {
|
||||||
positions: Record<string, { denomination: number; count: number }>
|
positions: Record<string, { denomination: number; count: number }>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,10 +103,16 @@ onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const { NostrClient, createSignedEvent } = await import('@bitSpire/nostr-client')
|
const { NostrClient, createSignedEvent } = await import('@bitSpire/nostr-client')
|
||||||
const { resolveSigner } = await import('@/services/signer-resolver')
|
const { resolveSigner } = await import('@/services/signer-resolver')
|
||||||
const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL
|
|
||||||
// Best-effort: resolve a signer (bunker resume / pairing, or dev nsec).
|
// 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.
|
// 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
|
||||||
|
// Same env → pairing-seed precedence as lightning.ts: on a blank-.env
|
||||||
|
// seed-driven machine the relay comes from the pairing transport, not env.
|
||||||
|
const relayUrl =
|
||||||
|
config?.relayUrl ||
|
||||||
|
import.meta.env.VITE_RELAY_URL ||
|
||||||
|
resolved?.transport?.relays?.[0]
|
||||||
if (signer && relayUrl) {
|
if (signer && relayUrl) {
|
||||||
const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
|
const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
|
||||||
await client.connect()
|
await client.connect()
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,18 @@
|
||||||
* Capture is abstracted behind PairingSource, so NFC (or a HAL scanner) can be
|
* Capture is abstracted behind PairingSource, so NFC (or a HAL scanner) can be
|
||||||
* offered later without changing this view.
|
* offered later without changing this view.
|
||||||
*/
|
*/
|
||||||
import { onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||||||
import {
|
import {
|
||||||
availablePairingSources,
|
availablePairingSources,
|
||||||
ingestScannedSeed,
|
ingestScannedSeed,
|
||||||
|
parseScannedSeed,
|
||||||
|
testRelay,
|
||||||
type PairingSource,
|
type PairingSource,
|
||||||
|
type RelayTestResult,
|
||||||
type StopCapture,
|
type StopCapture,
|
||||||
} from '@/services/pairing'
|
} from '@/services/pairing'
|
||||||
|
|
||||||
type Phase = 'probing' | 'scanning' | 'no-source' | 'pairing' | 'error'
|
type Phase = 'probing' | 'scanning' | 'review' | 'no-source' | 'pairing' | 'error'
|
||||||
|
|
||||||
const phase = ref<Phase>('probing')
|
const phase = ref<Phase>('probing')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
|
@ -28,6 +31,19 @@ const sources = shallowRef<PairingSource[]>([])
|
||||||
const activeSource = shallowRef<PairingSource | null>(null)
|
const activeSource = shallowRef<PairingSource | null>(null)
|
||||||
let stopCapture: StopCapture | null = null
|
let stopCapture: StopCapture | null = null
|
||||||
|
|
||||||
|
// Review-step state: the scanned-but-not-yet-committed seed + relay tests.
|
||||||
|
const scannedRaw = ref('')
|
||||||
|
const previewSpire = ref('')
|
||||||
|
const previewRelays = ref<string[]>([])
|
||||||
|
type RelayState = { status: 'idle' | 'testing' | 'done'; result?: RelayTestResult }
|
||||||
|
const relayTests = ref<Record<string, RelayState>>({})
|
||||||
|
const testingRelays = ref(false)
|
||||||
|
const committing = ref(false)
|
||||||
|
|
||||||
|
const anyRelayFailed = computed(() =>
|
||||||
|
Object.values(relayTests.value).some((s) => s.status === 'done' && s.result != null && !s.result.ok),
|
||||||
|
)
|
||||||
|
|
||||||
async function startWith(source: PairingSource) {
|
async function startWith(source: PairingSource) {
|
||||||
await teardown()
|
await teardown()
|
||||||
activeSource.value = source
|
activeSource.value = source
|
||||||
|
|
@ -50,18 +66,58 @@ let handling = false
|
||||||
async function handleScan(raw: string) {
|
async function handleScan(raw: string) {
|
||||||
if (handling) return
|
if (handling) return
|
||||||
handling = true
|
handling = true
|
||||||
const result = await ingestScannedSeed(raw)
|
// Validate only — don't commit yet. Show a review step with the decoded
|
||||||
if (result.ok) {
|
// relay + a "test relay" button so a well-formed but unreachable relay is
|
||||||
// saveSpireSeed succeeded; relaunch is in flight — hold a friendly screen.
|
// caught before we relaunch into a pairing crash-loop (aiolabs/bitspire#70).
|
||||||
phase.value = 'pairing'
|
const preview = parseScannedSeed(raw)
|
||||||
|
if (preview.ok) {
|
||||||
|
await teardown() // camera off during review
|
||||||
|
scannedRaw.value = raw.trim()
|
||||||
|
previewSpire.value = preview.spirePubkey
|
||||||
|
previewRelays.value = preview.relays
|
||||||
|
relayTests.value = Object.fromEntries(preview.relays.map((r) => [r, { status: 'idle' }]))
|
||||||
|
errorMessage.value = ''
|
||||||
|
phase.value = 'review'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Reject non-seed scans (a stray QR) and resume scanning.
|
// Reject non-seed / malformed scans (a stray QR, a corrupted relay) and resume.
|
||||||
console.warn('[Pairing] rejected scan:', result.reason, result.message)
|
console.warn('[Pairing] rejected scan:', preview.reason, preview.message)
|
||||||
errorMessage.value =
|
errorMessage.value = 'That code is not a valid pairing code. Show the operator pairing QR.'
|
||||||
result.reason === 'invalid-seed'
|
handling = false
|
||||||
? 'That code is not a pairing code. Show the operator pairing QR.'
|
if (activeSource.value) await startWith(activeSource.value)
|
||||||
: result.message
|
}
|
||||||
|
|
||||||
|
/** Probe every relay in the scanned seed and record reachability. */
|
||||||
|
async function testRelays() {
|
||||||
|
testingRelays.value = true
|
||||||
|
await Promise.all(
|
||||||
|
previewRelays.value.map(async (url) => {
|
||||||
|
relayTests.value[url] = { status: 'testing' }
|
||||||
|
const result = await testRelay(url)
|
||||||
|
relayTests.value[url] = { status: 'done', result }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
testingRelays.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commit the reviewed seed: persist + relaunch into the real pairing path. */
|
||||||
|
async function confirmPair() {
|
||||||
|
committing.value = true
|
||||||
|
const result = await ingestScannedSeed(scannedRaw.value)
|
||||||
|
if (result.ok) {
|
||||||
|
phase.value = 'pairing' // relaunch in flight
|
||||||
|
return
|
||||||
|
}
|
||||||
|
committing.value = false
|
||||||
|
errorMessage.value = result.message
|
||||||
|
phase.value = 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discard the scan and go back to scanning. */
|
||||||
|
async function rescan() {
|
||||||
|
scannedRaw.value = ''
|
||||||
|
previewRelays.value = []
|
||||||
|
relayTests.value = {}
|
||||||
handling = false
|
handling = false
|
||||||
if (activeSource.value) await startWith(activeSource.value)
|
if (activeSource.value) await startWith(activeSource.value)
|
||||||
}
|
}
|
||||||
|
|
@ -101,7 +157,17 @@ onUnmounted(teardown)
|
||||||
class="relative overflow-hidden rounded-2xl border-4 border-primary/40 bg-black"
|
class="relative overflow-hidden rounded-2xl border-4 border-primary/40 bg-black"
|
||||||
style="width: min(80vw, 28rem); aspect-ratio: 1 / 1"
|
style="width: min(80vw, 28rem); aspect-ratio: 1 / 1"
|
||||||
>
|
>
|
||||||
<video ref="videoEl" class="h-full w-full object-cover" muted autoplay playsinline></video>
|
<!-- The Sintra's camera is mounted rotated, so rotate the preview 90° CCW
|
||||||
|
for an upright image. Preview-only: qr-source decodes the raw (un-
|
||||||
|
rotated) frame and QR decoding is rotation-invariant. The container is
|
||||||
|
square + overflow-hidden, so the rotated square stays in the box. -->
|
||||||
|
<video
|
||||||
|
ref="videoEl"
|
||||||
|
class="h-full w-full -rotate-90 object-cover"
|
||||||
|
muted
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
></video>
|
||||||
<!-- Reticle -->
|
<!-- Reticle -->
|
||||||
<div class="pointer-events-none absolute inset-6 rounded-xl border-2 border-white/70"></div>
|
<div class="pointer-events-none absolute inset-6 rounded-xl border-2 border-white/70"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -121,6 +187,67 @@ onUnmounted(teardown)
|
||||||
<p class="text-base lg:text-2xl text-muted-foreground">Pairing accepted — restarting…</p>
|
<p class="text-base lg:text-2xl text-muted-foreground">Pairing accepted — restarting…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Review: confirm the scanned relay is reachable before committing -->
|
||||||
|
<div v-if="phase === 'review'" class="flex w-full max-w-md flex-col items-center gap-5">
|
||||||
|
<p class="text-base lg:text-2xl text-muted-foreground">
|
||||||
|
Pairing code scanned. Test the relay, then pair.
|
||||||
|
</p>
|
||||||
|
<div class="w-full rounded-xl border border-border p-4 text-left">
|
||||||
|
<p class="text-xs uppercase text-muted-foreground">Spire</p>
|
||||||
|
<p class="mb-3 break-all font-mono text-sm">{{ previewSpire.slice(0, 16) }}…</p>
|
||||||
|
<p class="text-xs uppercase text-muted-foreground">Relay(s)</p>
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
<li
|
||||||
|
v-for="url in previewRelays"
|
||||||
|
:key="url"
|
||||||
|
class="flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<span class="break-all font-mono text-xs">{{ url }}</span>
|
||||||
|
<span class="shrink-0 text-sm">
|
||||||
|
<template v-if="relayTests[url]?.status === 'testing'">
|
||||||
|
<span class="text-muted-foreground">testing…</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="relayTests[url]?.status === 'done'">
|
||||||
|
<span v-if="relayTests[url]?.result?.ok" class="text-green-500"
|
||||||
|
>✓ {{ relayTests[url]?.result?.ms }}ms</span
|
||||||
|
>
|
||||||
|
<span v-else class="text-destructive">✗ unreachable</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-center gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-border px-4 py-2 text-sm disabled:opacity-50"
|
||||||
|
:disabled="testingRelays || committing"
|
||||||
|
@click="testRelays"
|
||||||
|
>
|
||||||
|
{{ testingRelays ? 'Testing…' : 'Test relay' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-border px-4 py-2 text-sm disabled:opacity-50"
|
||||||
|
:disabled="committing"
|
||||||
|
@click="rescan"
|
||||||
|
>
|
||||||
|
Rescan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
|
||||||
|
:disabled="committing"
|
||||||
|
@click="confirmPair"
|
||||||
|
>
|
||||||
|
{{ committing ? 'Pairing…' : 'Pair this machine' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="anyRelayFailed" class="max-w-md text-center text-sm text-warning">
|
||||||
|
A relay looks unreachable from this machine — pairing will fail unless it can reach the
|
||||||
|
relay. Check the URL/network, or rescan a corrected code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="phase === 'no-source'"
|
v-if="phase === 'no-source'"
|
||||||
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"
|
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,10 @@ interface LightningConfig {
|
||||||
*/
|
*/
|
||||||
async function loadLightningConfig(): Promise<LightningConfig> {
|
async function loadLightningConfig(): Promise<LightningConfig> {
|
||||||
const defaults: 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
|
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID
|
||||||
operatorPubkeys: [],
|
operatorPubkeys: [],
|
||||||
lnbitsServerPubkey: '',
|
lnbitsServerPubkey: '',
|
||||||
|
|
@ -97,6 +100,10 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
||||||
// Config is loaded async now - will be set in initializeLightningServices
|
// Config is loaded async now - will be set in initializeLightningServices
|
||||||
let CONFIG: LightningConfig
|
let CONFIG: LightningConfig
|
||||||
|
|
||||||
|
/** Dev-only relay used when neither env nor the pairing supplies one. Matches
|
||||||
|
* the dev stack — LNbits's bundled nostrrelay (no separate strfry container). */
|
||||||
|
const DEV_DEFAULT_RELAY = 'ws://localhost:5001/nostrrelay/test'
|
||||||
|
|
||||||
/** Safety timeout in ms (15 minutes) — absolute maximum LNURL session lifetime.
|
/** Safety timeout in ms (15 minutes) — absolute maximum LNURL session lifetime.
|
||||||
* Sessions are normally cleaned up by the state machine on idle transition.
|
* 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. */
|
* This is a safety net in case the state machine doesn't clean up properly. */
|
||||||
|
|
@ -395,9 +402,6 @@ export async function initializeLightningServices(options?: {
|
||||||
// Load configuration (async for Electron runtime config)
|
// Load configuration (async for Electron runtime config)
|
||||||
CONFIG = await loadLightningConfig()
|
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
|
// Resolve the signing identity BEFORE validating the LNbits transport
|
||||||
// config. An unpaired machine must reach the QR-pairing wizard regardless
|
// config. An unpaired machine must reach the QR-pairing wizard regardless
|
||||||
// of relay/server-pubkey provisioning — pairing is what provides those — so
|
// of relay/server-pubkey provisioning — pairing is what provides those — so
|
||||||
|
|
@ -410,17 +414,53 @@ export async function initializeLightningServices(options?: {
|
||||||
// transport key; the operator's nsecbunkerd holds the signing key); in dev
|
// 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
|
// it falls back to an in-process LocalSigner. The Phase-A Signer seam means
|
||||||
// nothing downstream changes. See aiolabs/bitspire#52.
|
// 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)
|
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)' : '',
|
||||||
|
)
|
||||||
|
// Operator pubkey provenance. Today the ONLY source is VITE_OPERATOR_PUBKEYS
|
||||||
|
// (env). An empty set disables the fees/operator-config services → the machine
|
||||||
|
// sits at "awaiting configuration" — so log it loudly rather than fail silent.
|
||||||
|
// (aiolabs/bitspire#70 P1 will source this from LNbits over the transport.)
|
||||||
|
console.log(
|
||||||
|
'[Lightning] Operator pubkey(s):',
|
||||||
|
CONFIG.operatorPubkeys.length
|
||||||
|
? CONFIG.operatorPubkeys.join(', ') + ' (env)'
|
||||||
|
: '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Strict mode: validate the RESOLVED config is production-ready (no
|
||||||
|
// localhost). Values may come from env or the pairing seed (#70).
|
||||||
if (options?.strict) {
|
if (options?.strict) {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) {
|
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) {
|
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) {
|
if (errors.length > 0) {
|
||||||
throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- '))
|
throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- '))
|
||||||
|
|
@ -429,17 +469,17 @@ export async function initializeLightningServices(options?: {
|
||||||
|
|
||||||
// Validate required configuration. Reached only for a paired machine (an
|
// Validate required configuration. Reached only for a paired machine (an
|
||||||
// unpaired one threw NoPairingError above) — it needs the LNbits server
|
// 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) {
|
if (!CONFIG.lnbitsServerPubkey) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'[Lightning] VITE_LNBITS_SERVER_PUBKEY is required. ' +
|
'[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' +
|
||||||
'Get it from: docker logs lnbits | grep nostr_transport pubkey',
|
'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Nostr client
|
// Create Nostr client
|
||||||
const nostrClient = new NostrClient({
|
const nostrClient = new NostrClient({
|
||||||
relays: [{ url: CONFIG.relayUrl }],
|
relays: relays.map((url) => ({ url })),
|
||||||
signer,
|
signer,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -449,7 +489,7 @@ export async function initializeLightningServices(options?: {
|
||||||
// LNbits nostr-transport client.
|
// LNbits nostr-transport client.
|
||||||
const lnbits = new LnbitsClient({
|
const lnbits = new LnbitsClient({
|
||||||
serverPubkey: CONFIG.lnbitsServerPubkey,
|
serverPubkey: CONFIG.lnbitsServerPubkey,
|
||||||
relays: [CONFIG.relayUrl],
|
relays,
|
||||||
})
|
})
|
||||||
lnbits.initialize(nostrClient, signer)
|
lnbits.initialize(nostrClient, signer)
|
||||||
_lnbitsRef = lnbits
|
_lnbitsRef = lnbits
|
||||||
|
|
@ -472,7 +512,7 @@ export async function initializeLightningServices(options?: {
|
||||||
nostrClient,
|
nostrClient,
|
||||||
signer,
|
signer,
|
||||||
operatorPubkey: CONFIG.operatorPubkeys,
|
operatorPubkey: CONFIG.operatorPubkeys,
|
||||||
relays: [CONFIG.relayUrl],
|
relays,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Callbacks for events
|
// Callbacks for events
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
import { ingestScannedSeed } from '../ingest'
|
import { ingestScannedSeed } from '../ingest'
|
||||||
import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client'
|
import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client'
|
||||||
|
import { npubEncode } from 'nostr-tools/nip19'
|
||||||
|
|
||||||
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
|
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
|
||||||
function makeSeed(json: unknown): string {
|
function makeSeed(json: unknown): string {
|
||||||
|
|
@ -15,9 +16,9 @@ function makeSeed(json: unknown): string {
|
||||||
const SPIRE_PUBKEY = 'a'.repeat(64)
|
const SPIRE_PUBKEY = 'a'.repeat(64)
|
||||||
const VALID_SEED = makeSeed({
|
const VALID_SEED = makeSeed({
|
||||||
v: 1,
|
v: 1,
|
||||||
spire_npub: 'npub1example',
|
spire_npub: npubEncode(SPIRE_PUBKEY),
|
||||||
spire_pubkey: SPIRE_PUBKEY,
|
lnbits_npub: npubEncode('b'.repeat(64)),
|
||||||
bunker_url: `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef`,
|
bunker_secret: 'deadbeef',
|
||||||
relays: ['wss://events.relay/'],
|
relays: ['wss://events.relay/'],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,10 @@ import type { PairingSource } from './types'
|
||||||
export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types'
|
export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types'
|
||||||
export { QrPairingSource } from './qr-source'
|
export { QrPairingSource } from './qr-source'
|
||||||
export { NfcPairingSource } from './nfc-source'
|
export { NfcPairingSource } from './nfc-source'
|
||||||
export { ingestScannedSeed } from './ingest'
|
export { ingestScannedSeed, parseScannedSeed } from './ingest'
|
||||||
export type { IngestResult } from './ingest'
|
export type { IngestResult, SeedPreview } from './ingest'
|
||||||
|
export { testRelay } from './relay-test'
|
||||||
|
export type { RelayTestResult } from './relay-test'
|
||||||
|
|
||||||
/** All sources in preference order, regardless of availability. */
|
/** All sources in preference order, regardless of availability. */
|
||||||
export function allPairingSources(): PairingSource[] {
|
export function allPairingSources(): PairingSource[] {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,37 @@ export type IngestResult =
|
||||||
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
|
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
|
||||||
| { ok: false; reason: 'invalid-seed' | 'no-bridge' | 'persist-failed'; message: string }
|
| { ok: false; reason: 'invalid-seed' | 'no-bridge' | 'persist-failed'; message: string }
|
||||||
|
|
||||||
|
export type SeedPreview =
|
||||||
|
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
|
||||||
|
| { ok: false; reason: 'invalid-seed'; message: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate-only: parse a scanned payload as a spire-seed WITHOUT persisting or
|
||||||
|
* relaunching. The wizard uses this to show a review step (decoded relay + a
|
||||||
|
* "test relay" button) before committing, so a well-formed but unreachable
|
||||||
|
* relay is caught before the machine relaunches into a pairing crash-loop.
|
||||||
|
* `parseSpireSeed` already rejects a malformed relay (e.g. a QR misread of
|
||||||
|
* `ws://` → `As://`); this surfaces that as an invalid-seed rejection.
|
||||||
|
*/
|
||||||
|
export function parseScannedSeed(raw: string): SeedPreview {
|
||||||
|
const trimmed = (raw || '').trim()
|
||||||
|
try {
|
||||||
|
const seed = parseSpireSeed(trimmed)
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
spirePubkey: seed.spirePubkey,
|
||||||
|
fingerprint: seedFingerprint(trimmed),
|
||||||
|
relays: seed.relays,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'invalid-seed',
|
||||||
|
message: e instanceof Error ? e.message : 'Not a valid pairing code',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function ingestScannedSeed(raw: string): Promise<IngestResult> {
|
export async function ingestScannedSeed(raw: string): Promise<IngestResult> {
|
||||||
const trimmed = (raw || '').trim()
|
const trimmed = (raw || '').trim()
|
||||||
|
|
||||||
|
|
|
||||||
69
apps/machine/src/services/pairing/relay-test.ts
Normal file
69
apps/machine/src/services/pairing/relay-test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
/**
|
||||||
|
* Relay reachability probe for the pairing wizard (aiolabs/bitspire#70).
|
||||||
|
*
|
||||||
|
* `parseSpireSeed` catches a MALFORMED relay (e.g. a QR misread of `ws://` into
|
||||||
|
* `As://`), but a well-formed-yet-unreachable relay — `ws://localhost:…` baked
|
||||||
|
* into a seed for a remote machine, a wrong LAN IP, or a relay that's simply
|
||||||
|
* down — still parses fine and would only fail later as a NIP-46 connect
|
||||||
|
* crash-loop. This opens a WebSocket to the relay (and sends a NIP-01 REQ so a
|
||||||
|
* real relay answers) so the operator can confirm reachability on-machine,
|
||||||
|
* before committing the pairing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RelayTestResult {
|
||||||
|
url: string
|
||||||
|
ok: boolean
|
||||||
|
/** Round-trip time to open (ms), when reachable. */
|
||||||
|
ms?: number
|
||||||
|
/** True when the relay answered our REQ — i.e. it's actually a nostr relay. */
|
||||||
|
answered?: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a WebSocket to `url` and report whether it connects within `timeoutMs`. */
|
||||||
|
export function testRelay(url: string, timeoutMs = 6000): Promise<RelayTestResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const start = Date.now()
|
||||||
|
let ws: WebSocket | null = null
|
||||||
|
let settled = false
|
||||||
|
|
||||||
|
const finish = (r: Omit<RelayTestResult, 'url'>): void => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
try {
|
||||||
|
ws?.close()
|
||||||
|
} catch {
|
||||||
|
/* already closing */
|
||||||
|
}
|
||||||
|
resolve({ url, ...r })
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => finish({ ok: false, error: `timed out after ${timeoutMs}ms` }),
|
||||||
|
timeoutMs,
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(url)
|
||||||
|
} catch (e) {
|
||||||
|
finish({ ok: false, error: e instanceof Error ? e.message : 'invalid relay URL' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
// Connected. Probe it as a nostr relay; a genuine relay replies (EOSE /
|
||||||
|
// notice). If it stays silent we still count the open as reachable.
|
||||||
|
try {
|
||||||
|
ws?.send(JSON.stringify(['REQ', 'bitspire-relay-test', { limit: 0 }]))
|
||||||
|
} catch {
|
||||||
|
/* send failed, but the socket opened → still reachable */
|
||||||
|
}
|
||||||
|
const graceMs = Math.min(600, timeoutMs)
|
||||||
|
setTimeout(() => finish({ ok: true, ms: Date.now() - start, answered: false }), graceMs)
|
||||||
|
}
|
||||||
|
ws.onmessage = () => finish({ ok: true, ms: Date.now() - start, answered: true })
|
||||||
|
ws.onerror = () =>
|
||||||
|
finish({ ok: false, error: 'connection failed (unreachable or not a relay)' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
parseSpireSeed,
|
parseSpireSeed,
|
||||||
seedFingerprint,
|
seedFingerprint,
|
||||||
type Signer,
|
type Signer,
|
||||||
|
type SpireSeed,
|
||||||
} from '@bitSpire/nostr-client'
|
} from '@bitSpire/nostr-client'
|
||||||
import type { BunkerBindingRecord } from '@/types/electron'
|
import type { BunkerBindingRecord } from '@/types/electron'
|
||||||
|
|
||||||
|
|
@ -50,6 +51,25 @@ export interface ResolveSignerOptions {
|
||||||
allowEphemeral: boolean
|
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 {
|
interface PairingState {
|
||||||
spireSeed: string
|
spireSeed: string
|
||||||
binding: BunkerBindingRecord | null
|
binding: BunkerBindingRecord | null
|
||||||
|
|
@ -64,20 +84,55 @@ async function loadPairingState(): Promise<PairingState> {
|
||||||
return { spireSeed: (import.meta.env.VITE_SPIRE_SEED as string | undefined) || '', binding: null }
|
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 { spireSeed, binding } = await loadPairingState()
|
||||||
|
|
||||||
|
const resume = (b: BunkerBindingRecord): Promise<Signer> =>
|
||||||
|
resumeFromBinding({
|
||||||
|
clientSecretHex: b.clientSecretHex,
|
||||||
|
spirePubkey: b.spirePubkey,
|
||||||
|
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) {
|
if (spireSeed) {
|
||||||
const seed = parseSpireSeed(spireSeed)
|
let seed: SpireSeed
|
||||||
const fingerprint = seedFingerprint(spireSeed)
|
let fingerprint: string
|
||||||
|
try {
|
||||||
|
seed = parseSpireSeed(spireSeed)
|
||||||
|
fingerprint = seedFingerprint(spireSeed)
|
||||||
|
} catch (err) {
|
||||||
|
// A stored seed we can't parse — e.g. a legacy-shape seed left in .env
|
||||||
|
// after the seed format changed (bitspire-#70). If we already hold a
|
||||||
|
// binding it's authoritative (server-persistent), so resume from it
|
||||||
|
// rather than bricking a paired machine on the next boot. With no
|
||||||
|
// binding the seed is our only pairing input, so fail closed.
|
||||||
|
if (binding) {
|
||||||
|
console.warn(
|
||||||
|
'[Signer] Stored spire seed is unparseable; resuming from existing binding:',
|
||||||
|
(err as Error).message,
|
||||||
|
)
|
||||||
|
return { signer: await resume(binding), transport: transportFromBinding(binding) }
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
if (binding && binding.seedFingerprint === fingerprint) {
|
if (binding && binding.seedFingerprint === fingerprint) {
|
||||||
console.log('[Signer] Resuming bunker session for spire', seed.spirePubkey)
|
console.log('[Signer] Resuming bunker session for spire', seed.spirePubkey)
|
||||||
return resumeFromBinding({
|
// Seed present + parsed → prefer its (fresh) transport config over the
|
||||||
clientSecretHex: binding.clientSecretHex,
|
// binding's, which may predate the seed carrying transport (pre-#70).
|
||||||
spirePubkey: binding.spirePubkey,
|
return { signer: await resume(binding), transport: transportFromSeed(seed) }
|
||||||
bunkerUrl: binding.bunkerUrl,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// First pair or re-pair: redeem the one-shot connect secret.
|
// First pair or re-pair: redeem the one-shot connect secret.
|
||||||
|
|
@ -89,27 +144,36 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Signer>
|
||||||
clientSecretHex: transport.secretHex,
|
clientSecretHex: transport.secretHex,
|
||||||
})
|
})
|
||||||
if (isElectron && window.electronAPI) {
|
if (isElectron && window.electronAPI) {
|
||||||
|
// Re-pair (a NEW seed replacing a prior binding) → wipe the previous
|
||||||
|
// operator's config/trust state (fee config + replay watermarks) so it
|
||||||
|
// can't linger or silently replay-block the new operator's config. A
|
||||||
|
// first pair (no prior binding) has nothing to reset. Cash accounting is
|
||||||
|
// preserved — see resetForRepair; a full wipe is the factory-reset path.
|
||||||
|
if (binding) {
|
||||||
|
console.log('[Signer] Re-pair (new seed fingerprint) — clearing prior operator config state')
|
||||||
|
await window.electronAPI.resetForRepair()
|
||||||
|
}
|
||||||
|
// 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({
|
await window.electronAPI.saveBunkerBinding({
|
||||||
clientSecretHex: transport.secretHex,
|
clientSecretHex: transport.secretHex,
|
||||||
spirePubkey: seed.spirePubkey,
|
spirePubkey: seed.spirePubkey,
|
||||||
bunkerUrl: seed.bunkerUrl,
|
bunkerUrl: seed.bunkerUrl,
|
||||||
seedFingerprint: fingerprint,
|
seedFingerprint: fingerprint,
|
||||||
pairedAt: Math.floor(Date.now() / 1000),
|
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).
|
// Re-pair → re-publish the cassette-state hello to the new operator (#56).
|
||||||
await window.electronAPI.resetBootstrapGate()
|
await window.electronAPI.resetBootstrapGate()
|
||||||
}
|
}
|
||||||
return signer
|
return { signer, transport: transportFromSeed(seed) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// No seed in this boot but a binding survives → resume.
|
// No seed in this boot but a binding survives → resume.
|
||||||
if (binding) {
|
if (binding) {
|
||||||
console.log('[Signer] Resuming bunker session from stored binding (no seed this boot)')
|
console.log('[Signer] Resuming bunker session from stored binding (no seed this boot)')
|
||||||
return resumeFromBinding({
|
return { signer: await resume(binding), transport: transportFromBinding(binding) }
|
||||||
clientSecretHex: binding.clientSecretHex,
|
|
||||||
spirePubkey: binding.spirePubkey,
|
|
||||||
bunkerUrl: binding.bunkerUrl,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.allowEphemeral) {
|
if (opts.allowEphemeral) {
|
||||||
|
|
@ -117,10 +181,10 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Signer>
|
||||||
const devKey = !isElectron ? (import.meta.env.VITE_ATM_PRIVATE_KEY as string | undefined) : ''
|
const devKey = !isElectron ? (import.meta.env.VITE_ATM_PRIVATE_KEY as string | undefined) : ''
|
||||||
if (devKey) {
|
if (devKey) {
|
||||||
console.warn('[Signer] No bunker pairing — using LocalSigner from VITE_ATM_PRIVATE_KEY (dev)')
|
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)')
|
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()
|
throw new NoPairingError()
|
||||||
|
|
|
||||||
9
apps/machine/src/types/electron.d.ts
vendored
9
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -6,10 +6,6 @@ export interface RuntimeConfig {
|
||||||
relayUrl: string
|
relayUrl: string
|
||||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||||
lnbitsServerPubkey: string
|
lnbitsServerPubkey: string
|
||||||
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
|
|
||||||
lightningPubPubkey?: string
|
|
||||||
lightningPubApiUrl?: string
|
|
||||||
extensionApiUrl?: string
|
|
||||||
appId: string
|
appId: string
|
||||||
machineModel: string
|
machineModel: string
|
||||||
fiatCode: string
|
fiatCode: string
|
||||||
|
|
@ -46,6 +42,10 @@ export interface BunkerBindingRecord {
|
||||||
bunkerUrl: string
|
bunkerUrl: string
|
||||||
seedFingerprint: string
|
seedFingerprint: string
|
||||||
pairedAt: number
|
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 {
|
export interface AtmSecrets {
|
||||||
|
|
@ -98,6 +98,7 @@ declare global {
|
||||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||||
clearBunkerBinding: () => Promise<void>
|
clearBunkerBinding: () => Promise<void>
|
||||||
resetBootstrapGate: () => Promise<void>
|
resetBootstrapGate: () => Promise<void>
|
||||||
|
resetForRepair: () => Promise<void>
|
||||||
saveSpireSeed: (seed: string) => Promise<void>
|
saveSpireSeed: (seed: string) => Promise<void>
|
||||||
relaunchApp: () => Promise<void>
|
relaunchApp: () => Promise<void>
|
||||||
applyOperatorCassettesConfig: (
|
applyOperatorCassettesConfig: (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { nip19 } from 'nostr-tools'
|
|
||||||
import { useAtmStore } from '@/stores/atm'
|
import { useAtmStore } from '@/stores/atm'
|
||||||
import { useBranding } from '@/composables/useBranding'
|
import { useBranding } from '@/composables/useBranding'
|
||||||
import { initialContext } from '@bitSpire/state-machine'
|
import { initialContext } from '@bitSpire/state-machine'
|
||||||
|
|
@ -15,18 +14,8 @@ const atmStore = useAtmStore()
|
||||||
const { logoUrl, title: brandTitle } = useBranding()
|
const { logoUrl, title: brandTitle } = useBranding()
|
||||||
const lndconnectUrl = import.meta.env.VITE_LNDCONNECT_URL || ''
|
const lndconnectUrl = import.meta.env.VITE_LNDCONNECT_URL || ''
|
||||||
const showZeusQR = ref(false)
|
const showZeusQR = ref(false)
|
||||||
const showLpQR = ref(false)
|
|
||||||
const copied = ref(false)
|
const copied = ref(false)
|
||||||
|
|
||||||
// Build nprofile for Lightning.Pub (pubkey + relay hint)
|
|
||||||
const lpNprofile = computed(() => {
|
|
||||||
const pubkey = import.meta.env.VITE_LIGHTNING_PUB_PUBKEY
|
|
||||||
if (!pubkey) return ''
|
|
||||||
const relayUrl = import.meta.env.VITE_RELAY_URL
|
|
||||||
const relays = relayUrl ? [relayUrl.replace('ws://', 'wss://')] : []
|
|
||||||
return nip19.nprofileEncode({ pubkey, relays })
|
|
||||||
})
|
|
||||||
|
|
||||||
async function copyToClipboard(value: string) {
|
async function copyToClipboard(value: string) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(value)
|
await navigator.clipboard.writeText(value)
|
||||||
|
|
@ -157,15 +146,6 @@ function handleCashOut() {
|
||||||
>
|
>
|
||||||
Zeus QR (lnd-alice)
|
Zeus QR (lnd-alice)
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
v-if="lpNprofile"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="text-xs text-muted-foreground"
|
|
||||||
@click="showLpQR = true"
|
|
||||||
>
|
|
||||||
Lightning.Pub nprofile
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Zeus QR fullscreen overlay -->
|
<!-- Zeus QR fullscreen overlay -->
|
||||||
|
|
@ -193,27 +173,6 @@ function handleCashOut() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lightning.Pub nprofile QR fullscreen overlay -->
|
|
||||||
<div
|
|
||||||
v-if="showLpQR"
|
|
||||||
class="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 bg-black/90 p-4"
|
|
||||||
@click.self="showLpQR = false"
|
|
||||||
>
|
|
||||||
<p class="text-sm text-white/70">Lightning.Pub nprofile</p>
|
|
||||||
<div class="rounded-2xl">
|
|
||||||
<QRCode :value="lpNprofile" :size="400" />
|
|
||||||
</div>
|
|
||||||
<code class="max-w-[90vw] truncate text-xs text-white/50">{{ lpNprofile }}</code>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Button variant="outline" size="sm" class="text-white" @click="copyToClipboard(lpNprofile)">
|
|
||||||
{{ copied ? 'Copied!' : 'Copy' }}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" class="text-white" @click="showLpQR = false">
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Help button (top-left) -->
|
<!-- Help button (top-left) -->
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { nip19 } from 'nostr-tools'
|
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
|
@ -23,35 +22,6 @@ import { QrCode, ExternalLink } from 'lucide-vue-next'
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
||||||
|
|
||||||
// Lightning.Pub config loaded at runtime from Electron main process
|
|
||||||
const lpPubkey = ref('')
|
|
||||||
const relayUrl = ref('')
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (isElectron && window.electronAPI) {
|
|
||||||
const config = await window.electronAPI.getConfig()
|
|
||||||
lpPubkey.value = config.lightningPubPubkey || ''
|
|
||||||
relayUrl.value = config.relayUrl || ''
|
|
||||||
} else {
|
|
||||||
// Dev fallback: use Vite env vars
|
|
||||||
lpPubkey.value = import.meta.env.VITE_LIGHTNING_PUB_PUBKEY || ''
|
|
||||||
relayUrl.value = import.meta.env.VITE_RELAY_URL || ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Build nprofile for Lightning.Pub (pubkey + relay hint)
|
|
||||||
const lpNprofile = computed(() => {
|
|
||||||
if (!lpPubkey.value) return ''
|
|
||||||
const relays = relayUrl.value ? [relayUrl.value.replace('ws://', 'wss://')] : []
|
|
||||||
return nip19.nprofileEncode({ pubkey: lpPubkey.value, relays })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Deep link URL: opens ShockWallet with this ATM's Lightning.Pub pre-filled
|
|
||||||
const shockwalletDeepLink = computed(() => {
|
|
||||||
if (!lpNprofile.value) return ''
|
|
||||||
return `https://wallet.aiolabs.dev/sources/add?nprofile=${encodeURIComponent(lpNprofile.value)}`
|
|
||||||
})
|
|
||||||
|
|
||||||
interface SupportPage {
|
interface SupportPage {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -74,17 +44,11 @@ const defaultPages: SupportPage[] = [
|
||||||
| Blink | No | Partial | Yes | Yes | https://www.blink.sv |
|
| Blink | No | Partial | Yes | Yes | https://www.blink.sv |
|
||||||
| Zeus | Yes | Yes | Yes | Yes | https://zeusln.com |
|
| Zeus | Yes | Yes | Yes | Yes | https://zeusln.com |
|
||||||
| Breez | Yes | Yes | Yes | Yes | https://breez.technology |
|
| Breez | Yes | Yes | Yes | Yes | https://breez.technology |
|
||||||
| ShockWallet | No | Yes | Yes | Yes | [shockwallet-deep-link] |
|
| ShockWallet | No | Yes | Yes | Yes | https://shockwallet.app |
|
||||||
|
|
||||||
Tap a QR icon to scan and download a wallet.
|
Tap a QR icon to scan and download a wallet.
|
||||||
|
|
||||||
**Non-custodial** means you hold your own keys and have full control of your Bitcoin. **KYC-free** means no identity verification is required. Partial (~) means limits apply without verification.
|
**Non-custodial** means you hold your own keys and have full control of your Bitcoin. **KYC-free** means no identity verification is required. Partial (~) means limits apply without verification.`,
|
||||||
|
|
||||||
## Using ShockWallet with this ATM
|
|
||||||
|
|
||||||
Scan the QR code below to add this ATM's Lightning node to your ShockWallet. This lets you send and receive sats directly through the ATM's payment system.
|
|
||||||
|
|
||||||
[lp-nprofile]`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'faq',
|
id: 'faq',
|
||||||
|
|
@ -153,7 +117,6 @@ type Segment =
|
||||||
| { type: 'qr'; content: string }
|
| { type: 'qr'; content: string }
|
||||||
| { type: 'table'; table: ParsedTable }
|
| { type: 'table'; table: ParsedTable }
|
||||||
| { type: 'qr-placeholder' }
|
| { type: 'qr-placeholder' }
|
||||||
| { type: 'lp-nprofile' }
|
|
||||||
|
|
||||||
/** Parse markdown table into structured data */
|
/** Parse markdown table into structured data */
|
||||||
function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
|
function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
|
||||||
|
|
@ -176,17 +139,8 @@ function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
|
||||||
return { headers, rows }
|
return { headers, rows }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve dynamic placeholders in markdown content */
|
|
||||||
function resolvePlaceholders(md: string): string {
|
|
||||||
return md.replace(
|
|
||||||
'[shockwallet-deep-link]',
|
|
||||||
shockwalletDeepLink.value || 'https://wallet.aiolabs.dev'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse content into segments: html, qr, or table */
|
/** Parse content into segments: html, qr, or table */
|
||||||
function parseContent(md: string): Segment[] {
|
function parseContent(md: string): Segment[] {
|
||||||
md = resolvePlaceholders(md)
|
|
||||||
const segments: Segment[] = []
|
const segments: Segment[] = []
|
||||||
const lines = md.split('\n')
|
const lines = md.split('\n')
|
||||||
let htmlBlock = ''
|
let htmlBlock = ''
|
||||||
|
|
@ -235,9 +189,6 @@ function parseContent(md: string): Segment[] {
|
||||||
} else if (trimmed === '[operator-qr-placeholder]') {
|
} else if (trimmed === '[operator-qr-placeholder]') {
|
||||||
flushHtml()
|
flushHtml()
|
||||||
segments.push({ type: 'qr-placeholder' })
|
segments.push({ type: 'qr-placeholder' })
|
||||||
} else if (trimmed === '[lp-nprofile]') {
|
|
||||||
flushHtml()
|
|
||||||
segments.push({ type: 'lp-nprofile' })
|
|
||||||
} else {
|
} else {
|
||||||
htmlBlock += line + '\n'
|
htmlBlock += line + '\n'
|
||||||
}
|
}
|
||||||
|
|
@ -375,33 +326,6 @@ onUnmounted(() => {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Lightning.Pub nprofile QR (scannable by ShockWallet) -->
|
|
||||||
<Card v-else-if="seg.type === 'lp-nprofile'" class="my-6 mx-auto max-w-xs">
|
|
||||||
<CardContent class="flex flex-col items-center gap-3 p-6">
|
|
||||||
<template v-if="lpNprofile">
|
|
||||||
<div class="rounded-xl bg-white p-3">
|
|
||||||
<QrcodeVue
|
|
||||||
:value="lpNprofile"
|
|
||||||
:size="180"
|
|
||||||
level="L"
|
|
||||||
render-as="svg"
|
|
||||||
background="#ffffff"
|
|
||||||
foreground="#000000"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-muted-foreground text-center px-2">
|
|
||||||
Scan with ShockWallet to connect
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<QrCode class="h-16 w-16 text-muted-foreground/30" />
|
|
||||||
<span class="text-sm text-muted-foreground/50 text-center">
|
|
||||||
Lightning.Pub not configured
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Table with inline QR codes -->
|
<!-- Table with inline QR codes -->
|
||||||
<div v-else-if="seg.type === 'table'" class="mb-8">
|
<div v-else-if="seg.type === 'table'" class="mb-8">
|
||||||
<Table class="text-sm sm:text-base lg:text-xl w-full">
|
<Table class="text-sm sm:text-base lg:text-xl w-full">
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ The `dev`-branch `flake.nix` pins the auto-upgrade source to `?ref=dev` so any A
|
||||||
```nix
|
```nix
|
||||||
system.autoUpgrade = {
|
system.autoUpgrade = {
|
||||||
enable = true;
|
enable = true;
|
||||||
flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git?ref=dev#${machineModel}-installed";
|
flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#${machineModel}-installed";
|
||||||
dates = "04:00";
|
dates = "04:00";
|
||||||
allowReboot = false;
|
allowReboot = false;
|
||||||
};
|
};
|
||||||
|
|
@ -262,8 +262,8 @@ ls -la /dev/serial/by-id/
|
||||||
{
|
{
|
||||||
services.bitspire = {
|
services.bitspire = {
|
||||||
enable = true;
|
enable = true;
|
||||||
relayUrl = "wss://relay.aiolabs.dev"; # ATM ↔ LNbits relay
|
relayUrl = ""; # seed-provided (#70); set to PIN a relay
|
||||||
lnbitsServerPubkey = "<64-hex>"; # LNbits transport pubkey
|
lnbitsServerPubkey = ""; # seed-provided (#70); set to PIN a pubkey
|
||||||
appDir = "/opt/bitspire"; # rarely overridden — defaults via flake
|
appDir = "/opt/bitspire"; # rarely overridden — defaults via flake
|
||||||
dataDir = "/var/lib/bitspire"; # rarely overridden
|
dataDir = "/var/lib/bitspire"; # rarely overridden
|
||||||
logLevel = "info"; # error | warn | info | debug
|
logLevel = "info"; # error | warn | info | debug
|
||||||
|
|
|
||||||
|
|
@ -20,18 +20,17 @@ in
|
||||||
|
|
||||||
relayUrl = mkOption {
|
relayUrl = mkOption {
|
||||||
type = types.str;
|
type = types.str;
|
||||||
default = "wss://relay.aiolabs.dev";
|
default = "";
|
||||||
description = ''
|
description = ''
|
||||||
Nostr relay URL the ATM and LNbits both subscribe to.
|
Optional override for the Nostr relay the ATM uses. Empty by
|
||||||
|
default (aiolabs/bitspire#70): the relay comes from the pairing
|
||||||
On a fresh-boot disk image this value is seeded into
|
SEED, not from provisioning — a fresh machine boots blank, scans a
|
||||||
`/var/lib/bitspire/.env` as `VITE_RELAY_URL=…` (see flake.nix
|
spire-seed, and the seed's relay drives the connection. A non-empty
|
||||||
`bitspire-env` activation script). The operator can override
|
value here is seeded into `/var/lib/bitspire/.env` as
|
||||||
the seeded value at runtime by editing `.env` directly or by
|
`VITE_RELAY_URL=…` and WINS over the seed (env-first precedence), so
|
||||||
re-running `deploy/nixos/provision-atm.sh` with a different
|
only set it to pin a machine to a specific relay. The renderer's
|
||||||
`RELAY_URL`. The renderer's resolution order is:
|
resolution order is: `VITE_RELAY_URL` (this / .env) → the pairing
|
||||||
`/var/lib/bitspire/.env` → this NixOS default → renderer
|
seed's relay → a dev-only `ws://localhost:7777` fallback.
|
||||||
hardcoded fallback (`ws://localhost:7777`).
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -39,10 +38,13 @@ in
|
||||||
type = types.str;
|
type = types.str;
|
||||||
default = "";
|
default = "";
|
||||||
description = ''
|
description = ''
|
||||||
LNbits nostr-transport server pubkey (hex, 64 chars). Published
|
Optional override for the LNbits nostr-transport server pubkey
|
||||||
by the LNbits server on startup. Required for the ATM to talk
|
(hex, 64 chars). Empty by default (aiolabs/bitspire#70): the
|
||||||
to its wallet. Provisioned by provision-atm.sh; can be left
|
pubkey comes from the pairing SEED (the seed's `lnbits_npub`), so
|
||||||
empty on disk-image builds.
|
a seed-paired machine needs nothing here. A non-empty value is
|
||||||
|
seeded into `.env` as `VITE_LNBITS_SERVER_PUBKEY=…` and WINS over
|
||||||
|
the seed (env-first precedence) — set it only to pin a machine to
|
||||||
|
a specific server. Mirrors `relayUrl`.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -141,11 +143,14 @@ in
|
||||||
"d ${cfg.dataDir}/branding 0755 bitspire bitspire -"
|
"d ${cfg.dataDir}/branding 0755 bitspire bitspire -"
|
||||||
];
|
];
|
||||||
|
|
||||||
# Environment file for ATM configuration
|
# Descriptive-only ATM info at /etc/bitspire/config.env. NOTE: this is NOT
|
||||||
|
# the runtime environment — the systemd service's EnvironmentFile is
|
||||||
|
# mkForce'd to /var/lib/bitspire/.env, and the renderer reads only VITE_*
|
||||||
|
# vars. Relay + server pubkey are deliberately omitted here: they come from
|
||||||
|
# the pairing seed (aiolabs/bitspire#70), and duplicating them as non-VITE
|
||||||
|
# RELAY_URL/LNBITS_SERVER_PUBKEY only invited "looks authoritative" confusion.
|
||||||
environment.etc."bitspire/config.env".text = ''
|
environment.etc."bitspire/config.env".text = ''
|
||||||
# bitSpire ATM Configuration
|
# bitSpire ATM Configuration (descriptive; not the runtime env)
|
||||||
RELAY_URL=${cfg.relayUrl}
|
|
||||||
LNBITS_SERVER_PUBKEY=${cfg.lnbitsServerPubkey}
|
|
||||||
LOG_LEVEL=${cfg.logLevel}
|
LOG_LEVEL=${cfg.logLevel}
|
||||||
DATA_DIR=${cfg.dataDir}
|
DATA_DIR=${cfg.dataDir}
|
||||||
|
|
||||||
|
|
|
||||||
73
deploy/nixos/factory-reset-atm.sh
Executable file
73
deploy/nixos/factory-reset-atm.sh
Executable file
|
|
@ -0,0 +1,73 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Factory-reset a bitSpire ATM to a truly-fresh state — the deterministic way to
|
||||||
|
# reproduce a brand-new machine so tests aren't masked by leftover env/db values
|
||||||
|
# (aiolabs/bitspire#70 remnant hygiene).
|
||||||
|
#
|
||||||
|
# WIPES:
|
||||||
|
# - /var/lib/bitspire/state.db (bunker binding, fee config, cassettes, cashbox,
|
||||||
|
# transactions, operator commands, replay watermarks — recreated on next boot)
|
||||||
|
# - /var/lib/bitspire/.env (truncated to the minimal image-baked template:
|
||||||
|
# machine model + fiat + display; drops relay, server pubkey, operator pubkey
|
||||||
|
# and any stored spire seed)
|
||||||
|
#
|
||||||
|
# After this the ATM boots UNPAIRED into the pairing wizard, exactly like a fresh
|
||||||
|
# disk image — so a scanned seed is the sole source of truth.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash factory-reset-atm.sh # SSH to localhost:2222 (QEMU)
|
||||||
|
# bash factory-reset-atm.sh 192.168.1.50 # a real ATM on the LAN
|
||||||
|
# bash factory-reset-atm.sh 192.168.1.50 22 # custom SSH port
|
||||||
|
# FORCE=1 bash factory-reset-atm.sh … # skip the confirmation prompt
|
||||||
|
# ATM_USER=root bash factory-reset-atm.sh … # override SSH user (default: bitspire)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ATM_HOST="${1:-localhost}"
|
||||||
|
ATM_SSH_PORT="${2:-2222}"
|
||||||
|
ATM_USER="${ATM_USER:-bitspire}"
|
||||||
|
|
||||||
|
echo "=== Factory-reset bitSpire ATM at $ATM_USER@$ATM_HOST:$ATM_SSH_PORT ==="
|
||||||
|
echo "This WIPES state.db and truncates .env to the minimal template (keeps only"
|
||||||
|
echo "machine model + fiat). ALL pairing, cash accounting, and transaction history"
|
||||||
|
echo "on the ATM will be lost."
|
||||||
|
if [ "${FORCE:-}" != "1" ]; then
|
||||||
|
read -r -p "Type 'yes' to proceed: " confirm
|
||||||
|
[ "$confirm" = "yes" ] || { echo "Aborted."; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh -o StrictHostKeyChecking=no -p "$ATM_SSH_PORT" "$ATM_USER@$ATM_HOST" 'sudo bash -s' <<'REMOTE'
|
||||||
|
set -euo pipefail
|
||||||
|
ENV=/var/lib/bitspire/.env
|
||||||
|
DB=/var/lib/bitspire/state.db
|
||||||
|
|
||||||
|
# Preserve model + fiat from the existing .env (fall back to sintra/EUR).
|
||||||
|
model=$(grep -E '^VITE_LAMASSU_MACHINE_MODEL=' "$ENV" 2>/dev/null | cut -d= -f2- || true)
|
||||||
|
fiat=$(grep -E '^VITE_LAMASSU_FIAT_CODE=' "$ENV" 2>/dev/null | cut -d= -f2- || true)
|
||||||
|
model=${model:-sintra}
|
||||||
|
fiat=${fiat:-EUR}
|
||||||
|
|
||||||
|
systemctl stop bitspire 2>/dev/null || true
|
||||||
|
|
||||||
|
# Wipe persisted state (db + WAL/SHM sidecars).
|
||||||
|
rm -f "$DB" "$DB-wal" "$DB-shm"
|
||||||
|
|
||||||
|
# Truncate .env to the minimal image-baked template.
|
||||||
|
cat > "$ENV" <<EOF
|
||||||
|
VITE_LAMASSU_MACHINE_MODEL=$model
|
||||||
|
VITE_LAMASSU_FIAT_CODE=$fiat
|
||||||
|
VITE_SPIRE_SEED=
|
||||||
|
ELECTRON_FORCE_PROD=1
|
||||||
|
DISPLAY=:0
|
||||||
|
EOF
|
||||||
|
chmod 600 "$ENV"
|
||||||
|
chown bitspire:bitspire "$ENV" 2>/dev/null || true
|
||||||
|
|
||||||
|
systemctl start bitspire 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "--- .env is now (values blanked) ---"
|
||||||
|
sed -E 's/=.*/=/' "$ENV"
|
||||||
|
echo "--- state.db removed (recreated fresh on next boot) ---"
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== ATM factory-reset. It boots UNPAIRED → the pairing wizard. ==="
|
||||||
|
echo "Watch: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'"
|
||||||
|
|
@ -21,18 +21,17 @@ let
|
||||||
batm3 = "USD";
|
batm3 = "USD";
|
||||||
}.${machineModel} or "USD";
|
}.${machineModel} or "USD";
|
||||||
|
|
||||||
# .env template — runtime secrets are provisioned later via provision-atm.sh.
|
# Minimal .env template (aiolabs/bitspire#70 remnant hygiene). Seed ONLY
|
||||||
# Only non-secret defaults and display vars go here. VITE_SPIRE_SEED (the
|
# image-baked, non-maskable values. Relay + server pubkey come from the pairing
|
||||||
# NIP-46 bunker pairing seed) is written at provision time; the dev-only
|
# SEED, operator pubkey + fee config come from LNbits over the transport — so we
|
||||||
# VITE_ATM_PRIVATE_KEY fallback is omitted here on purpose.
|
# deliberately do NOT pre-seed those keys (a present-but-empty VITE_RELAY_URL /
|
||||||
|
# VITE_LNBITS_SERVER_PUBKEY / VITE_OPERATOR_PUBKEYS would win over the seed and
|
||||||
|
# mask its source). VITE_SPIRE_SEED is written by the wizard / provision-atm.sh;
|
||||||
|
# the dev-only VITE_ATM_PRIVATE_KEY fallback is omitted on purpose.
|
||||||
envTemplate = pkgs.writeText "bitspire-env" ''
|
envTemplate = pkgs.writeText "bitspire-env" ''
|
||||||
VITE_RELAY_URL=
|
|
||||||
VITE_LNBITS_SERVER_PUBKEY=
|
|
||||||
VITE_SPIRE_SEED=
|
|
||||||
VITE_APP_ID=
|
|
||||||
VITE_OPERATOR_PUBKEYS=
|
|
||||||
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
|
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
|
||||||
VITE_LAMASSU_FIAT_CODE=${fiatCodeForModel}
|
VITE_LAMASSU_FIAT_CODE=${fiatCodeForModel}
|
||||||
|
VITE_SPIRE_SEED=
|
||||||
ELECTRON_FORCE_PROD=1
|
ELECTRON_FORCE_PROD=1
|
||||||
DISPLAY=:0
|
DISPLAY=:0
|
||||||
'';
|
'';
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,22 @@
|
||||||
# kind-21000 NIP-44 v2 events on a relay — there is no out-of-band token,
|
# kind-21000 NIP-44 v2 events on a relay — there is no out-of-band token,
|
||||||
# the ATM's nostr private key IS the credential. # pragma: allowlist secret
|
# the ATM's nostr private key IS the credential. # pragma: allowlist secret
|
||||||
#
|
#
|
||||||
# Required environment variables (or edit defaults below):
|
# The primary input is SPIRE_SEED — the pairing seed carries the relay, the
|
||||||
# LNBITS_SERVER_PUBKEY Hex pubkey published by the LNbits server at startup.
|
# LNbits server pubkey AND the signing identity, so a seed-provisioned machine
|
||||||
# From the LNbits compose:
|
# needs nothing else (aiolabs/bitspire#70).
|
||||||
# docker logs lnbits | grep 'nostr_transport pubkey'
|
#
|
||||||
# LNBITS_HTTP_URL Origin LNbits is reachable at over HTTP, used only
|
# Environment variables:
|
||||||
# to compose the LNURL-withdraw callback URL that
|
# SPIRE_SEED RECOMMENDED. The spire pairing seed
|
||||||
# customer wallets dereference. Default: http://10.0.2.2:5000
|
# (`spire-seed:v1:<base64url>`) minted by spirekeeper.
|
||||||
# RELAY_URL Nostr relay LNbits + the bunker subscribe on.
|
# Carries relay + LNbits server pubkey + the production
|
||||||
# Default: ws://$HOST_IP:5001/nostrrelay/test (LNbits
|
# identity under the NIP-46 bunker (aiolabs/bitspire#52 / #70).
|
||||||
# bundled nostrrelay). Override for a separate relay.
|
# RELAY_URL OPTIONAL override — pins VITE_RELAY_URL and WINS over the
|
||||||
# SPIRE_SEED The spire pairing seed (`spire-seed:v1:<base64url>`)
|
# seed's relay (env-first precedence). Leave unset to let the
|
||||||
# minted by spirekeeper. THIS is the production
|
# seed drive it. Required only on the no-seed dev path
|
||||||
# identity under the NIP-46 bunker (aiolabs/bitspire#52).
|
# (default there: ws://$HOST_IP:5001/nostrrelay/test).
|
||||||
|
# LNBITS_SERVER_PUBKEY OPTIONAL override (hex). Leave unset with a seed. On the
|
||||||
|
# no-seed dev path it's scraped from
|
||||||
|
# `docker logs lnbits | grep 'nostr_transport pubkey'`.
|
||||||
# ATM_PRIVATE_KEY DEV-ONLY 32-byte hex nsec fallback, used only when
|
# ATM_PRIVATE_KEY DEV-ONLY 32-byte hex nsec fallback, used only when
|
||||||
# SPIRE_SEED is unset (no bunker). Generated if unset
|
# SPIRE_SEED is unset (no bunker). Generated if unset
|
||||||
# AND no SPIRE_SEED is provided.
|
# AND no SPIRE_SEED is provided.
|
||||||
|
|
@ -61,40 +64,51 @@ else
|
||||||
echo "--- LAN ATM: using $HOST_IP as dev machine address ---"
|
echo "--- LAN ATM: using $HOST_IP as dev machine address ---"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 2: Resolve the LNbits server pubkey. Prefer the env override; else
|
# Steps 2-4: transport config (relay + LNbits server pubkey) + signing identity.
|
||||||
# fall back to scraping the local docker compose stack.
|
#
|
||||||
if [ -z "${LNBITS_SERVER_PUBKEY:-}" ]; then
|
# Under aiolabs/bitspire#70 the relay + server pubkey come from the pairing SEED,
|
||||||
echo ""
|
# so a seed-provisioned machine needs NEITHER in .env. We only pin them when the
|
||||||
echo "--- Step 1: Extracting LNbits nostr-transport pubkey from docker logs ---"
|
# operator EXPLICITLY passes RELAY_URL / LNBITS_SERVER_PUBKEY (a deliberate
|
||||||
LNBITS_SERVER_PUBKEY=$(docker logs lnbits 2>&1 \
|
# override that WINS over the seed via env-first precedence), or when there is no
|
||||||
| grep -oP 'nostr_transport pubkey:?\s*\K[a-f0-9]{64}' \
|
# seed (the dev-nsec fallback has nothing else to supply them, so we scrape/default).
|
||||||
| tail -1 || true)
|
TRANSPORT_LINES=""
|
||||||
if [ -z "$LNBITS_SERVER_PUBKEY" ]; then
|
|
||||||
echo "ERROR: Could not extract LNbits pubkey. Set LNBITS_SERVER_PUBKEY explicitly"
|
|
||||||
echo "or start the LNbits stack first (docker compose -f docker/docker-compose.dev.yml up lnbits)."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo "LNbits server pubkey: ${LNBITS_SERVER_PUBKEY:0:16}..."
|
|
||||||
|
|
||||||
# Step 3: Pin LNbits HTTP origin.
|
|
||||||
LNBITS_HTTP_URL="${LNBITS_HTTP_URL:-http://$HOST_IP:5000}"
|
|
||||||
|
|
||||||
# Step 4: Relay URL. Defaults to the LNbits bundled nostrrelay.
|
|
||||||
RELAY_URL="${RELAY_URL:-ws://$HOST_IP:5001/nostrrelay/test}"
|
|
||||||
|
|
||||||
# Step 5: Signing identity. Prefer the spire pairing seed (bunker). Only fall
|
|
||||||
# back to a generated dev nsec when no seed is supplied.
|
|
||||||
if [ -n "${SPIRE_SEED:-}" ]; then
|
if [ -n "${SPIRE_SEED:-}" ]; then
|
||||||
echo ""
|
|
||||||
echo "--- Using spire pairing seed (bunker-backed identity) ---"
|
|
||||||
case "$SPIRE_SEED" in
|
case "$SPIRE_SEED" in
|
||||||
spire-seed:v1:*) : ;;
|
spire-seed:v1:*) : ;;
|
||||||
*) echo "ERROR: SPIRE_SEED must start with 'spire-seed:v1:'"; exit 1 ;;
|
*) echo "ERROR: SPIRE_SEED must start with 'spire-seed:v1:'"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
echo ""
|
||||||
|
echo "--- Spire pairing seed: relay + LNbits pubkey come from the seed ---"
|
||||||
|
if [ -n "${RELAY_URL:-}" ]; then
|
||||||
|
echo " (pinning VITE_RELAY_URL=$RELAY_URL — overrides the seed's relay)"
|
||||||
|
TRANSPORT_LINES="VITE_RELAY_URL=$RELAY_URL"
|
||||||
|
fi
|
||||||
|
if [ -n "${LNBITS_SERVER_PUBKEY:-}" ]; then
|
||||||
|
TRANSPORT_LINES="${TRANSPORT_LINES:+$TRANSPORT_LINES
|
||||||
|
}VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY"
|
||||||
|
fi
|
||||||
IDENTITY_LINES="# Spire pairing seed — bunker-backed identity (aiolabs/bitspire#52)
|
IDENTITY_LINES="# Spire pairing seed — bunker-backed identity (aiolabs/bitspire#52)
|
||||||
VITE_SPIRE_SEED=$SPIRE_SEED"
|
VITE_SPIRE_SEED=$SPIRE_SEED"
|
||||||
else
|
else
|
||||||
|
# No seed → DEV-ONLY nsec fallback. Nothing else supplies the relay + pubkey,
|
||||||
|
# so scrape/default them.
|
||||||
|
if [ -z "${LNBITS_SERVER_PUBKEY:-}" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "--- No seed: extracting LNbits nostr-transport pubkey from docker logs ---"
|
||||||
|
LNBITS_SERVER_PUBKEY=$(docker logs lnbits 2>&1 \
|
||||||
|
| grep -oP 'nostr_transport pubkey:?\s*\K[a-f0-9]{64}' \
|
||||||
|
| tail -1 || true)
|
||||||
|
if [ -z "$LNBITS_SERVER_PUBKEY" ]; then
|
||||||
|
echo "ERROR: no SPIRE_SEED, and could not extract the LNbits pubkey."
|
||||||
|
echo "Provide a SPIRE_SEED (recommended — the seed carries relay + pubkey),"
|
||||||
|
echo "or set LNBITS_SERVER_PUBKEY explicitly."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
RELAY_URL="${RELAY_URL:-ws://$HOST_IP:5001/nostrrelay/test}"
|
||||||
|
TRANSPORT_LINES="VITE_RELAY_URL=$RELAY_URL
|
||||||
|
VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY"
|
||||||
if [ -z "${ATM_PRIVATE_KEY:-}" ]; then
|
if [ -z "${ATM_PRIVATE_KEY:-}" ]; then
|
||||||
ATM_PRIVATE_KEY=$(openssl rand -hex 32)
|
ATM_PRIVATE_KEY=$(openssl rand -hex 32)
|
||||||
echo ""
|
echo ""
|
||||||
|
|
@ -110,10 +124,10 @@ echo "--- Step 2: Writing .env to ATM ---"
|
||||||
ENV_CONTENT="# bitSpire Configuration
|
ENV_CONTENT="# bitSpire Configuration
|
||||||
# Auto-generated by provision-atm.sh on $(date -Iseconds)
|
# Auto-generated by provision-atm.sh on $(date -Iseconds)
|
||||||
|
|
||||||
# LNbits nostr-transport connection
|
# LNbits nostr-transport. Relay + server pubkey come from the pairing seed
|
||||||
VITE_RELAY_URL=$RELAY_URL
|
# (aiolabs/bitspire#70); present below only as an explicit override or the
|
||||||
VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY
|
# no-seed dev fallback.
|
||||||
VITE_LNBITS_HTTP_URL=$LNBITS_HTTP_URL
|
$TRANSPORT_LINES
|
||||||
|
|
||||||
$IDENTITY_LINES
|
$IDENTITY_LINES
|
||||||
|
|
||||||
|
|
@ -132,6 +146,6 @@ echo ""
|
||||||
echo "=== ATM provisioned successfully ==="
|
echo "=== ATM provisioned successfully ==="
|
||||||
echo ""
|
echo ""
|
||||||
echo "Credentials written to /var/lib/bitspire/.env"
|
echo "Credentials written to /var/lib/bitspire/.env"
|
||||||
echo "ATM service restarted. It should connect to LNbits via relay $RELAY_URL."
|
echo "ATM service restarted. Relay: ${RELAY_URL:-from the pairing seed}."
|
||||||
echo ""
|
echo ""
|
||||||
echo "To check status: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'"
|
echo "To check status: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'"
|
||||||
|
|
|
||||||
35
flake.nix
35
flake.nix
|
|
@ -186,29 +186,34 @@
|
||||||
allowReboot = false;
|
allowReboot = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
# Env template — runtime secrets provisioned via provision-atm.sh.
|
# Minimal env template (aiolabs/bitspire#70 remnant hygiene).
|
||||||
# Identity fields are intentionally empty so a fresh disk image
|
# Seed ONLY image-baked, non-maskable values. Everything else the
|
||||||
# boots cleanly into the "needs provisioning" state; provision-
|
# ATM needs comes from the pairing SEED (relay, lnbits_npub, bunker)
|
||||||
# atm.sh SSHes in and overwrites with real values.
|
# or from LNbits over the transport (operator pubkey, fee config) —
|
||||||
|
# so we must NOT pre-seed those keys. A present-but-empty
|
||||||
|
# VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY / VITE_OPERATOR_PUBKEYS
|
||||||
|
# is a masking hazard: env WINS over the seed, and this activation
|
||||||
|
# only writes when .env is ABSENT, so any value written at first
|
||||||
|
# boot is frozen for the life of the disk. Leaving the keys out
|
||||||
|
# entirely lets the seed/transport be the sole source.
|
||||||
#
|
#
|
||||||
# VITE_RELAY_URL seeds from `config.services.bitspire.relayUrl`
|
# VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY are emitted ONLY when
|
||||||
# so the NixOS module's `relayUrl` option becomes the default
|
# the operator deliberately pins them via the Nix options (non-empty
|
||||||
# without losing the operator's ability to override via .env
|
# default ""), which is an explicit override that wins over the seed.
|
||||||
# (edit the file or re-run provision-atm.sh).
|
|
||||||
system.activationScripts.bitspire-env = ''
|
system.activationScripts.bitspire-env = ''
|
||||||
mkdir -p /var/lib/bitspire
|
mkdir -p /var/lib/bitspire
|
||||||
if [ ! -f /var/lib/bitspire/.env ]; then
|
if [ ! -f /var/lib/bitspire/.env ]; then
|
||||||
cp ${pkgs.writeText "bitspire-env-default" ''
|
cp ${pkgs.writeText "bitspire-env-default" (''
|
||||||
VITE_RELAY_URL=${config.services.bitspire.relayUrl}
|
|
||||||
VITE_LNBITS_SERVER_PUBKEY=
|
|
||||||
VITE_SPIRE_SEED=
|
|
||||||
VITE_APP_ID=
|
|
||||||
VITE_OPERATOR_PUBKEYS=
|
|
||||||
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
|
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
|
||||||
VITE_LAMASSU_FIAT_CODE=${fiatCode}
|
VITE_LAMASSU_FIAT_CODE=${fiatCode}
|
||||||
|
VITE_SPIRE_SEED=
|
||||||
ELECTRON_FORCE_PROD=1
|
ELECTRON_FORCE_PROD=1
|
||||||
DISPLAY=:0
|
DISPLAY=:0
|
||||||
''} /var/lib/bitspire/.env
|
'' + pkgs.lib.optionalString (config.services.bitspire.relayUrl != "") ''
|
||||||
|
VITE_RELAY_URL=${config.services.bitspire.relayUrl}
|
||||||
|
'' + pkgs.lib.optionalString (config.services.bitspire.lnbitsServerPubkey != "") ''
|
||||||
|
VITE_LNBITS_SERVER_PUBKEY=${config.services.bitspire.lnbitsServerPubkey}
|
||||||
|
'')} /var/lib/bitspire/.env
|
||||||
chmod 600 /var/lib/bitspire/.env
|
chmod 600 /var/lib/bitspire/.env
|
||||||
chown bitspire:bitspire /var/lib/bitspire/.env
|
chown bitspire:bitspire /var/lib/bitspire/.env
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { npubEncode } from 'nostr-tools/nip19'
|
||||||
import { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from '../seed.js'
|
import { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from '../seed.js'
|
||||||
|
|
||||||
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
|
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
|
||||||
|
|
@ -12,41 +13,56 @@ function makeSeed(json: unknown): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPIRE_PUBKEY = 'a'.repeat(64)
|
const SPIRE_PUBKEY = 'a'.repeat(64)
|
||||||
const BUNKER_URL = `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef`
|
const LNBITS_PUBKEY = 'b'.repeat(64)
|
||||||
|
const SPIRE_NPUB = npubEncode(SPIRE_PUBKEY)
|
||||||
|
const LNBITS_NPUB = npubEncode(LNBITS_PUBKEY)
|
||||||
|
|
||||||
const VALID = {
|
const VALID = {
|
||||||
v: 1,
|
v: 1,
|
||||||
spire_npub: 'npub1example',
|
spire_npub: SPIRE_NPUB,
|
||||||
spire_pubkey: SPIRE_PUBKEY,
|
lnbits_npub: LNBITS_NPUB,
|
||||||
bunker_url: BUNKER_URL,
|
bunker_secret: 'deadbeef',
|
||||||
relays: ['wss://events.relay/'],
|
relays: ['wss://events.relay/'],
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('parseSpireSeed', () => {
|
describe('parseSpireSeed', () => {
|
||||||
it('parses a well-formed seed (snake_case → camelCase)', () => {
|
it('derives hex pubkeys from npubs and reconstructs the bunker URL', () => {
|
||||||
const seed = parseSpireSeed(makeSeed(VALID))
|
const seed = parseSpireSeed(makeSeed(VALID))
|
||||||
expect(seed).toEqual({
|
expect(seed).toEqual({
|
||||||
v: 1,
|
v: 1,
|
||||||
spirePubkey: SPIRE_PUBKEY,
|
spirePubkey: SPIRE_PUBKEY,
|
||||||
bunkerUrl: BUNKER_URL,
|
lnbitsServerPubkey: LNBITS_PUBKEY,
|
||||||
|
bunkerUrl: `bunker://${SPIRE_PUBKEY}?relay=${encodeURIComponent('wss://events.relay/')}&secret=deadbeef`,
|
||||||
relays: ['wss://events.relay/'],
|
relays: ['wss://events.relay/'],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('re-pads stripped base64url of any residue length', () => {
|
it('defaults the bunker relay to relays[0] when bunker_relay is absent', () => {
|
||||||
// Vary a field so the encoded payload lands on each mod-4 residue.
|
const seed = parseSpireSeed(makeSeed(VALID))
|
||||||
for (const suffix of ['', 'a', 'ab', 'abc']) {
|
expect(seed.bunkerUrl).toContain(`relay=${encodeURIComponent('wss://events.relay/')}`)
|
||||||
const seed = makeSeed({ ...VALID, spire_npub: `npub1${suffix}` })
|
|
||||||
expect(() => parseSpireSeed(seed)).not.toThrow()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps bunker_url verbatim (percent-decoding is parseBunkerInput’s job)', () => {
|
it('uses an explicit bunker_relay when present (distinct from event relays)', () => {
|
||||||
|
const seed = parseSpireSeed(makeSeed({ ...VALID, bunker_relay: 'wss://bunker.relay/' }))
|
||||||
|
expect(seed.bunkerUrl).toContain(`relay=${encodeURIComponent('wss://bunker.relay/')}`)
|
||||||
|
// event relays are unchanged
|
||||||
|
expect(seed.relays).toEqual(['wss://events.relay/'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('percent-encodes relay + secret for parseBunkerInput to decode', () => {
|
||||||
const seed = parseSpireSeed(makeSeed(VALID))
|
const seed = parseSpireSeed(makeSeed(VALID))
|
||||||
expect(seed.bunkerUrl).toContain('relay=wss%3A%2F%2F')
|
expect(seed.bunkerUrl).toContain('relay=wss%3A%2F%2F')
|
||||||
expect(seed.bunkerUrl).toContain('secret=deadbeef')
|
expect(seed.bunkerUrl).toContain('secret=deadbeef')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('re-pads stripped base64url of any residue length', () => {
|
||||||
|
// Vary the secret so the encoded payload lands on each mod-4 residue.
|
||||||
|
for (const suffix of ['', 'a', 'ab', 'abc']) {
|
||||||
|
const seed = makeSeed({ ...VALID, bunker_secret: `deadbeef${suffix}` })
|
||||||
|
expect(() => parseSpireSeed(seed)).not.toThrow()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['wrong scheme', 'spire-seed:v2:abc'],
|
['wrong scheme', 'spire-seed:v2:abc'],
|
||||||
['not a seed', 'bunker://whatever'],
|
['not a seed', 'bunker://whatever'],
|
||||||
|
|
@ -56,10 +72,18 @@ describe('parseSpireSeed', () => {
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['bad version', { ...VALID, v: 2 }],
|
['bad version', { ...VALID, v: 2 }],
|
||||||
['short pubkey', { ...VALID, spire_pubkey: 'abc' }],
|
['missing spire_npub', { ...VALID, spire_npub: undefined }],
|
||||||
['non-bunker url', { ...VALID, bunker_url: 'https://evil/' }],
|
['non-npub spire_npub', { ...VALID, spire_npub: 'a'.repeat(64) }],
|
||||||
|
['missing lnbits_npub', { ...VALID, lnbits_npub: undefined }],
|
||||||
|
['non-npub lnbits_npub', { ...VALID, lnbits_npub: 'notanpub' }],
|
||||||
|
['empty bunker_secret', { ...VALID, bunker_secret: '' }],
|
||||||
|
['missing bunker_secret', { ...VALID, bunker_secret: undefined }],
|
||||||
['empty relays', { ...VALID, relays: [] }],
|
['empty relays', { ...VALID, relays: [] }],
|
||||||
['non-string relay', { ...VALID, relays: [123] }],
|
['non-string relay', { ...VALID, relays: [123] }],
|
||||||
|
['non-ws relay (scan corruption ws://→As://)', { ...VALID, relays: ['As://events.relay/'] }],
|
||||||
|
['non-ws relay (http)', { ...VALID, relays: ['http://events.relay/'] }],
|
||||||
|
['empty bunker_relay', { ...VALID, bunker_relay: '' }],
|
||||||
|
['non-ws bunker_relay', { ...VALID, bunker_relay: 'As://bunker.relay/' }],
|
||||||
])('rejects %s', (_label, json) => {
|
])('rejects %s', (_label, json) => {
|
||||||
expect(() => parseSpireSeed(makeSeed(json))).toThrow()
|
expect(() => parseSpireSeed(makeSeed(json))).toThrow()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,44 +3,70 @@
|
||||||
*
|
*
|
||||||
* The operator dashboard (aiolabs/spirekeeper `pairing.py`) hands each ATM a
|
* The operator dashboard (aiolabs/spirekeeper `pairing.py`) hands each ATM a
|
||||||
* one-time seed URL that encodes the bunker connection + the spire's signing
|
* one-time seed URL that encodes the bunker connection + the spire's signing
|
||||||
* identity. Wire contract (model A1):
|
* identity. Wire contract (model A1, minimal encoding):
|
||||||
*
|
*
|
||||||
* spire-seed:v1:<base64url(json, no padding)>
|
* spire-seed:v1:<base64url(json, no padding)>
|
||||||
* json = {
|
* json = {
|
||||||
* "v": 1,
|
* "v": 1,
|
||||||
* "spire_npub": "npub1…", // informational, ignored here
|
* "spire_npub": "npub1…", // spire signing identity (bech32; hex derived)
|
||||||
* "spire_pubkey": "<64-hex>", // the spire's bunker-held signing identity
|
* "lnbits_npub": "npub1…", // LNbits nostr-transport server identity
|
||||||
* "bunker_url": "bunker://<spire_pubkey_hex>?relay=<url>&secret=<sec>",
|
* "bunker_secret": "<sec>", // one-shot NIP-46 connect token
|
||||||
* "relays": ["wss://…"] // relays for the spire's OWN events (21000/30078)
|
* "relays": ["wss://…"], // relays the spire's OWN events use (21000/30078)
|
||||||
|
* "bunker_relay": "wss://…" // OPTIONAL — NIP-46 relay; defaults to relays[0]
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* - base64url is `urlsafe_b64encode(...).rstrip("=")` → re-pad to a multiple
|
* Design (see aiolabs/bitspire#70): the pubkey is carried ONCE, as an npub.
|
||||||
* of 4 before decoding.
|
* The old shape spelled it three times (spire_npub + spire_pubkey hex + inside
|
||||||
* - `relay` / `secret` inside `bunker_url` are percent-encoded; decoding them
|
* a full bunker_url), which bloats a QR that's already hard to scan. Here:
|
||||||
* is left to nostr-tools `parseBunkerInput` (see bunker-signer.ts), so we
|
*
|
||||||
* keep `bunker_url` verbatim.
|
* - `spire_pubkey` (hex) is derived from `spire_npub` (npub is ~the same length
|
||||||
* - `bunker_url`'s relay is the BUNKER relay; `relays[]` is where the spire
|
* as hex but carries a bech32 checksum — real error-detection for a value
|
||||||
* publishes its own events. They may differ — both must be spire-reachable.
|
* read off a camera).
|
||||||
|
* - `bunker_url` is RECONSTRUCTED from `spire_pubkey`, `bunker_relay` (or
|
||||||
|
* `relays[0]`), and `bunker_secret`, then handed verbatim to nostr-tools
|
||||||
|
* `parseBunkerInput` (see bunker-signer.ts).
|
||||||
|
* - `lnbits_npub` gives the ATM its LNbits transport server pubkey so a paired
|
||||||
|
* machine needs nothing else provisioned to reach the backend (#70 part 2).
|
||||||
|
*
|
||||||
|
* base64url is `urlsafe_b64encode(...).rstrip("=")` → re-pad to a multiple of 4
|
||||||
|
* before decoding.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { sha256 } from '@noble/hashes/sha2.js'
|
import { sha256 } from '@noble/hashes/sha2.js'
|
||||||
import { bytesToHex } from 'nostr-tools/utils'
|
import { bytesToHex } from 'nostr-tools/utils'
|
||||||
|
import { decode as nip19Decode } from 'nostr-tools/nip19'
|
||||||
|
|
||||||
export const SPIRE_SEED_SCHEME = 'spire-seed:v1:'
|
export const SPIRE_SEED_SCHEME = 'spire-seed:v1:'
|
||||||
|
|
||||||
export interface SpireSeed {
|
export interface SpireSeed {
|
||||||
/** Seed format version (always 1 for this scheme). */
|
/** Seed format version (always 1 for this scheme). */
|
||||||
v: number
|
v: number
|
||||||
/** The spire's signing identity — 64-char hex. Every event is signed as this. */
|
/** The spire's signing identity — 64-char hex, derived from `spire_npub`. */
|
||||||
spirePubkey: string
|
spirePubkey: string
|
||||||
/** `bunker://<pubkey>?relay=&secret=` — handed to nostr-tools parseBunkerInput. */
|
/** `bunker://<pubkey>?relay=&secret=` — reconstructed, handed to parseBunkerInput. */
|
||||||
bunkerUrl: string
|
bunkerUrl: string
|
||||||
/** Relays where the spire publishes its own events (kind 21000 / 30078). */
|
/** Relays where the spire publishes its own events (kind 21000 / 30078). */
|
||||||
relays: string[]
|
relays: string[]
|
||||||
|
/** LNbits nostr-transport server pubkey — 64-char hex, derived from `lnbits_npub`. */
|
||||||
|
lnbitsServerPubkey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEX64 = /^[0-9a-f]{64}$/
|
const HEX64 = /^[0-9a-f]{64}$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A relay must be a `ws://` or `wss://` URL. Unlike the npubs (bech32-checksummed,
|
||||||
|
* so a mis-scanned character is caught), the relay strings are raw inside the
|
||||||
|
* seed's base64 — a QR misread can silently corrupt `ws://` into e.g. `As://`
|
||||||
|
* and the pairing then crash-loops on an unreachable relay. Reject at parse time
|
||||||
|
* so the wizard refuses a garbled scan instead of persisting it (bitspire#70).
|
||||||
|
*/
|
||||||
|
const WS_URL = /^wss?:\/\/[^\s]+$/
|
||||||
|
function assertRelayUrl(value: string, field: string): void {
|
||||||
|
if (!WS_URL.test(value)) {
|
||||||
|
throw new Error(`parseSpireSeed: ${field} must be a ws:// or wss:// URL (got "${value}")`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Decode an unpadded base64url string in both browser and Node. */
|
/** Decode an unpadded base64url string in both browser and Node. */
|
||||||
function base64urlDecode(input: string): string {
|
function base64urlDecode(input: string): string {
|
||||||
const padded = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '=')
|
const padded = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '=')
|
||||||
|
|
@ -50,6 +76,23 @@ function base64urlDecode(input: string): string {
|
||||||
return Buffer.from(padded, 'base64').toString('binary')
|
return Buffer.from(padded, 'base64').toString('binary')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Decode an `npub1…` to its 64-char hex pubkey, failing closed. */
|
||||||
|
function hexFromNpub(value: unknown, field: string): string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new Error(`parseSpireSeed: ${field} must be a string`)
|
||||||
|
}
|
||||||
|
let decoded: ReturnType<typeof nip19Decode>
|
||||||
|
try {
|
||||||
|
decoded = nip19Decode(value)
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`parseSpireSeed: ${field} is not a valid npub (${(err as Error).message})`)
|
||||||
|
}
|
||||||
|
if (decoded.type !== 'npub' || typeof decoded.data !== 'string' || !HEX64.test(decoded.data)) {
|
||||||
|
throw new Error(`parseSpireSeed: ${field} must be an npub`)
|
||||||
|
}
|
||||||
|
return decoded.data
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse + validate a `spire-seed:v1:` URL. Throws on any malformation —
|
* Parse + validate a `spire-seed:v1:` URL. Throws on any malformation —
|
||||||
* the seed is a trust root, so we fail closed rather than connect to a
|
* the seed is a trust root, so we fail closed rather than connect to a
|
||||||
|
|
@ -77,22 +120,40 @@ export function parseSpireSeed(seedUrl: string): SpireSeed {
|
||||||
throw new Error(`parseSpireSeed: unsupported version ${String(obj.v)}`)
|
throw new Error(`parseSpireSeed: unsupported version ${String(obj.v)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const spirePubkey = obj.spire_pubkey
|
const spirePubkey = hexFromNpub(obj.spire_npub, 'spire_npub')
|
||||||
if (typeof spirePubkey !== 'string' || !HEX64.test(spirePubkey)) {
|
const lnbitsServerPubkey = hexFromNpub(obj.lnbits_npub, 'lnbits_npub')
|
||||||
throw new Error('parseSpireSeed: spire_pubkey must be 64-char hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
const bunkerUrl = obj.bunker_url
|
const bunkerSecret = obj.bunker_secret
|
||||||
if (typeof bunkerUrl !== 'string' || !bunkerUrl.startsWith('bunker://')) {
|
if (typeof bunkerSecret !== 'string' || bunkerSecret.length === 0) {
|
||||||
throw new Error('parseSpireSeed: bunker_url must be a bunker:// URL')
|
throw new Error('parseSpireSeed: bunker_secret must be a non-empty string')
|
||||||
}
|
}
|
||||||
|
|
||||||
const relays = obj.relays
|
const relays = obj.relays
|
||||||
if (!Array.isArray(relays) || relays.length === 0 || !relays.every((r) => typeof r === 'string')) {
|
if (!Array.isArray(relays) || relays.length === 0 || !relays.every((r) => typeof r === 'string')) {
|
||||||
throw new Error('parseSpireSeed: relays must be a non-empty string array')
|
throw new Error('parseSpireSeed: relays must be a non-empty string array')
|
||||||
}
|
}
|
||||||
|
relays.forEach((r, i) => assertRelayUrl(r as string, `relays[${i}]`))
|
||||||
|
|
||||||
return { v: 1, spirePubkey, bunkerUrl, relays: relays as string[] }
|
// Optional bunker relay; default to the first event relay. Keeps the common
|
||||||
|
// case (bunker on the same relay) one field lighter, while still allowing a
|
||||||
|
// distinct NIP-46 relay when the operator runs one.
|
||||||
|
let bunkerRelay = relays[0] as string
|
||||||
|
if (obj.bunker_relay !== undefined) {
|
||||||
|
if (typeof obj.bunker_relay !== 'string' || obj.bunker_relay.length === 0) {
|
||||||
|
throw new Error('parseSpireSeed: bunker_relay, if present, must be a non-empty string')
|
||||||
|
}
|
||||||
|
assertRelayUrl(obj.bunker_relay, 'bunker_relay')
|
||||||
|
bunkerRelay = obj.bunker_relay
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct the bunker URL nostr-tools expects. relay + secret are
|
||||||
|
// percent-encoded here; parseBunkerInput decodes them downstream.
|
||||||
|
const bunkerUrl =
|
||||||
|
`bunker://${spirePubkey}` +
|
||||||
|
`?relay=${encodeURIComponent(bunkerRelay)}` +
|
||||||
|
`&secret=${encodeURIComponent(bunkerSecret)}`
|
||||||
|
|
||||||
|
return { v: 1, spirePubkey, bunkerUrl, relays: relays as string[], lnbitsServerPubkey }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue