refactor(machine): drop VITE_LNBITS_HTTP_URL — lnurl now arrives populated from LNbits (#57 gap 2)

Closes gap 2 from coord log 2026-06-01T18:30Z. The LNbits withdraw
extension's nostr-transport RPC now populates `link.lnurl` from
`settings.lnbits_baseurl` (aiolabs/withdraw#1 / commit e9d911e), so the
ATM no longer needs a separate HTTP URL on the wire to compose the
LNURL-withdraw callback itself.

What goes:

- `VITE_LNBITS_HTTP_URL` env var (renderer + Electron main)
- `lnbitsHttpUrl` field on `LightningConfig`, `RuntimeConfig`, and the
  Window mirror in `src/types/electron.d.ts`
- The manual `${lnbitsHttpUrl}/withdraw/api/v1/lnurl/${unique_hash}`
  composition in `generateLnurlWithdraw`
- The `encodeLnurl` bech32 helper in `lightning.ts` (LNbits returns
  bech32-encoded; we just `.toUpperCase()` to match BOLT/LNURL convention)
- `@scure/base` dep from `apps/machine/package.json` (only used by the
  removed helper; clink still uses it directly)
- The `lnbitsHttpUrl` option + `LNBITS_HTTP_URL=…` env var + boot echo
  in `deploy/nixos/bitspire-atm.nix`
- Doc references in CLAUDE.md, README.md, deploy/nixos/README.md,
  docs/architecture-comparison.md, and the lightning-check skill

What stays:

- `link.lnurl` consumption, with an explicit error if LNbits returns
  null (which signals `LNBITS_BASEURL` is unset on the server side —
  better to fail clearly than silently)
- The receiver-side bech32 uppercasing (LNbits returns lowercase per
  the standard library)

Why this is a net win:

- Removes a config-drift surface — if LNbits's external URL moved
  (DNS, port, reverse-proxy rewrite), every ATM in the field would
  stop issuing redeemable LNURL-withdraw QRs until reconfigured.
  Now LNbits derives its own URL from `settings.lnbits_baseurl`,
  one source of truth.
- Removes an extra provisioning step. No more `LNBITS_HTTP_URL=…`
  before running `provision-atm.sh`; the relay + server pubkey suffice.
- Removes the misleading boot echo that triggered the §`18:30Z`
  smoke triage confusion ("LNbits HTTP: <url>" read like ATM-→-LNbits
  connectivity, when it was only ever a URL embedded in customer QRs).

Also adds a `# pragma: allowlist secret` marker above the
`VITE_ATM_PRIVATE_KEY` doc block in `.env.example` so the global
secret scanner stops false-positiving on the documentation prose.

Workspace typecheck + 24/24 apps/machine tests still green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-01 20:33:28 +02:00
commit 4f68ddc40b
14 changed files with 63 additions and 91 deletions

View file

@ -75,7 +75,7 @@ LNbits transport requires NIP-44 v2, NOT NIP-04. Required checks:
LNbits derives the calling account from the event signature. There is **no admin token, no API key, no client cert** on the ATM. Required checks:
- [ ] No code stores or references an admin token
- [ ] No code makes outbound HTTP to LNbits (other than via `VITE_LNBITS_HTTP_URL` for the LNURL-withdraw callback, which is the customer wallet's path — the ATM itself doesn't hit HTTP)
- [ ] No code makes outbound HTTP to LNbits (the LNURL-withdraw callback URL embedded in cash-in QRs is the customer wallet's path — the ATM itself doesn't hit HTTP). Post aiolabs/withdraw#1 / `e9d911e`, the ATM does not even compose that URL: LNbits's nostr-transport returns `link.lnurl` populated from `settings.lnbits_baseurl`
- [ ] The ATM's `identity.privateKey` is loaded once from `/var/lib/bitspire/.env` and never logged
### 4. Cash-out flow (`--lnbits`)
@ -125,8 +125,10 @@ const link = await lnbits.createWithdrawLink(walletId, {
is_unique: false,
})
const callbackUrl = `${CONFIG.lnbitsHttpUrl.replace(/\/+$/, '')}/withdraw/api/v1/lnurl/${link.unique_hash}`
const lnurl = encodeLnurl(callbackUrl) // bech32 HRP="lnurl", uppercase
if (!link.lnurl) {
throw new Error('LNbits returned link.lnurl=null — check LNBITS_BASEURL on the server')
}
const lnurl = link.lnurl.toUpperCase()
const subId = await lnbits.subscribePayments(walletId, {
tag: 'withdraw',
@ -139,8 +141,8 @@ Required checks:
- [ ] `uses: 1` — single-use prevents replay
- [ ] `min_withdrawable === max_withdrawable === ctx.satsAmount` — exact amount, no operator slippage
- [ ] The LNURL is composed from `VITE_LNBITS_HTTP_URL` + `link.unique_hash` (the transport `lnurlw_create_link` returns `link.lnurl === null` — those fields are filled by HTTP views, not the create RPC)
- [ ] bech32 encoding uses HRP `"lnurl"` and the result is uppercased per BOLT/LNURL convention
- [ ] The LNURL comes from `link.lnurl` directly (LNbits populates it from `settings.lnbits_baseurl` over the nostr-transport per aiolabs/withdraw#1 / `e9d911e`); error out if it's null instead of falling back to a hardcoded HTTP URL
- [ ] Uppercase the bech32 string per BOLT/LNURL convention (the LNbits side returns it lowercase)
- [ ] Subscription filter is `tag: 'withdraw'` + `link_id: link.id` — this is what the withdraw extension stamps on settlement events
- [ ] On session abort, the cleanup closure both unsubscribes AND deletes the withdraw link (so a half-completed session doesn't leave a redeemable QR alive on LNbits)