Unpaired machine shows "ATM Unavailable" instead of the pairing wizard (config validated before pairing) #70

Open
opened 2026-06-29 22:07:54 +00:00 by padreug · 2 comments
Owner

Summary

A fresh / unprovisioned machine boots to the "ATM Unavailable" maintenance screen instead of the QR-pairing wizard. The whole point of pairing is that a fresh machine needs nothing pre-provisioned — but the boot flow currently requires VITE_LNBITS_SERVER_PUBKEY (and a non-localhost VITE_RELAY_URL) before it ever checks pairing state, so an unpaired machine fails config validation and never reaches the wizard.

Found while testing the freshly-built Sintra images (live ISO + disk-image-sintra-usb): both have an empty .env (relay + server pubkey blank by design) and boot straight to "ATM Unavailable".

Root cause

apps/machine/src/services/lightning.ts → initializeLightningServices() runs in this order:

  1. loadLightningConfig()
  2. strict-mode check: rejects localhost relay + missing lnbitsServerPubkey
  3. if (!CONFIG.lnbitsServerPubkey) throw 'VITE_LNBITS_SERVER_PUBKEY is required' — fires even in non-strict mode
  4. resolveSigner(...) ← only here does NoPairingError get thrown for an unpaired machine

apps/machine/src/services/init-error.ts → classifyInitError() maps only NoPairingError / BunkerRejectedError → unpaired (→ wizard). The config error at step 3 is a generic Error, so it surfaces its raw message → the static "ATM Unavailable" screen (App.vue), never the wizard.

So: config validation happens before the pairing check. An unpaired machine with a blank relay/pubkey can't reach the wizard.

Design intent (per discussion)

An unpaired machine should not need VITE_RELAY_URL or VITE_LNBITS_SERVER_PUBKEY provisioned — pairing is supposed to provide what's needed. Today the seed only carries the signing/transport bits, not the LNbits transport config:

  • The spire-seed carries: one-shot NIP-46 connect token, spire signing pubkey, bunker URL (which itself contains a relay for NIP-46). See packages/nostr-client/src/bunker-signer.ts (spirePubkey, bunkerUrl).
  • It does not carry the LNbits transport relay (VITE_RELAY_URL) or the LNbits server pubkey (VITE_LNBITS_SERVER_PUBKEY) — those are still provisioned separately into .env (via provision-atm.sh / the activation .env template), and the journal shows them as distinct from the ATM/spire pubkey.

Proposed direction (two parts)

  1. Reorder so unpaired → wizard first. Move the signer/pairing resolution (or at least a "do we have a seed or binding?" check) ahead of the relay/LNbits-pubkey validation, so a machine with no pairing throws NoPairingError (→ unpaired → wizard) regardless of relay/pubkey. The relay/pubkey validation then only applies to a paired machine that's actually trying to talk to LNbits.
  2. Make pairing carry the LNbits transport config so a paired machine has everything without .env provisioning. Either extend the spire-seed (spirekeeper mint + parseSpireSeed) to include the LNbits transport relay + LNbits server pubkey, or derive them from the bunker/pairing.

Open questions (for tomorrow)

  1. Is the relay in the bunker://…?relay= URL the same relay used for the ATM↔LNbits kind-21000 transport, or separate? If same, the relay is already in the seed and only the LNbits server pubkey needs adding.
  2. Where should the LNbits server pubkey come from on a fresh machine — baked into the seed by spirekeeper at mint time, or discovered during pairing?
  3. Minimal vs full fix: ship the reorder first (unpaired machines reach the wizard), then the seed-carries-transport-config change as a follow-up?
  • #52 (on-machine QR-pairing wizard)
  • #41 (runtime site config — where instance-level config lives)

Code refs

  • apps/machine/src/services/lightning.ts — initializeLightningServices() (config validation before resolveSigner)
  • apps/machine/src/services/init-error.ts — classifyInitError()
  • apps/machine/src/services/signer-resolver.ts — resolveSigner / NoPairingError
  • packages/nostr-client/src/bunker-signer.ts — seed fields (spirePubkey, bunkerUrl)
