ADR-005 §6: the operator paid the customer by hand and recorded it in
spirekeeper. The op carries the txid and the note; the machine flips its
own dispense_error/partial row to remediated via remediateTransaction,
which only touches rows still in an error state, so re-delivery is a
no-op. Closes the machine side of the ledger for an owed-cash sale
without dispensing anything.
Every cash-out now produces one report_dispense — on success as well as
failure — and the machine does not stop sending it until spirekeeper
acknowledges it.
state.db gains a dispense_reports table (migration v13 → v14): the report
is written INSIDE recordTransaction's SQLite transaction, alongside the
transactions row, so a crash between the two cannot lose it. Rows carry
attempts / last_attempt_at / last_error / acked_at. Three IPC calls
(pending / ack / note-attempt) expose it to the renderer.
The store builds the report when a cash-out reaches complete,
dispenseFault or outOfCash: txid, payment hash, dispense_confirmed,
error / error_code / raw_code / error_class, per-denomination requested
vs dispensed vs rejected, the per-bay cassette record verbatim, and
counts_uncertain. The success report is what lets the server capture
(distribute) the settlement; the failure report is what puts a customer
on the owed-cash worklist instead of leaving the only record on the ATM.
Delivery is at-least-once: a flusher drains pending rows after each
persist, on relay (re)connect, and every 60 s, acking only on an OK reply
and backing off 30 s · 2^attempts (capped 1 h) otherwise. While
spirekeeper has not registered the RPC every send fails the same way; the
backoff keeps that quiet and the rows wait — this half ships first.
The lightning service exposes reportDispense; the function pointer is
set at all three lightning-init sites so the flusher works on every path.
HAL glue (electron/hal-service.ts and the renderer-side services/hal.ts):
dispenseConfirmed is Σ(denomination × dispensed) === Σ(denomination ×
requested), computed on value. The driver's tagged error is carried
through as errorCode / rawCode / errorClass / human; pre-dispense
inventory refusals are errorClass 'inventory' so they route to outOfCash
rather than the fault screen. The manual-dispense command result keeps
its wire key `dispensed` (spirekeeper's poller reads it) and gains the
new fields alongside.
Cash-out hold: state-store persists it in meta as one JSON value beside
countsUncertainSince, idempotent on set (the first fault's `since` is
kept); IPC get/set/clear through preload. The store persists the hold the
moment the machine sets it and restores it into the machine on boot. A
recount clears it in the store (same gesture that clears counts-
uncertain); operator-config also honours a new resume_cash_out op — not a
cassette op, split off before applyOperatorCassetteOps, and honoured only
when stamped after the hold began so a re-delivered old resume cannot
clear a fresh fault. Either release calls back into the store, which
sends CASH_OUT_RELEASED. The cassettes-state document carries
cash_out_held_since / _reason / _code (additive, like
counts_uncertain_since); the availability beacon reports cash_out false
while held; the idle Sell button is disabled with the reason.
Store watcher: dispenseFault and outOfCash both record dispense_error /
partial (the customer has paid either way). A report of zero dispensed
WITH a hardware error now sets countsUncertainSince instead of being
trusted as zero — a note stopped in the transport completes neither
counter (sintra 2026-10-09: bay read 66, held 65, one in the transport).
Fault screen: both terminal states show "your payment went through",
amount paid, per-denomination dispensed, the txid as QR and text, the
payment hash (threaded from the settlement watch through PAYMENT_RECEIVED)
and the time, with "keep this reference" and an acknowledge button. The
raw dispenser code is not shown; it travels in the report.
The fund-atm esbuild bundle imports `qrcode`, but apps/machine never
declared it — it resolved only through packages/nostr-client's
devDependency, which 763817b removed along with the dead scripts that
were the only reason it was there. The full `pnpm build` then failed at
its last step ("Could not resolve qrcode"), which is what the nix image
build runs. Declared (with @types/qrcode) in the package that imports it.
LamassuEventKind → BitSpireEventKind (nostr-client; no consumers outside
the package), the kiosk theme localStorage keys lamassu-theme /
lamassu-color-mode → bitspire-* (a one-time theme reset on existing
kiosks), the ui-shared UMD global LamassuUIShared → BitSpireUIShared, and
the orphaned Rust HAL's Cargo name/description/repository plus its lib.rs
header — now also labelled as the unbuilt leftover it is.
nostr-client: 43/43 tests, tsc clean. Machine app: vue-tsc + electron tsc clean.
MACHINE_MODEL, FIAT_CODE, VALIDATOR_DEVICE, DISPENSER_DEVICE and CASSETTES
carried the old brand in their names. Renamed everywhere they are read
(device.ts, electron/main.ts), written (flake.nix, mkAtmApp.nix, live.nix,
provision-atm.sh, factory-reset-atm.sh) and documented (.env.example,
docs/device-configuration.md). No compatibility fallback in code: the
machine reads VITE_BITSPIRE_* and nothing else.
The deployed .env files are the one place the old names persist — sintra's
/var/lib/bitspire/.env holds all three keys today — and the machine reads
MACHINE_MODEL / FIAT_CODE / CASSETTES from that file on every boot. Renaming
the keys in code alone would boot a live machine on preset defaults (wrong
bays, wrong fiat) at the next nightly pull. So configuration.nix gains an
activation script, beside the existing lamassu→bitspire user migration,
that rewrites VITE_LAMASSU_* → VITE_BITSPIRE_* in that file. Idempotent;
runs before bitspire.service starts.
setCassettes updated bays and dispenserInitData before re-initialising the
device, deliberately, so that "subsequent dispense calls see the new layout
even if the dispenser re-init is slow / fails". With the re-init failing
every time on douro, that meant the app kept a layout the hardware had
never taken, the operator-config consumer logged "Applied ops", and a
cassettes-state event went out to the operator advertising it. The douro
spent the afternoon reporting bay1:100x60 bay2:200x0 while the device was
still running the boot-time 100x50/200x50. A dispense in that state picks
bays by a layout the device does not share.
Roll the in-memory layout back when the re-init throws, and let the error
propagate as before. Reversing that earlier choice deliberately: a stale
but honest layout beats a fresh but fictional one when the difference is
which cassette pays out.
Also await dispenser.close() here and in cleanup(), now that close()
reports completion.
Closes#118
pcscd was enabled in hardware/batm3.nix and hardware/upboard.nix, which
cannot express "is a reader fitted": upboard.nix is shared by sintra (HID
Global OMNIKEY 5022) and tejo (nothing fitted), so tejo inherited pcscd it
has no use for, while the douro — with its own hardware file — got none and
wedged on every boot.
Make it a machine capability instead. services.bitspire.nfc.enable owns
pcscd, the two polkit rules and the wedge-recovery unit, and hands the app
a BITSPIRE_NFC_ENABLED flag so it doesn't initialise nfc-pcsc at all on a
machine with no reader. Per-model truth lives in nfcReaderForModel in
flake.nix next to fiatCodeForModel and upgradeWindowForModel, since a
shared hardware file can't answer the question. batm3 and sintra are true;
douro and tejo flip to true when readers are fitted.
The flag goes through the unit's Environment rather than
/var/lib/bitspire/.env, because .env is only written when absent — a
machine provisioned months ago would never pick up a new value.
nfc-pcsc's pcsclite binding does not fail when pcscd is not running — it
retries SCardEstablishContext in a tight loop on the calling thread, which
here is Electron's main thread. ~12k stat()s a second on
/run/pcscd/pcscd.comm, event loop dead: the window never paints, the
renderer is never reaped, and the watchdog can't fire because it needs the
same event loop. The douro sat like that for 11 hours at 80% CPU (its
CPUQuota ceiling), ignoring SIGTERM, with nothing in the journal after
[StateStore].
Check the socket exists before touching the binding. This is what makes
the "best-effort, every failure swallowed into a status callback" contract
in the module header true, and it also covers pcscd dying at runtime on a
machine that does have a reader.
#107 caught three sites by grepping for an object literal as the second
console argument. That pattern misses the more common form, a variable
holding an object, so five more were still landing in the journal as
[object Object]. This time the list came from the machine itself: every
distinct such line in three days of sintra's journal.
The five: the bay list at HAL init, the inventory loaded from state.db,
the inventory pushed to the renderer, the amounts sent to a dispense, and
the access-control audit record. Three sibling sites in the mock and
service paths are fixed too; they had not run recently enough to appear
in the journal but carry the same shapes.
The audit line is the one that mattered. It is the entire record of who
was granted or denied terminal access until #90 persists it to state.db,
and every field of it was being discarded.
formatBays and formatInventory render the denomination/count shapes these
sites share. `count` is optional on a device-config cassette, so an
absent one prints as unknown rather than as zero, which would read as a
drained bay.
Electron's console bridge stringifies each console argument on its way to
the journal, so `console.log('msg:', { a, b })` arrives as
`msg: [object Object]` and every field is lost.
That cost a debugging session today: a cassette-state publish that had
in fact applied an operator refill correctly looked from the journal like
nothing had happened, because the d-tag, event id and stamp were all
inside the object.
Three call sites, the only ones in the renderer passing an object. The
publish line now also carries seq and the applied-op count, which are the
two things worth knowing when an operation seems not to have landed.
Recorded in CLAUDE.md's debugging invariants so it does not come back.
The machine now owns its bay counts outright. The operator publishes what
it did — a refill in notes added, an empty, a recount, a denomination
change — and this process applies it to the total it already holds.
Both sides used to write the same value over a transport that never tells
a writer it lost. Addressable events order by created_at at second
granularity with ties broken on event id, and a relay returns OK for an
event it then discards, so a dashboard form loaded before a dispense
silently discarded that dispense and neither side could detect it. A
value with one writer cannot be clobbered.
Schema v13 adds cassette_ops, the dedup ledger. A delta applied twice is
wrong and addressable events are re-delivered on every reconnect, so the
operator mints an id per operation and this table records the ones
applied. That also retires the created_at watermark on this path: it was
the only replay defence under absolute counts, but it drops an
out-of-order event whole, operations included, where per-op ids let the
unseen ones through and no-op the rest.
A window is applied oldest-first by `at`, ties broken by id, in one
transaction with the count mutation. A recount then a refill is not the
same as the reverse, and a crash mid-apply must roll back to a coherent
count rather than a partial one.
A malformed op or one naming a bay this machine does not have is neither
applied nor recorded, so it stays pending on the operator's dashboard.
That is the honest outcome. Recording it as applied would stop the noise
by telling the operator their refill landed.
The state document gains applied_ops, seq and schema_version. applied_ops
is the acknowledgement leg — echoing the ids back is the only way the
operator can tell an operation that landed from one merely sent. seq is
bumped on every local count change from any cause, so a reader can reject
a regression without trusting either clock.
When the dispenser throws or the dispense times out there is no per-bay
report, so nothing is debited — not the cassette rows, not HAL's bays.
Bills may well have reached the customer, and both counters then read
high with nothing to indicate it. The machine went on treating a number
it had reason to doubt as measurement.
A dispense that ends with no report now latches a countsUncertainSince
flag, which rides along in the state document as counts_uncertain_since
so the operator can see the numbers need a recount. The field is
additive: a consumer reading positions ignores it, so this needs no
coordinated release. An operator config apply clears the flag inside the
same transaction, since asserting authoritative counts is precisely what
a recount is.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three linked failures in one mechanism, so one commit.
The state publish was gated on a one-shot 'have we said hello' flag. It
fired once on first boot and then only after a dispense or an applied
operator config, so any change to the layout itself — a reseed, an
atm-tui edit, direct SQL — was never announced. The operator kept
validating against a bay set the machine no longer had, and a publish
from the dashboard could overwrite a fresh seed (#94). State is now
published on every start.
A publish is one fire-and-forget event with no retry. If the relay was
unreachable at the moment of a dispense, that update was gone until the
next customer bought cash. A five-minute heartbeat makes the channel
self-healing and is also the only way an out-of-band edit to the table
ever reaches the operator.
Addressable events are ordered by created_at at second granularity with
ties broken by lowest event id, and a relay acknowledges an event it
then discards. Two publishes inside one second therefore left the winner
decided by a hash, permanently, and a clock stepping backwards would
have made every report from this machine vanish silently. Each publish
now takes a stamp strictly above the last, recorded in the meta row that
used to hold the gate — same key, no migration, honest name.
Closes#94
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two dispense paths bypassed the refresh-and-publish step that cash-out
does. The kind-21003 management command persisted the transaction and
stopped there, and the operator-command poller runs entirely in the main
process, where the renderer cannot see the bays move at all. In both
cases the renderer kept serving a stale inventory and the operator's
cassette view stayed frozen until the next customer cash-out.
The management handler takes an after-hook, and the main process emits
'cassettes:changed' when it mutates the table so the renderer can catch
up. Both land on one helper that reloads the inventory and republishes
the state document.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
getInventory dropped zero-count bays, so a fully dispensed machine
returned an empty map — identical to a machine with no cassettes
configured. Every caller reads an empty map as "nothing known, ask the
hardware": reloadPersistedInventory skipped the update entirely, so the
last non-empty snapshot stuck and the public availability beacon went on
advertising bills that had already gone out the slot.
Zero-count bays are kept, so an empty map now means exactly one thing:
no cassettes are configured. Consumers already filter for > 0 before
offering a denomination. loadInventoryFromDb returns null when the DB
could not be asked at all (browser dev, failed IPC) so callers can still
tell "no answer" from an answer of "the bays are empty", and only the
former defers to HAL.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
recordTransaction only debited the cassette rows for type 'cash_out'.
An operator remediation is recorded as 'manual_dispense', so HAL's
in-memory bays went down while the persisted rows did not — and HAL
re-seeds from those rows on the next boot, so the machine came back
believing it still held bills a customer had already been handed.
A remediation against a partly-dispensed original debits again on
purpose: the original only ever debited what physically left, and this
is a second lot of bills leaving the bay.
Closes#76
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Pressing Complete on a session that fails leaves nothing in the journal:
the decline path has no logging, so a failed sell is indistinguishable
from a button that never fired. Add the telemetry that was missing —
completeWithCard logs outcome, duration and reason, and the entry line
now says whether selling is available at all.
Two real defects alongside it. A session whose withdraw step the card
server withheld (daily limit spent, card disabled) still rendered a
Complete Sale button that could only ever fail; it now shows the
server's reason instead. And the decline path advised 'tap your card to
try again' even for refusals a re-tap cannot lift, so 'blocked' is now
its own outcome: nothing was consumed, the session stays loaded, and no
re-tap is suggested.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
When a card accepted a cash-out pull and settlement never confirmed, the
machine returned to the amount screen as though nothing had happened —
the customer's wallet had paid and there was nothing on screen or in the
journal to say so. Latch that transition, log it with the txid, and show
a red notice naming the reference an operator can reconcile against.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A one-tap Bolt Card Complete settles in about a second. Subscribing took
two sequential nostr round trips first — decode_payment to recover the
hash, then subscribe_payments — roughly eight seconds against a remote
relay, because the watch was armed when the invoice was DISPLAYED. The
settlement push is an ephemeral event with no replay, so it fired before
anything was listening: the machine sat on a paid invoice until it timed
out and the customer's sats were taken with no cash dispensed. On sintra
2026-09-22 this hit both one-tap sells (26,660 and 26,500 sats). The old
two-tap flow only ever worked because fumbling with the card covered the
window; at 07:18 the push landed two seconds after the watch went live.
Three layered defences, one mechanism:
- Arm at creation. generateInvoice does not resolve until the watch is
live, so the invoice cannot reach the screen unwatched.
- Take the payment hash from the create_invoice response instead of
decoding it back off the bolt11 — the value was already in hand and
the round trip was half the window (repo guidance says as much).
- Latch and poll. A settlement that still beats the consumer is replayed
on attach, and get_payment runs alongside the subscription so a push
that is lost or never sent cannot strand a payment either.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
index.html's inline <style> hid the cursor on any viewport >= 1024px:
@media (min-width: 1024px) { html, body { overflow: hidden; cursor: none; } }
which is every desktop browser opening the public demo. Descendants inherit
it, so the pointer vanished everywhere except over buttons — those carry
Tailwind's .cursor-pointer, which overrode the inherited value and made the
bug look stranger than it was.
This is the rule the earlier .kiosk scoping missed: src/style.css got gated,
this one did not, so the two disagreed.
Delete it rather than gate it. src/style.css's `.kiosk, .kiosk *` rule already
covers <html> and every descendant with !important, and main.ts applies that
class unless VITE_DEMO_TAG is set — so real machines are unaffected and cursor
hiding now has exactly one owner, the one that knows whether this is a kiosk.
The block here cannot make that call: it is static HTML, and the page CSP
(script-src 'self') forbids an inline script that could read the env.
overflow: hidden stays as it was — untouched on both.
Verified by building both ways: without the tag the built HTML has no cursor
rule, the JS still adds .kiosk and the CSS still carries the !important
hide; with the tag the .kiosk branch is dead-code-eliminated and no
cursor: none survives anywhere.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
Tap → unlock took ~3 s on sintra. The card server's /session now fills
fiat only from its warm rate cache (aiolabs/boltcards
fix/session-fiat-from-cache); when it returns a currency with fiat null,
the store prices the balance in that currency from the ATM's own rate
source after the unlock, so the chip still shows the wallet's currency.
Log how long the session call took and whether the server priced it, so
the next latency question can be answered from the journal.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
At Complete the store handed the session's withdraw/pay step to
window.electronAPI straight out of the loadedBoltCard ref — a Vue
reactive proxy — and Electron's structured clone refused it:
'[ATM] Bolt Card withdraw failed: Error: An object could not be cloned.'
(sintra, 2026-09-21 06:51). The customer had to re-tap, which works
because the direct-tap path passes a plain string. Copy the steps field
by field into plain objects before they cross the bridge.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The machine's balance already sits in App.vue's top-right status chip.
Repeated in the centre — right above the holder's card chip on a
tap-to-enter session — it reads as *their* balance ('Available: 0 sats'
next to a card showing 959,242 sats). Keep only the buy/sell rates there.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A tap-to-enter session is effectively the holder logging into their card
wallet, so show its balance — but a kiosk in a public place must not
display a stranger's balance unasked. CardChip renders the card label
with the balance masked (••••••) and an eye toggle; revealed, it mirrors
the LNbits wallet page: sats, then the fiat equivalent formatted with
Intl currency style. Fiat comes from the card server (the wallet's own
currency, else the instance default, at its rate) and falls back to the
ATM's fiat at its display rate when the server priced nothing. Shown on
the idle menu and both cash screens; reveal state resets on re-lock.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Entry now spends the tap's single-use SUN once, on the card server's new
/session endpoint (aiolabs/boltcards feat/card-session-endpoint), instead
of parsing the lnurlw locally and deferring every check to Complete. The
server proves a genuine, non-replayed card and returns the wallet balance
plus the hit-keyed LUD-03 withdraw and LUD-06 pay second steps — the same
single-use bearer /scan and /pay hand out — so Complete still needs no
second tap and the ATM holds no p/c for the visit.
- electron/boltcard-session.ts: /scan → /session URL derivation, response
parsing, 404 → 'card server does not support sessions'.
- lnurl-withdraw / lnurl-pay: the second steps are now callable on their
own (executeWithdrawCallback, resolveInvoiceFromPayStep); the tap paths
are unchanged and reuse them.
- IPC: lnurl:open-card-session, lnurl:withdraw-session, lnurl:pay-session.
- store: handleBoltCardEntry opens the session then authorizes the
server-returned external_id; the payment handlers take a source (raw
tap or session); a withheld withdraw step declines with the server's
reason. The boltcard AccessScan no longer carries the lnurlw.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ADR-003, .env.example, the access module's headers and the provisioning
schema still described the planned npub-QR → UID → serial-reader path.
What shipped (#86) is Bolt Card tap-to-enter over the main-process
pcscd reader with external_id as the identity, soft entry and
verify-at-payment. Nothing ever called availableAccessReaders(): the
camera npub-QR reader, the mock reader and the AccessReader seam were
dead, so they go; services/access now holds authorize, the card parser
and the credential types. The unused 'uid' scan variant goes with them;
'npub' (+PIN) and the 'challenge' seam stay.
The ADR gets an amendment section recording the differences, including
that open enrollment is not a security boundary and that the audit is
still a stub (both tracked as issues).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The card loaded at tap-to-enter carries one SUN p/c pair, and the
boltcards server bumps the counter on the first GET. After a declined
Complete (limit below amount, callback failure, payment error) the
stored lnurlw can never succeed again, yet it stayed loaded with a
Complete button that would keep failing. The same held on the success
path when the payment never settled (cash-out invoice timeout back to
selectingAmount).
The tap handlers now report skipped / accepted / declined, and
completeWithCard clears the card after any real attempt, appending
'tap your card to try again' to a decline. A skipped outcome (guard
bounced it, no server call) keeps the card. A fresh tap on the cash
screen goes through the normal tap-to-pay/receive path.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ACCESS_DEV_UNLOCK was opt-out (anything but 'false' enabled it) and
access.example.json shipped it on, so a gated production machine would
render a visible gate-bypass button on the lock screen by default. Flip
to opt-in (=== 'true'), update the example file and the provisioning
schema comment to match.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The root-level END_SESSION let the 10-minute hard cap (and the End Session
button) jump to `locked` from any state, bypassing the money-path guards
the machine already has: confirmAbandon with bills stacked, an in-flight
dispense, an outbound cash-in payment. Cap fires at minute 10 while a
customer's bills sit in the stacker → locked → next unlock resetContext
wipes them unpaid; during dispensingCash the done-event is dropped and
no transaction record is written.
Nothing is lost by scoping it: every transaction terminal state already
targets #atm.locked on this branch, so the machine re-locks on its own
when the transaction ends. END_SESSION now lives on idle.on only, and
useSessionSecurity defers both deadlines until currentState is idle —
an expired session re-locks on the first tick back at the menu.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The idle re-lock was an XState `after` on `idle`, which is anchored to
state ENTRY and never reset on screen touches — so it fired a fixed 60s
countdown regardless of interaction (reported: touching the screen
didn't extend the session). The machine can't observe raw pointer
events, so inactivity can't be measured there.
Move session timeouts to the DOM layer (useSessionSecurity, mounted in
the always-on App shell), enforcing two fail-closed limits that both
re-lock via a new root-level END_SESSION transition:
- SOFT idle (60s): re-lock after no *trusted* pointer/touch/key input
while on the idle menu; resets on every genuine interaction. Scoped to
idle so it never interrupts an in-flight cash-in/out.
- HARD cap (10min): absolute ceiling from unlock time, never reset — a
forgotten/relayed card can't hold a session open. Lives at the machine
root so it can lock mid-transaction, not just from idle.
Security posture: only event.isTrusted resets the soft timer (synthetic
events can't keep a session alive); wall-clock deadline checks re-lock
immediately after a suspend/resume rather than silently extending;
one-shot disarm-on-fire prevents spin; END_SESSION is guarded to the
active gate so it's inert when the gate is off.
Machine no longer owns the idle timer; tests updated (END_SESSION
re-locks from idle and from an in-flight cash-out; no-op when disabled).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
Per on-device review: order the top-left group ✕ then ?, and use the
`destructive` button variant so the exit swatch is a solid, theme-aware
red (--destructive is scoped per colorscheme) rather than a subtle
outline that didn't read as an exit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
The top-right "End Session" button overlapped the centered balance /
commission chips, which wrap into the top-right corner on narrower
screens (sintra). Relocate it to a minimal red ✕ icon button grouped
next to the "?" help button in the top-left, clear of the chips. Same
endSession() behavior; shown only while the gate is active.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
A Bolt Card tap loads the holder's card for the whole session, so an
unattended idle menu is transactable by the next person until the 60s
IDLE_LOCK_TIMEOUT fires. Give the holder an explicit re-lock:
- END_SESSION event on `idle`, guarded to the active gate, targets
`locked` (whose entry already clears the access session + loaded card).
No-op on a gate-disabled machine that rests at idle.
- endSession() store action; IdleView shows a destructive-styled
"End Session" button top-right only while accessControl.enabled.
- Tests: END_SESSION re-locks when the gate is active; no-op when off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
After a transaction with the gate enabled, the machine re-locks (cashOut/
cashIn → locked, not idle), so the view's isIdle watch never fires and the
router stays on /cash-out|/cash-in. When the next tap reopens the gate to
idle, the stale transaction view showed (e.g. a completed sell's collect
screen, stuck). Reset the route to / while locked (router-view hidden under
LockedView) so idle renders IdleView.
Builds on the access gate: a single Bolt Card tap at the locked screen both
unlocks the terminal AND pre-loads the card, so buy/sell just need "Complete"
— no second tap. Reuses the #83/#84 payment paths verbatim.
Soft entry, verify-at-payment: the tap is parsed LOCALLY (external_id only) so
the single-use SUN p/c stay valid; the cryptographic check happens at Complete
when the stored lnurlw actually moves sats (withdraw for sell, lnurlp-pay for
buy). Open-enrollment, card-only (no npub-QR, no PIN) per product decision.
- services/access: `boltcard` credential (externalId + lnurlw) in the AccessScan
union; canonicalId + open-enrollment/allow-list authorize; parseBoltcardLnurlw
(local, no server). Only external_id is hashed — p/c never enter authorize.
- store: loadedBoltCard (session-scoped, cleared on re-lock); handleBoltCardEntry
(tap while locked → authorize → grant + load); completeWithCard (routes to the
existing tap handlers); NFC listener routes locked→enter.
- LockedView: card-only "Tap your Bolt Card" screen (dropped camera/npub-QR/PIN).
- CashIn/CashOutView: "Complete Purchase/Sale" button + card chip when loaded.
- tests: boltcard authorize + parseBoltcardLnurlw (17 access tests total).
Enabling the gate is a provisioning step (access.json enabled+openEnrollment);
other machines default off → unchanged.
Squashed skeleton (was 11 commits on feat/access-control-skeleton) for a
clean rebase onto dev. Adds a `locked` gate the terminal boots into until a
credential is presented; opt-in and non-breaking (defaults off → boots
straight to idle as before).
- state-machine: `locked` state + ACCESS_GRANTED/ACCESS_DENIED/DEV_UNLOCK
events + accessBypass/devUnlockAllowed guards (packages/state-machine).
- services/access: reader abstraction, npub+PIN authorize() (nostr-tools
nip19; accepts nostr:/nprofile), camera npub-QR reader, mock reader.
- LockedView.vue + ColorModeToggle: branded viewfinder, PIN pad, denied
reason, dev-unlock; camera off-by-default + idle return.
- store/main/electron.d.ts: seed gate config, grant/deny/devUnlock wiring,
access.json provisioning (no rebuild), get-config surface.
- deploy: access.example.json + provision-access.sh; ADR-003.
Credential union is npub today; UID (NFC tap) is the next step.
index.html still linked Vite's scaffold favicon at /vite.svg, which does
not exist in public/ — so every browser tab (the public demo included)
showed a broken icon next to the title. Use the bitSpire logo that is
already shipped for the idle screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
The kiosk's <title> still read "Lamassu ATM" — visible as the browser tab
on the public demo, and inherited by the Electron window. The product has
been bitSpire since the rename; Lamassu belongs in the provenance credits
(README, the c0b69d1 boundary note), not on the artifact.
Rename the user-facing labels that ship: the page title, the flake
description (surfaces in `nix flake metadata`), the ISO build banner, the
header comments on the live-USB config / udev rules / app derivation that
land on the machine image, and the workspace packages' descriptions.
Deliberately NOT touched, because they are identifiers rather than labels
and renaming them has deployed-machine consequences:
- VITE_LAMASSU_MACHINE_MODEL / VITE_LAMASSU_FIAT_CODE (provisioned .env)
- LamassuEventKind (exported enum)
- localStorage keys lamassu-theme / lamassu-color-mode (would reset
every machine's stored theme)
- docker container names + devenv scripts (dev-only)
- the packages/hal Cargo crate name
Hardware names in HAL driver comments ("Lamassu Sintra", "Douro", "Tejo")
stay: those are the physical machines' real names — that IS the credit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
The browser path (no electronAPI) is already a first-class code path:
initializeWithLightning() resolves an EPHEMERAL LocalSigner, allows mock
fallback and leaves debugMode on, so the bill simulator stands in for the
validator. That is what makes a hosted kiosk demo possible at all. Two
things still needed fixing for it.
1. Cursor. `cursor: none` was applied globally for the touchscreen, which in
an ordinary browser reads as a broken page. Scope it to `.kiosk`, set on
<html> by main.ts unless VITE_DEMO_TAG is present — so every real machine
keeps today's behavior and only the demo build shows a pointer.
2. Cleanup. An ephemeral identity per page load is the right call (it isolates
concurrent visitors, and each fresh account gets its own auto-credit under
LNBITS_DEMO_MODE, whereas a single baked-in key would be credited once and
then drain). The cost is a throwaway LNbits account per visit, and nothing
in an auto-created row distinguishes one: pubkey-set/prvkey-NULL equally
describes a real ATM.
A nostr pubkey can't carry a marker — grinding a vanity prefix is far too
slow to do on page load — and the account/wallet the server auto-creates
isn't nameable by the client. So when VITE_DEMO_TAG is set the ATM mints
one extra, never-used wallet whose NAME is the tag, turning the sweep into
an exact string match instead of a heuristic about what looks disposable.
Both are inert on a real machine: the var is unset outside the demo build.
The marker call is fire-and-forget — losing it degrades cleanup, not the demo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
The machine app's `build` script runs vue-tsc, the Vite build, two electron
tsc passes and an esbuild bundle. Serving the kiosk as a plain SPA needs only
the middle one, and the electron passes drag in native-addon typings that a
web build has no use for.
Add `build:web` (just `vite build`) with a turbo task that still builds the
workspace packages first via `dependsOn: ["^build"]`, so a consumer can run
`pnpm build:web` at the repo root and get `apps/machine/dist`.
`env: ["VITE_*"]` is declared on the task because the Vite vars are baked into
the bundle at build time — without it turbo would happily serve a cached
build produced under different env.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
The Feitian R502-CL (and cheap CCID readers generally) can wedge: it keeps
detecting a card but every APDU returns "card absent or mute", and ONLY a
USB power-cycle clears it — restarting pcscd or the app does not (confirmed
on-device). Until now that left cash-out/cash-in taps dead until a manual
replug.
- nfc-service.ts: count consecutive read failures; after 3 (gated by a 30s
cooldown so a still-wedged reader can't reset-loop) trigger
nfc-reader-reset.service. nfc-pcsc then re-detects the reader on USB
hotplug with no app restart (verified live).
- batm3.nix: nfc-reader-reset.service (oneshot, root) re-binds the reader's
USB device (a software replug); reader-agnostic via the CCID interface
class (0x0B) so it also covers a future ACR1252U. A polkit rule lets the
unprivileged `bitspire` app start just that one unit.
Hardware track (separate): the durable fix is a better reader (ACR1252U —
large antenna for behind-panel, firmware-upgradable). This change makes any
reader's wedge a ~2s self-heal in the meantime.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tap-to-receive for the buy flow, the receive counterpart to #83's
cash-out tap-to-pay. A Bolt Card only emits an lnurlw withdraw voucher
(wrong direction to deposit into it), so the tap is used as an
authenticated identity (external_id + SUN p/c) to resolve the card
wallet's lnurlp/Lightning Address; the ATM then pays an invoice for the
payout over its existing nostr transport.
- electron/lnurl-pay.ts: resolveCardInvoice() — resolve card -> pay
target -> LUD-16/LUD-06 -> BOLT11. scanUrlToResolver() is the single
HTTPS-today / nostr-tomorrow transport seam. 13 tests.
- IPC lnurl:pay-card (main-process HTTPS to dodge renderer CORS) +
preload/electron.d.ts surface.
- stores/atm.ts: handleBoltCardReceive() settles via the existing
payInvoice -> PAYMENT_RECEIVED path; the one NFC listener now routes
the same tap by flow (cash-out pulls, cash-in receives).
- CashInView.vue: NFC status + dev tap input.
- docs/boltcard-receive-resolver.md: spec for the custom LNbits
/boltcards/api/v1/pay/<id> resolver endpoint (omni-private side).
Card issuance is unchanged — same NDEF/keys/external_id; receive is a
server-side reading of the same tap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hammering a flaky CCID reader with rapid re-reads wedges it into a
present↔empty storm (only a USB replug clears it). After a failed read,
ignore card re-detections for 1.5s; successful reads don't cool down.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Retrying a read hammered the cheap CCID reader into a stuck present↔empty
loop (only cleared by a reboot), so drop the retry: a read is a single
attempt and the user re-taps if the RF link drops mid-read. Also skip the
Capability-Container round-trip in the common case — NTAG424 Bolt Cards use
NDEF FileID E104, so try E104/0004 directly and only read the CC to discover
the id if both fail. Fewer APDUs → a read completes inside a shorter stable
window.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First real-card tap read the NDEF file with id 0004 and got "not a Bolt
Card" — NTAG424 (Bolt Cards) use FileID E104. Read the Capability Container
(EF E103) after selecting the NDEF app to learn the advertised NDEF FileID,
then read that file; fall back to E104/0004. Tolerates a transient transmit
error (surfaced as a retryable status; the next tap re-reads).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pcscd gates clients via polkit; the sandboxed bitspire user was "Rejected
unauthorized PC/SC client", so add a polkit rule granting it
access_pcsc/access_card. Also log NFC reader status + taps from the main
process to journald (value redacted — it carries the card's SUN p/c) so
reader detection and taps are observable during testing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Renderer side of tap-to-pay. The store subscribes to the main-process
reader (onNfcCardTapped/onNfcStatus); a tap during displayingInvoice pulls
payment for the shown invoice via lnurlWithdraw (amount in msats), guarded
against double-taps. Settlement still flows through the existing invoice
watcher → PAYMENT_RECEIVED → dispensingCash, so the state machine is
unchanged. Bolt Card state clears when leaving the invoice screen.
CashOutView: "Tap Card or Scan to Pay" + live reader/processing/declined
status on the invoice screen, plus a dev input to simulate a tap with a
pasted lnurlw. Exposes nfcStatus / boltCardProcessing / simulateBoltCardTap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Main-process driver over nfc-pcsc (PC/SC). On tap it reads the NTAG424
Type-4 NDEF file via ISO7816 APDUs (select NDEF app D2760000850101 →
select file → ReadBinary NLEN + message) and extracts the lnurlw voucher
(fresh SUN p/c per tap), forwarding it to the renderer on `nfc:card-tapped`
(+ `nfc:status`). Lazy, guarded import — a missing reader/pcscd just
reports 'unavailable', never breaking the cash-out QR path. Preload
removeAllListeners guards against a double payment-trigger on renderer reload.
- electron/nfc-service.ts: startNfcReader() + readNdefLnurlw()/extractLnurlw().
- electron/nfc-service.test.ts: 7 tests (NDEF URI extraction, Type-4 read
sequence incl. AID select, empty-file + select-fail handling).
- main.ts start + IPC forward; preload + electron.d.ts listeners.
- add nfc-pcsc dep (native @pokusew/pcsclite; nix build handling next).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>