The script unconditionally wrote VITE_RELAY_URL + VITE_LNBITS_SERVER_PUBKEY (and
hard-exited if it couldn't scrape the pubkey), env-pinning every provisioned
machine and defeating the seed — the same bug as the activation default. Make it
seed-first: with a SPIRE_SEED, relay + pubkey come from the seed and are written
only when the operator explicitly passes RELAY_URL / LNBITS_SERVER_PUBKEY as a
deliberate pin. The no-seed dev-nsec path still scrapes/defaults them. Also drops
the unused VITE_LNBITS_HTTP_URL line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bitspire-env activation seeded VITE_RELAY_URL from the relayUrl option
(default wss://relay.aiolabs.dev). Because env wins over the pairing seed, every
fresh machine pinned itself to that relay — which is dead — so a scanned seed's
relay was ignored ("No connected relays"; hit live on the aio-demo USB). Default
relayUrl to "" so both relay and server pubkey come from the seed; a non-empty
option now pins a machine (an explicit override) rather than being the default.
Descriptions updated to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Electron main's get-config returned relayUrl = VITE_RELAY_URL ||
'ws://localhost:7777'. On an unprovisioned (blank-.env) machine that non-empty
localhost default reached the renderer and, via the env-first precedence, won
over the pairing seed's relay — then failed strict validation as localhost.
That defeated #70's "the seed provides the relay": the Sintra paired fine but
booted with ws://localhost:7777 instead of the seed's nostrclient endpoint.
Return '' when unset so the renderer falls through to the seed's transport
relay (its own ws://localhost:7777 dev fallback only applies when neither env
nor pairing supplies one). Mirror of the renderer default fixed in e578680.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A well-formed but unreachable relay (localhost baked into a seed for a remote
machine, a wrong LAN IP, a relay that's down) parses fine and only fails later
as a NIP-46 connect crash-loop. Give the operator a way to catch it on-machine
before committing (bitspire-#70).
The wizard no longer commits immediately on a good scan: it now parses (without
persisting) and shows a review step with the decoded spire + relay(s), a "Test
relay" button (opens a WebSocket + NIP-01 REQ, reports reachable/latency or
unreachable), and Pair / Rescan. Only on "Pair" does it persist + relaunch into
the real pairing path.
- parseScannedSeed: validate-only split of ingestScannedSeed (no persist).
- testRelay: WebSocket reachability probe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The npubs in the seed are bech32-checksummed, so a mis-scanned character is
caught — but the relay strings are raw inside the base64. A QR misread silently
turned `ws://192.168.0.32:5001/...` into `As://192.168.0.32:5001/...`, which
parsed fine and then crash-looped the machine on an unreachable NIP-46 relay.
Validate every `relays[]` entry (and `bunker_relay`) is a `ws://`/`wss://` URL
at parse time, so a garbled scan is rejected as an invalid seed instead of
persisted. Part of bitspire-#70 pairing robustness.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the consumer half of bitspire-#70: a paired machine gets its LNbits
transport relay(s) + server pubkey from the pairing, so a blank-.env unit reaches
the backend after scanning a seed — no VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY
provisioning.
- resolveSigner now returns { signer, transport }. transport (relays +
lnbitsServerPubkey) comes from the seed on a fresh pair / seeded resume, and
from the binding on a seedless resume. It's threaded out of resolveSigner
rather than re-parsed in loadLightningConfig because the seed arrives over the
one-shot get-atm-secrets IPC — a second consumer would break that contract.
- bunker_binding persists relays + lnbits_server_pubkey (state.db v11→v12,
nullable so pre-#70 bindings resume and fall back to env). Mirrored into
BunkerBindingRecord (preload + electron.d.ts).
- initializeLightningServices resolves effective transport with env-wins
precedence (explicit env override for dev, else pairing, else a dev-only
localhost relay), mutating CONFIG to a single source of truth and building the
Nostr/LNbits/CLINK clients from the full relay list. Strict + required-config
validation now run on the resolved values.
state.db round-trip test covers the new columns + their absence on a pre-#70
binding. Renderer + electron typechecks and all 38 machine tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
resolveSigner parses the stored VITE_SPIRE_SEED on every boot before it checks
the binding, so a machine whose .env still holds a legacy-shape seed would
throw on the new parser (bitspire-#70) and surface "ATM Unavailable" on the
next auto-pull — even though it has a perfectly good, server-persistent binding
to resume from.
Guard the parse: an unparseable stored seed with a binding present falls back
to resuming the binding (authoritative); with no binding it still fails closed,
since the seed is then the only pairing input. Also dedupes the three
resume-from-binding call sites behind a small local.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The v1 seed spelled the spire pubkey three times — spire_npub, spire_pubkey
(hex), and again inside a full bunker_url — which bloats a QR that's already
hard to scan off the machine's camera. Carry it once, as an npub, and derive
the rest:
- spire_pubkey (hex) ← decode(spire_npub). npub is ~the same length as hex but
carries a bech32 checksum, so a mis-scanned character is caught instead of
yielding a wrong-but-valid-looking key.
- bunker_url ← reconstructed from spire_pubkey + bunker_secret + bunker_relay.
- bunker_relay is OPTIONAL, defaulting to relays[0] (option 3): minimal in the
common case where the bunker shares the event relay, explicit when it differs.
- lnbits_npub is NEW — gives a paired machine its LNbits transport server pubkey
from the seed itself, so nothing else needs provisioning (bitspire-#70 part 2).
Kept as v: 1 (redefined in place, no compat shim): the seed is a one-shot
pairing token, no bitspire machine has shipped, and a paired machine resumes
from its stored binding, not by re-parsing the seed. Roughly a third smaller
encoded — ~180-200 fewer chars in the QR.
Lockstep: aiolabs/spirekeeper pairing.py must emit the new shape (spire_npub +
lnbits_npub + bunker_secret, drop spire_pubkey/bunker_url) before a new seed can
be minted. Consumer wiring (relays + lnbitsServerPubkey into LightningConfig)
and a resolver-resilience guard for machines holding an old-shape seed land
separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sintra live ISO (live.nix) had no serial support — ftdi_sio and the
ttyJ5/ttyJ7 udev symlinks were only in hardware/upboard.nix (installed), so
booting iso-sintra on real hardware failed on the validator + F56 dispenser
while the disk image worked. The two definitions had already drifted (live's
tejo block lacked ttyS4).
Extract the UP Board serial peripherals (usbserial/ftdi_sio/cp210x, the
ttyJ4/ttyJ5/ttyJ7 udev symlinks + permissions, console=tty0) into
hardware/upboard-serial.nix and import it from both upboard.nix (installed
tejo + sintra) and live.nix (sintra only). Single source of truth — the two
artifacts can't drift again. Named upboard-serial (not sintra-serial) since
upboard.nix serves both tejo-installed and sintra-installed.
Camera + LED/SPI rules stay inline in upboard.nix (installed-specific; the
pairing camera works via getUserMedia without the scanner symlink). Verified
by eval: live sintra now carries ftdi_sio + console=tty0 + ttyJ7; installed
sintra/tejo unchanged (serial present, camera present, no console dupe).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The USB disk image was systemd-boot (UEFI-only) with make-disk-image's "efi"
table (pure GPT + ESP, protective MBR). The Sintra's Aaeon UP Board firmware
USB-boots in Legacy/BIOS mode — it boots the live ISO via that ISO's isolinux
(BIOS) El Torito image, not the UEFI ESP — so a dd'd systemd-boot image has no
BIOS boot code to execute and the firmware won't list it (a hand-added hybrid
MBR didn't help: nothing to run).
Switch the USB target to GRUB with BIOS + UEFI on make-disk-image's "hybrid"
table: it adds a bios_grub partition, GRUB writes its BIOS stage to the MBR AND
a removable /EFI/BOOT/BOOTX64.EFI — mirroring the live ISO's dual boot. The
Aaeon now lists it (as two "ia android" entries, BIOS + UEFI) and boots it.
Scoped to disk-image-sintra-usb only; the eMMC install keeps systemd-boot.
ESP stays partition 1 so the ESP-USB relabel step is unchanged.
Verified on hardware: booted from USB into the wizard with the full upboard.nix
hardware config.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The disk image was ~6.2 GiB of closure, largely desktop/multimedia baggage a
single-purpose Electron kiosk never uses. Cut the clearly-unused stacks:
- services.speechd off → drops speech-dispatcher's espeak-ng + mbrola voices
(~1 GB text-to-speech). An ATM does not talk.
- v4l-utils built withGUI=false → drops the entire Qt6 stack (~0.5 GB) that only
backed the qv4l2 GUI; the v4l2-ctl CLI we actually use for the camera stays.
- documentation off (man/info/NixOS manual) — nobody reads them on a kiosk.
Closure 6.2 → 5.0 GiB. The remaining bulk is electron's own runtime (gtk4/
gstreamer/pipewire, unavoidable), mesa+llvm (GPU), and linux-firmware — those
need heavier / riskier work to touch. Distribute the image as .img.zst.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
initializeLightningServices() validated VITE_LNBITS_SERVER_PUBKEY (and, in
strict mode, rejected a localhost relay) *before* calling resolveSigner. An
unpaired machine — no seed, no binding, blank .env — therefore threw a generic
config Error that classifyInitError surfaces as the static "ATM Unavailable"
screen, never the NoPairingError that routes to the QR-pairing wizard.
Pairing is what's meant to provide the transport config, so the pairing check
must come first. Move resolveSigner ahead of the strict + server-pubkey
validation: an unpaired machine now throws NoPairingError → 'unpaired' →
wizard regardless of relay/pubkey provisioning, while a paired machine still
hits the config validation it legitimately needs.
Surfaced testing the freshly-built Sintra images (live ISO + USB disk image),
both of which ship a blank .env by design and booted straight to "ATM
Unavailable". bitspire-#70 (part 1 of 2; part 2 = seed carries the LNbits
server pubkey).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fresh live boots hit a cascade of activation/unit failures because live.nix
shared installed-system config that assumes persistent state:
- bitspire-env chowned /var/lib/bitspire/.env to bitspire:bitspire in the
default activation order, before `users` runs, so on a fresh boot (no .env
yet) it failed with 'invalid user'. Move to the attrset form with
deps=["users"]. (Installed systems skip the block since .env exists.)
- swapDevices=/var/swapfile lives in the live tmpfs and fails to init —
replace with zramSwap for the low-RAM models' OOM cushion.
- wg0 needs a provisioned key the live boot lacks; it failed and dragged
network-setup down. Drop the interface on live.
- display-reset runs `xrandr --output eDP-1`, but the Sintra drives HDMI-1
(no eDP-1) — gate the service off for sintra.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A USB-bootable Sintra image variant for booting on a machine whose eMMC
already holds a nixos/ESP-labelled install. make-disk-image hardcodes the
root/ESP labels (nixos/ESP); booting the standard image from USB next to
the eMMC races stage-1's by-label/nixos between the two roots and likely
mounts the eMMC. This variant labels root nixos-usb (via make-disk-image
-L) and relabels the ESP to ESP-USB in a post-step (mtools), with
fileSystems pointed at the new labels. Auto-upgrade is disabled — it's a
portable test / hand-off image, and that also removes scheduled bootloader
writes that could land on the eMMC's ESP.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The live ISO config set makeEfiBootable + makeBiosBootable but omitted
makeUsbBootable, so the image got BIOS+UEFI El Torito boot catalogs but
no isohybrid MBR/GPT — i.e. no partition table. dd'd to a USB stick it
shows iso9660 on the whole device with no ESP, and picky firmware (the
Sintra's Aaeon UP Board) won't recognise it as bootable, falling back to
its android-ia entry. Enabling makeUsbBootable applies the isohybrid MBR
(isohdpfx.bin) + GPT/ESP. Fixes USB boot for every model's live ISO.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dev autoUpgrade flake URL still referenced the pre-migration repo
(aiolabs/lamassu-next), so the Sintra test unit would auto-pull
lamassu-next/dev at 04:00 — which lacks all the bitspire work (the
QR-pairing wizard, etc.) and would revert the box to the old no-seed
build. Repoint it at aiolabs/bitspire?ref=dev, the post-migration home
of this code. lamassu-next still feeds the not-yet-converted production
ATMs (batm3, douro) until they migrate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The camera pairing source decoded frames at the <video> element's CSS box
size (qr's readFrame default) rather than the intrinsic frame, and let the
stream stay at the panel-bound ~720p that frontalCamera negotiates from the
screen size. On the 1280x800 kiosk with a fixed-focus 5MP scan camera that
left far too few pixels-per-module for a dense spire-seed QR, so a centered,
in-square code never decoded.
Decode the intrinsic frame (readFrame fullSize=true) and pin a deliberate
1280x960 capture via applyConstraints. lamassu-machine caps QR scanning at
640x480 for decode speed (megapixels only slow the per-frame decode); our
seed QR is denser than a lightning invoice, so 1280x960 balances
pixels-per-module against latency and keeps auto-exposure from blowing out a
frame-filling phone screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adding `qr` (and dropping `jsqr`) changed pnpm-lock.yaml, invalidating the
fixed-output hash for the vendored pnpm store. Without this the NixOS build
of the ATM app fails at the FOD before activation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Note the wizard flow next to VITE_SPIRE_SEED + a dedicated Pairing section
(aiolabs/bitspire#52): unpaired → scan seed off camera → persist + relaunch →
normal boot pairs. Records the qr-over-jsqr choice rationale by reference.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wires the capture + ingest pieces into a screen (aiolabs/bitspire#52). When
the machine boots `unpaired` (fresh, or binding revoked/expired) and runs
under Electron, App.vue renders `PairingWizard` in place of the static
"Pairing Required" card.
The wizard probes available sources, shows the camera viewfinder, and on a
valid scan persists + relaunches. A stray/non-seed QR is rejected with a hint
and scanning resumes. NFC (when present) appears as an alternate source
button. Browser dev (no Electron bridge) still falls back to the static card.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The capture half of the QR-pairing wizard (aiolabs/bitspire#52), behind a
`PairingSource` seam so the wizard UI stays agnostic to how the seed arrives:
- `QrPairingSource` — camera capture + decode via `qr` (paulmillr). Chosen
over the dormant, unmaintained `jsqr`: `qr` is zero-dependency, auditable,
dual MIT/Apache, actively maintained, and authored by the same person as the
`@noble`/`@scure` crypto our nostr stack already trusts. Its `qr/dom.js`
helper wraps getUserMedia + the per-frame decode loop.
- `NfcPairingSource` — Web NFC scaffold; `isAvailable()` is false on the
Sintra's Linux Electron, so it's inert until real NFC hardware lands (the
user flagged NFC as a plausible future pairing method).
- `ingestScannedSeed` — validates the scan parses as a spire-seed (rejecting a
stray QR), persists it, and relaunches. Covered by unit tests
(invalid-seed / no-bridge / persist-failed / happy path).
- `availablePairingSources()` probes each source and returns the runnable ones
in preference order (camera first).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation for the on-machine QR-pairing wizard (aiolabs/bitspire#52). An
unpaired ATM can now have a seed planted at runtime rather than only via
provisioning:
- electron IPC `state:save-spire-seed` writes VITE_SPIRE_SEED into the runtime
.env (0600), and `app:relaunch` restarts the kiosk so the normal boot path
(signer-resolver → connectNewSeed) does the actual bunker pairing. We
deliberately do NOT pair in-renderer — persist + relaunch reuses the single,
hardware-tested pairing path.
- signer-resolver throws a typed `NoPairingError` (distinct `.name`, survives
the bundle boundary) when there's no seed and no binding, instead of a
generic Error.
- init-error maps NoPairingError → `unpaired`, so the renderer can route a
fresh machine to the interactive wizard (next commit) rather than a
dead-end fault screen. Revoked/TTL bindings already map there too — re-pair
is the same scan-a-fresh-seed flow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The beacon's createSignedEvent (a bunker round-trip) sat OUTSIDE its try/catch,
and publish() is fire-and-forget — so a transient BunkerTimeoutError /
BunkerRejectedError during the periodic sign surfaced as an uncaught promise
rejection (seen on the Sintra after a bunker watchdog blip during the cash-in
smoke). Move the sign inside the try; the beacon re-publishes every interval, so
swallow + log is correct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the cash-in LNURL-withdraw creation with the secure create_withdraw
RPC (aiolabs/spirekeeper#31/#32). The ATM now sends only the hardware-attested
gross principal_sats; the operator side verifies the signer, derives fee + NET,
and stamps the link's attribution (source/nostr_sender_pubkey) from the VERIFIED
sender. Closes the dev-stack weakness where the ATM set the withdraw amount +
extra itself (could understate the fee / forge attribution).
- LnbitsClient.createWithdraw(walletId, {principal_sats, fiat_amount?, fiat_code?,
title?, wait_time?, client_ref?}) -> {link_id, lnurl, net_sats, principal_sats,
fee_sats}. Non-idempotent (mints a link) -> not retry-wrapped.
- lightning.ts generateLnurlWithdraw: createWithdrawLink -> createWithdraw; the
ATM no longer computes amount/fee/extra. LNURL-session map re-keyed on link_id
(the secure response carries no unique_hash); settlement-watch half unchanged
(subscribe_payments tag:'withdraw', link_id).
Server RPC is live on the dev stack (spirekeeper#32 registered create_withdraw),
so this is ready for the joint cash-in test. typecheck 12/12, full suite + prod
build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cassette-state beacon was published only once at bootstrap, so after a
cash-out dispense the operator's view stayed frozen at the bootstrap snapshot
(still 20x4/50x7 after dispensing) — the ATM decremented its local HAL counts
but never told the operator. Coord 2026-06-21 (post cash-out leg).
- operator-config.ts: extract publishCassettesState() (the live, ungated
publish) out of the one-shot bootstrap; expose it on OperatorConfigService;
also fire it after an operator-config apply (the "on reload" case).
- atm.ts: republish after each cash-out dispense (complete + partial), once the
decremented counts are persisted. kind-30078 is replaceable (latest wins) and
the operator already consumes every update — no operator-side change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The retry half of the 2026-05-26 error-handling agreement (aiolabs/bitspire#52).
`withRetry` retries an operation per the disposition of the error it throws —
LnbitsRpcError.retryPolicy (operator_signer_unavailable/rate_limited →
backoff, internal_error → retry-once) plus transport timeouts — and rethrows
terminal/unknown errors immediately.
Applied ONLY to idempotent reads (getWallet/getBalance/listWallets/getPayment/
decodePayment + the lnurlw read methods). create_invoice / pay_invoice /
lnurlw_create_link are deliberately NOT wrapped — a blind retry would mint a
duplicate or double-pay; their errors surface for flow-level handling. This is
why the switch lives at the per-call read layer, not as a blanket client retry.
Safe to land before lnbits emits error_code: an absent code already maps to
internal_error (retry-once), so reads get one transparent retry on a transient
blip with no behaviour change otherwise. 10 tests (backoff/terminal/timeout/
unknown/onRetry).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
provision-atm.sh now writes VITE_SPIRE_SEED (the spire-seed:v1: pairing seed
from spirekeeper) as the production identity, validating the scheme prefix;
the generated nsec path is kept only as a dev fallback when SPIRE_SEED is
unset. Relay default moved to the LNbits bundled nostrrelay
(ws://$HOST_IP:5001/nostrrelay/test). .env templates (live.nix + the flake's
installed-default) swap VITE_ATM_PRIVATE_KEY → VITE_SPIRE_SEED and drop the
dead LP-era vars. README notes state.db now also holds the bunker binding
(keep it or re-pair).
Part of Phase E, aiolabs/bitspire#52. Unblocks the Sintra live-pairing smoke.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A revoked / TTL-expired / off-policy bunker binding now surfaces a dedicated
"Pairing Required" screen instead of a raw error, and a signer/relay timeout
shows "Signer Unreachable" (transient). Shared classifyInitError() maps the
typed BunkerRejectedError / BunkerTimeoutError (by name, so it survives bundle
boundaries) to maintenance-screen sentinels, used at every store init catch +
the App.vue fallback. App.vue's nested-ternary screen copy refactored to a
keyed map (cleaner, and the new screens drop in).
Scope: boot-time detection (covers the dominant restart-after-revoke case).
Mid-session re-pair detection (flipping the screen when a sign fails during a
live flow) is a deliberate follow-up.
Part of Phase D, aiolabs/bitspire#52.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the error-handling layer agreed in the 2026-05-26 cross-session
handshake (aiolabs/bitspire#52). LnbitsClient now rejects ERROR responses
with a typed LnbitsRpcError carrying the machine-readable code + its retry
disposition, so callers (and the state machine, Phase D.3) branch on
disposition rather than string-matching the human-readable message.
- error-codes.ts: LnbitsErrorCode (14 codes, signer/transport/app classes)
mirroring the lnbits canonical enum; retryPolicyFor() classifier;
LnbitsRpcError.fromResponse().
- error_code is optional-additive on the wire: an absent or unknown code
maps to internal_error (retry-once), so this is safe to land before lnbits
emits codes — no string-matching, no special parser paths.
- invoice_already_paid is flagged terminal-idempotent (isIdempotentSuccess)
for the cash-out resume-after-reboot case.
Part of Phase D, aiolabs/bitspire#52.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
nsecbunkerd#27 enforces token lifecycle at sign time (Option D): an expired
token (`expiresAt`) now stops signing post-bind, not just at connect —
reversing the earlier #24 "TTL is connect-window-only" note. A lapsed TTL
now surfaces as the same BunkerRejectedError as a revoke, so the Phase D
re-pair handling covers both. Docstring corrected to say so.
refs nsecbunkerd#27/#24/#25, aiolabs/bitspire#52
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fund-atm resolves its signer by resuming the bunker binding from state.db
(the connect token is already spent by the main app, so it can't re-pair);
falls back to a dev nsec via VITE_ATM_PRIVATE_KEY. better-sqlite3 marked
external in the esbuild bundle. .env.example + CLAUDE.md document
VITE_SPIRE_SEED as the prod identity, VITE_ATM_PRIVATE_KEY as dev-only.
(fund-atm is slated for deprecation in favour of the operator funding the
wallet directly via the LNbits UI — kept working for now.)
Part of Phase C, aiolabs/bitspire#52.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New signer-resolver.ts turns the ATM's pairing state into a Signer:
- seed present, fingerprint differs from stored binding → pair: generate a
transport key, redeem the one-shot connect secret, persist the binding,
reset the bootstrap gate (re-publish hello to the new operator, #56);
- seed matches binding, or binding-only → resume (no re-redeem);
- neither → ephemeral LocalSigner (dev) or throw (strict/prod).
lightning.ts drops the atmPrivateKey plumbing and calls resolveSigner; the
Phase-A Signer seam means nothing downstream changes. App.vue's maintenance
beacon resolves the same way (best-effort, skips if unpaired).
Part of Phase C, aiolabs/bitspire#52.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
get-atm-secrets now returns { spireSeed, bunkerBinding } instead of the raw
nsec (one-shot semantics kept). Adds IPC handlers + preload bindings for
saveBunkerBinding / clearBunkerBinding / resetBootstrapGate so the renderer
can persist a pairing and re-arm the cassette-state hello on re-pair (#56).
resetBootstrapGate added to state-store. Types mirrored in electron.d.ts.
Part of Phase C, aiolabs/bitspire#52.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Swap CLINKClient's MachineIdentity for the Signer abstraction: sign_event /
nip44 now go through the signer (async), so the spire identity can live in a
NIP-46 bunker. The kind-21003 management path (operator-driven manual
dispense, the one live CLINK path on dev) decrypts as the spire via the
bunker; the dormant offer/debit paths are migrated too so they're
bunker-ready when CLINK is re-implemented for the upcoming ndebit/k1 spec
(shocknet/CLINK#7, #8).
Part of Phase C, aiolabs/bitspire#52.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a bunker_binding singleton table + get/save/clearBunkerBinding
accessors holding the ATM's own NIP-46 transport key (client_nsec), the
spire signing pubkey, the bunker URL, and the seed fingerprint. Persisted
so a restart resumes the bunker session without re-redeeming the one-shot
connect secret; a changed fingerprint signals a re-pair.
The v10→v11 migration is idempotent (CREATE TABLE IF NOT EXISTS), and the
v9→v10 block now advances existing.value so a v9 install chains straight
through to v11 in one boot (matching the v6→v8 blocks).
Phase B of aiolabs/bitspire#52. The IPC bridge + bootstrap resolution that
consume these accessors land in Phase C.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase B of aiolabs/bitspire#52 — the consumer surface for routing signing
to the operator's nsecbunkerd (model A1: the ATM holds only its own NIP-46
transport key; the signing identity lives in the bunker).
- seed.ts: parseSpireSeed for the `spire-seed:v1:<base64url>` contract from
spirekeeper pairing.py — re-pads stripped base64url, validates
{v, spire_pubkey, bunker_url, relays}, leaves percent-decoding of the
bunker URL to parseBunkerInput. seedFingerprint() detects a re-pair.
- bunker-signer.ts: BunkerSigner implements Signer by delegating
sign_event / nip44_* to nostr-tools' nip46 over the bunker relay. pubkey
is the spire identity, known synchronously from the seed. connectNewSeed
redeems the one-shot connect secret; resumeFromBinding reuses the
persisted transport key WITHOUT re-redeeming (the binding is
server-persistent). Per-RPC timeout + typed BunkerRejectedError /
BunkerTimeoutError so callers can distinguish revoked-binding (re-pair)
from a transient outage.
Unit-tested against a fake inner client (delegation, sync pubkey, timeout,
error mapping) + seed round-trip/validation fixtures. Live-relay wiring is
Phase C; live bunker integration is Phase F.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop encryptContent / decryptContent / decryptJSON and the hand-rolled
XChaCha20 + v1 conversation-key machinery they depended on (~230 lines).
The only callers were createMachineStatusEvent / createTransactionEvent,
which had no callers in apps/ and were removed in the Signer migration.
This closes the open question carried in aiolabs/bitspire#52: every live
encryption path is NIP-44 v2, and the nsecbunkerd signer is v2-only, so
there is nothing to keep v1 for. encryptContentV2 / decryptContentV2 stay
as the v2 helpers used by the dormant CLINK client + tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce a Signer interface (signEvent / nip44Encrypt / nip44Decrypt +
sync pubkey) with an in-process LocalSigner backed by an nsec, and route
every signing/encryption call site through it. Behaviour is unchanged —
LocalSigner wraps the same MachineIdentity the code used directly before.
This is Phase A of the bunker migration (aiolabs/bitspire#52): it puts the
seam in place so Phase B can drop in a NIP-46 BunkerSigner at the bootstrap
without touching any call site. The whole chain becomes async (the bunker
path is a relay round-trip; LocalSigner resolves immediately).
Sites moved onto the signer:
- packages/nostr-client: createSignedEvent / createAuthEvent (now async),
NostrClient config (signer not identity), AUTH challenge handler.
- packages/lnbits: LnbitsClient.initialize(nostr, signer); kind-21000 RPC
encrypt + sign + reply-decrypt; handleReply is now async (event-id dedup
still runs synchronously before the awaited decrypt, so replay safety and
per-subscription hash dedup are preserved).
- apps/machine: lightning.ts builds a LocalSigner and exposes it on
LightningServices; operator-config / operator-fees / availability beacon /
maintenance beacon / fund-atm all sign + encrypt via the signer.
NIP-42 auth (kind 22242) is included — under the bunker it must be in the
spire policy (aiolabs/spirekeeper#26, already merged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Separate payment (Nostr↔LNbits, SaaS-operator-owned), fleet control
(Nostr #42, machine-operator-owned), and access/recovery (SSH) planes
by trust owner. Recovery access is provisioned at install and
app-independent. Adopt NetBird for the access plane (scale + fully FOSS
self-hostable control plane; rejects Tailscale's closed control plane).
Reject a dashboard 'revoke SaaS-operator access' toggle as a false
promise — the SaaS operator controls LNbits and the default control
plane, so exclusion is by ownership (operator self-hosts), not by
toggle.
The VALID_THEMES set in electron/main.ts duplicated the renderer's
ThemeId list and silently coerced any unlisted branding.json theme to
null — which is how darkmatter regressed to the localStorage theme after
db074e2 added themes to the renderer but not this allowlist (fixed in
a0c2f38). Remove the second list entirely: pass raw.theme through and
let useTheme's applyBrandingTheme (themes[] + the 'custom' branch) be
the single validation point. Unknown values are ignored downstream, so
nothing reaches the DOM unvetted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db074e2 added countrysidecastle/darkmatter/emeraldforest/lightgreen/
neobrut/starrynight to the renderer's ThemeId union and style.css but
not to the electron main-process branding allowlist. branding.json
'theme' values outside the allowlist were silently dropped (theme=null),
so the renderer fell through to the localStorage theme (cyberpunk).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>