## Summary A fresh / unprovisioned machine boots to the **"ATM Unavailable"** maintenance screen instead of the **QR-pairing wizard**. The whole point of pairing is that a fresh machine needs *nothing* pre-provisioned — but the boot flow currently requires `VITE_LNBITS_SERVER_PUBKEY` (and a non-localhost `VITE_RELAY_URL`) **before** it ever checks pairing state, so an unpaired machine fails config validation and never reaches the wizard. Found while testing the freshly-built Sintra images (live ISO + `disk-image-sintra-usb`): both have an empty `.env` (relay + server pubkey blank by design) and boot straight to "ATM Unavailable". ## Root cause `apps/machine/src/services/lightning.ts` → `initializeLightningServices()` runs in this order: 1. `loadLightningConfig()` 2. **strict-mode check**: rejects localhost relay + missing `lnbitsServerPubkey` 3. **`if (!CONFIG.lnbitsServerPubkey) throw 'VITE_LNBITS_SERVER_PUBKEY is required'`** — fires even in **non-strict** mode 4. `resolveSigner(...)` ← only here does `NoPairingError` get thrown for an unpaired machine `apps/machine/src/services/init-error.ts` → `classifyInitError()` maps only `NoPairingError` / `BunkerRejectedError` → `unpaired` (→ wizard). The config error at step 3 is a generic `Error`, so it surfaces its raw message → the static **"ATM Unavailable"** screen (`App.vue`), never the wizard. So: **config validation happens before the pairing check.** An unpaired machine with a blank relay/pubkey can't reach the wizard. ## Design intent (per discussion) An unpaired machine should **not** need `VITE_RELAY_URL` or `VITE_LNBITS_SERVER_PUBKEY` provisioned — pairing is supposed to provide what's needed. Today the seed only carries the signing/transport bits, not the LNbits transport config: - The `spire-seed` carries: one-shot NIP-46 connect token, **spire signing pubkey**, **bunker URL** (which itself contains a relay for NIP-46). See `packages/nostr-client/src/bunker-signer.ts` (`spirePubkey`, `bunkerUrl`). - It does **not** carry the **LNbits transport relay** (`VITE_RELAY_URL`) or the **LNbits server pubkey** (`VITE_LNBITS_SERVER_PUBKEY`) — those are still provisioned separately into `.env` (via `provision-atm.sh` / the activation `.env` template), and the journal shows them as distinct from the ATM/spire pubkey. ## Proposed direction (two parts) 1. **Reorder so unpaired → wizard first.** Move the signer/pairing resolution (or at least a "do we have a seed or binding?" check) ahead of the relay/LNbits-pubkey validation, so a machine with no pairing throws `NoPairingError` (→ `unpaired` → wizard) regardless of relay/pubkey. The relay/pubkey validation then only applies to a *paired* machine that's actually trying to talk to LNbits. 2. **Make pairing carry the LNbits transport config** so a paired machine has everything without `.env` provisioning. Either extend the `spire-seed` (spirekeeper mint + `parseSpireSeed`) to include the LNbits transport relay + LNbits server pubkey, or derive them from the bunker/pairing. ## Open questions (for tomorrow) 1. Is the relay in the `bunker://…?relay=` URL the **same** relay used for the ATM↔LNbits kind-21000 transport, or separate? If same, the relay is already in the seed and only the LNbits **server pubkey** needs adding. 2. Where should the LNbits server pubkey come from on a fresh machine — baked into the seed by spirekeeper at mint time, or discovered during pairing? 3. Minimal vs full fix: ship the reorder first (unpaired machines reach the wizard), then the seed-carries-transport-config change as a follow-up? ## Related - #52 (on-machine QR-pairing wizard) - #41 (runtime site config — where instance-level config lives) ## Code refs - `apps/machine/src/services/lightning.ts` — `initializeLightningServices()` (config validation before `resolveSigner`) - `apps/machine/src/services/init-error.ts` — `classifyInitError()` - `apps/machine/src/services/signer-resolver.ts` — `resolveSigner` / `NoPairingError` - `packages/nostr-client/src/bunker-signer.ts` — seed fields (`spirePubkey`, `bunkerUrl`)
Author
Owner

Part 1 (reorder) — done in 334cb86 (dev)

initializeLightningServices() now resolves the signer before the strict + VITE_LNBITS_SERVER_PUBKEY validation. An unpaired machine (no seed, no binding, blank .env) throws NoPairingError → unpaired → wizard regardless of relay/pubkey provisioning; a paired machine still hits the config validation it legitimately needs. Typecheck clean, 34/34 tests pass.

Open question #1 answered: the seed already carries the transport relay

Reading packages/nostr-client/src/seed.ts, the spire-seed already includes a relays[] array, documented as "relays where the spire publishes its own events (kind 21000 / 30078)". kind-21000 is the LNbits nostr-transport — so the transport relay is already in the seed; it's just not consumed as VITE_RELAY_URL today. The bunker_url's relay= is a separate (possibly different) relay for NIP-46 and is not the thing we need here.

So part 2 narrows: the only field genuinely missing from the seed is the LNbits server pubkey (VITE_LNBITS_SERVER_PUBKEY).

Part 2 (follow-up) — narrowed scope

Two sides:

  1. Consumer (this repo): source relayUrl from seed.relays and lnbitsServerPubkey from a new optional seed field when paired, falling back to env (env override stays useful for dev). Requires threading the parsed seed's relays + lnbitsPubkey out of resolveSigner and persisting them into the bunker_binding record (state.db + Electron IPC types) so resume-from-binding also has them.
  2. Minter (spirekeeper, separate repo): add lnbits_pubkey to the seed JSON at /pair mint time. Hard dependency — until spirekeeper mints it, the field is always absent, so part 2's consumer side can't be exercised end-to-end.

Open question #2 (where the server pubkey comes from): spirekeeper is the operator dashboard and knows/operates the LNbits instance, so minting it into the seed at /pair time is the natural source — no fresh-machine discovery step needed.

This is why part 1 shipped on its own: it's self-contained and unblocks the wizard; part 2 spans repos and needs the spirekeeper mint first.

## Part 1 (reorder) — done in `334cb86` (dev) `initializeLightningServices()` now resolves the signer **before** the strict + `VITE_LNBITS_SERVER_PUBKEY` validation. An unpaired machine (no seed, no binding, blank `.env`) throws `NoPairingError` → `unpaired` → wizard regardless of relay/pubkey provisioning; a paired machine still hits the config validation it legitimately needs. Typecheck clean, 34/34 tests pass. ## Open question #1 answered: the seed already carries the transport relay Reading `packages/nostr-client/src/seed.ts`, the `spire-seed` already includes a `relays[]` array, documented as *"relays where the spire publishes its own events (kind 21000 / 30078)"*. **kind-21000 is the LNbits nostr-transport** — so the transport relay is already in the seed; it's just not consumed as `VITE_RELAY_URL` today. The `bunker_url`'s `relay=` is a *separate* (possibly different) relay for NIP-46 and is not the thing we need here. So part 2 narrows: the only field genuinely missing from the seed is the **LNbits server pubkey** (`VITE_LNBITS_SERVER_PUBKEY`). ## Part 2 (follow-up) — narrowed scope Two sides: 1. **Consumer (this repo):** source `relayUrl` from `seed.relays` and `lnbitsServerPubkey` from a new optional seed field when paired, falling back to env (env override stays useful for dev). Requires threading the parsed seed's `relays` + `lnbitsPubkey` out of `resolveSigner` and persisting them into the `bunker_binding` record (state.db + Electron IPC types) so resume-from-binding also has them. 2. **Minter (spirekeeper, separate repo):** add `lnbits_pubkey` to the seed JSON at `/pair` mint time. Hard dependency — until spirekeeper mints it, the field is always absent, so part 2's consumer side can't be exercised end-to-end. Open question #2 (where the server pubkey comes from): spirekeeper is the operator dashboard and knows/operates the LNbits instance, so minting it into the seed at `/pair` time is the natural source — no fresh-machine discovery step needed. This is why part 1 shipped on its own: it's self-contained and unblocks the wizard; part 2 spans repos and needs the spirekeeper mint first.
Author
Owner

Parts D + E implemented

D — bitspire consumer wiring (on dev, local commits 53a0c2d, 549491a, e578680; push held, see rollout):

  • Seed slimmed (seed.ts): pubkey carried once as npub → derive hex + reconstruct bunker_url; optional bunker_relay→relays[0]; new lnbits_npub → lnbitsServerPubkey.
  • resolveSigner now returns { signer, transport }. Transport (relays + lnbitsServerPubkey) comes from the seed on pair / seeded resume, from the binding on seedless resume. Threaded out of resolveSigner rather than re-parsed in loadLightningConfig, because the seed arrives over the one-shot get-atm-secrets IPC — a second consumer would break that contract.
  • bunker_binding persists relays + lnbits_server_pubkey (state.db v11→v12, nullable → pre-#70 bindings resume and fall back to env).
  • initializeLightningServices resolves effective transport env-wins → pairing → dev localhost, validating the resolved values.
  • Resolver-resilience guard: an unparseable stored seed with a binding present resumes from the binding (so an old-shape seed can't brick a paired machine on auto-pull).
  • Tests: renderer + electron typechecks, 38 machine tests (incl. new state.db binding round-trip), nostr-client build + 18 seed tests.

E — spirekeeper mint: PR aiolabs/spirekeeper#37 (awaiting merge via Forgejo UI). build_seed_url emits the slim shape; pair_spire reads settings.nostr_transport_public_key → hex_to_npub → lnbits_npub, raising PairingError if the transport isn't running. 216 tests pass (incl. a separate commit fixing two pre-existing endpoint-test failures on main).

Rollout gate

bitspire dev push is held until spirekeeper#37 merges and the deployed lnbits has the updated extension — otherwise bitspire's new parser would reject an old-shape seed still minted by the deployed spirekeeper. Order: merge #37 → bump lnbits-extensions catalog (spirekeeper) → redeploy demo lnbits → push bitspire dev + cache → re-pair the Sintra with a fresh seed → verify blank-.env → wizard → paired → backend end-to-end.

(The Sintra itself keeps working through the gap regardless: old-shape seed + binding → resume from binding → env-provided relay/pubkey.)

## Parts D + E implemented **D — bitspire consumer wiring** (on `dev`, local commits `53a0c2d`, `549491a`, `e578680`; **push held**, see rollout): - Seed slimmed (`seed.ts`): pubkey carried once as npub → derive hex + reconstruct `bunker_url`; optional `bunker_relay`→`relays[0]`; new `lnbits_npub` → `lnbitsServerPubkey`. - `resolveSigner` now returns `{ signer, transport }`. Transport (relays + `lnbitsServerPubkey`) comes from the seed on pair / seeded resume, from the binding on seedless resume. Threaded out of `resolveSigner` rather than re-parsed in `loadLightningConfig`, because the seed arrives over the one-shot `get-atm-secrets` IPC — a second consumer would break that contract. - `bunker_binding` persists `relays` + `lnbits_server_pubkey` (state.db v11→v12, nullable → pre-#70 bindings resume and fall back to env). - `initializeLightningServices` resolves effective transport **env-wins → pairing → dev localhost**, validating the resolved values. - Resolver-resilience guard: an unparseable stored seed with a binding present resumes from the binding (so an old-shape seed can't brick a paired machine on auto-pull). - Tests: renderer + electron typechecks, 38 machine tests (incl. new state.db binding round-trip), nostr-client build + 18 seed tests. **E — spirekeeper mint**: **PR aiolabs/spirekeeper#37** (awaiting merge via Forgejo UI). `build_seed_url` emits the slim shape; `pair_spire` reads `settings.nostr_transport_public_key` → `hex_to_npub` → `lnbits_npub`, raising `PairingError` if the transport isn't running. 216 tests pass (incl. a separate commit fixing two pre-existing endpoint-test failures on main). ## Rollout gate bitspire `dev` push is **held** until spirekeeper#37 merges **and the deployed lnbits has the updated extension** — otherwise bitspire's new parser would reject an old-shape seed still minted by the deployed spirekeeper. Order: merge #37 → bump `lnbits-extensions` catalog (spirekeeper) → redeploy demo lnbits → push bitspire `dev` + cache → re-pair the Sintra with a fresh seed → verify blank-`.env` → wizard → paired → backend end-to-end. (The Sintra itself keeps working through the gap regardless: old-shape seed + binding → resume from binding → env-provided relay/pubkey.)
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/bitspire#70
No description provided.