Commit graph

529 commits

Author SHA1 Message Date
645fd57e5b perf(deploy): prune linux-firmware to the hardware bitSpire runs on
hardware.enableRedistributableFirmware installed the entire linux-firmware
tree: 752MB compressed, 16% of the image and its single largest component.
The fleet is four fixed Intel boards. The rest is firmware for Qualcomm,
Mellanox, NVIDIA, Marvell, AMD and MediaTek parts that will never be in
one of these machines.

Keep i915 for the GPU, intel/iwlwifi, rtl_nic, rtw88, rtw89 and brcm for
whatever NIC a given box turns out to have. Turning the option off also
drops the extras it bundles (sof-firmware, libreelec-dvb, alsa-firmware,
intel2200BG, zd1211fw), none of which applies to a soundless kiosk on a
wired Intel board. The regulatory database is normally implied by that
same option so it is now requested explicitly; without it WiFi is pinned
to the most restrictive channel set.

Intel WiFi is 89MB and most of what survives. That is the deliberately
conservative half of the trade: losing the network on a fielded ATM is
not recoverable remotely, and 89MB is cheap next to a site visit.

  sintra 4604 -> 3940 MB    tejo  4604 -> 3940 MB
  batm3  4582 -> 3918 MB    douro 4544 -> 3894 MB

VERIFIED ON HARDWARE. sintra was switched to this and rebooted. It came
back with ethernet up (r8169, RTL8168g), the kiosk running, and no
firmware load failures. Before the reboot, for every module these boards
use, the firmware the kernel declares was confirmed present: i915 44 of
44, r8169 23 of 23, r8152 7 of 7. iwlwifi declares 67 and 28 are absent,
but all 28 are absent from the full upstream tree too, so the module
simply names more files than linux-firmware ships.

The reboot is what earned the intel/fw_sst_* entries. The first boot
after pruning logged

  intel_sst_acpi: Direct firmware load for intel/fw_sst_22a8.bin failed
  with error -2

the Intel Smart Sound DSP that Cherry Trail boards probe at startup. The
audio stack is already gone so nothing was functionally broken, but a
recurring error in a payment terminal's boot log is worth 420KB to
remove: an error people learn to ignore is one they will ignore when it
matters. No static check would have found this — the firmware a driver
requests at probe time is not what modinfo reports.

Two traps found while building it, both carrying comments where they bite:

The symlink loop originally ended in `[ -e ... ] && ln ...`, which makes
the loop's exit status depend on whether the LAST candidate matched. A
non-match returns 1 and set -e fails the build, so whether it worked was
a function of readdir order. It passed standalone and failed once spliced
in.

Kept directories contain symlinks pointing outside themselves: brcm's
blobs are links into cypress/. Left dangling they fail nixpkgs'
compression step, and deleting them would silently drop firmware a device
needs, so the targets get pulled in instead and anything still dangling
is a hard error.

The tree is left uncompressed because NixOS compresses each
hardware.firmware entry itself, zstd or xz depending on the kernel.
Confirmed: sintra gets -zstd, douro's 5.15 gets -xz.

system.forbiddenDependenciesRegexes rejects the upstream package by its
versioned name, so a nixpkgs bump or a stray module re-enabling the
option fails the build instead of quietly putting 750MB back.
2026-09-24 22:52:59 +02:00
425f00a71d perf(deploy): make Electron's GPU flags tunable without a rebuild
The kiosk has launched with --disable-gpu AND
--disable-software-rasterizer since the first ISO commit (19d43c2).
Together those turn off GPU compositing and the SwiftShader fallback,
which leaves Chromium rasterizing every pixel on the CPU. On a Bay Trail
Atom that is expensive, and it is very likely the largest single
contributor to a sluggish UI.

Nothing in git ever justified the pair. There is no comment, no issue and
no commit message about it; the flags arrived with the original hardware
bring-up and were carried through every refactor since. The descriptive
config at /etc/bitspire/config.env has even claimed
ELECTRON_DISABLE_GPU=false this whole time, contradicting the actual
command line. So this looks like bring-up scaffolding rather than a
diagnosed workaround, and it is worth re-testing now that the Mesa work
gives known-good crocus and iris drivers for all three GPU generations in
the fleet.

Testing it by rebuilding is the wrong loop. These are remote machines
with no one at the screen, a wrong flag is a black display, and each
attempt is a large closure copy over WireGuard. So the GPU flags move out
of ExecStart into a shell variable read from /var/lib/bitspire/.env: set
BITSPIRE_ELECTRON_GPU_FLAGS, restart the unit, look at the panel. A bad
value is one edit and a restart away from being undone.

Behaviour is unchanged by default. The variable uses ${VAR-default}, not
${VAR:-default}, so an absent line means today's flags while an
explicitly empty value means no GPU flags at all, i.e. full acceleration.
That distinction is the whole point and is why the .env template ships
the line commented out rather than set: a present-but-empty value would
silently enable the GPU on every machine that regenerates its .env.

The live ISO takes the same launcher via specialArgs, so the ISO and the
installed image cannot drift apart on this.

Closure is unchanged at 4604MB.
2026-09-24 19:13:06 +02:00
e516ab449a perf(deploy): trim systemPackages to kiosk essentials
Every entry here ships to each ATM and eats the eMMC headroom the
nightly nixos-rebuild needs, which is tight enough already that GC runs
at 03:30 purely to clear room for the 04:00 upgrade.

Out: git, at 70MB, since nixos-rebuild fetches the flake with its own
git-minimal that unit-nixos-upgrade.service keeps in the closure, so
auto-upgrade is unaffected. nodejs_22, at 94MB, which nothing runs: the
app is Electron and embeds its own node, and fund-atm references
pkgs-unstable.nodejs by store path. wget, which curl covers. And vim,
replaced by nano.

Keeping an editor at all is deliberate. Field edits to
/var/lib/bitspire/.env happen over ssh, and nano costs a few MB where
vim costs 43. minicom and screen stay for the same reason: the validator
and dispenser sit on ttyJ5 and ttyJ7, those two are how a serial fault
gets diagnosed, and they cost about 2MB between them.

With the two preceding commits the sintra-installed closure goes from
5144MB to 4604MB across 131 fewer store paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 15:27:17 +02:00
cda3f3f17e perf(deploy): force the unused audio stack off
The block enabling pipewire was commented "for transaction sounds", but
no such sounds exist: nothing under apps/machine or packages/ constructs
an Audio element or ships an audio file. It has been dead weight for as
long as it has been there.

Removing our own `enable = true` is not enough. services.xserver pulls
in NixOS's graphical-desktop module, which mkDefault-enables pipewire
exactly as it does speechd, so the stack survived the first attempt at
this. That is why the line sits beside the speechd mkForce rather than
where the old block was.

Most of PipeWire's dependency chain is shared with the GStreamer that
Electron drags in, and that stays in the closure either way, so this
frees 21MB rather than the whole stack. The remainder comes out with
gtk4/gst, which wants a launch test on the sintra first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 15:27:06 +02:00
73a77c82b3 perf(deploy): stop the app closure retaining its build toolchain
node-gyp leaves its scaffolding beside the addons it compiles, and
several of those files carry absolute store paths to the tools that did
the compiling: build/node_gyp_bins/python3 is an ELF copy of python3
with an RPATH into it, build/config.gypi names python3, nodejs and npm,
the .o.d files under build/Release/.deps name pcsclite's dev output, and
pnpm rewrote a few CLI helpers' shebangs to the full nodejs.

Nix scans $out for store hashes, so each of those became a runtime
reference. Every ATM was carrying python311, nodejs, npm and
pcsclite.dev -- 212MB of closure -- for files nothing reads after the
build. Only build/Release/*.node is ever loaded, through bindings and
node-gyp-build.

Drop the scaffolding, and point the stray shebangs at PATH rather than
deleting files a package might still require. All three addons survive
with their RPATHs intact: better_sqlite3.node, pcsclite.node and the
serialport prebuilds. The derivation's references are now down to bash,
pcsclite.lib and the two gcc runtime libs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 15:26:58 +02:00
48c71f7902 Merge pull request 'Finish the console object-argument sweep' (#108) from fix/log-object-args-sweep into dev
Reviewed-on: #108
2026-09-24 13:09:55 +00:00
d66f50dbdf fix(logging): finish the object-argument sweep
#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.
2026-09-24 12:26:05 +02:00
74e488cd00 Merge pull request 'Interpolate log fields instead of passing an object' (#107) from fix/renderer-log-object-args into dev
Reviewed-on: #107
2026-09-24 07:31:54 +00:00
a68e462462 fix(logging): interpolate log fields instead of passing an object
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.
2026-09-23 23:55:13 +02:00
8d2509b092 Merge pull request 'Consume operator cassette operations instead of counts' (#106) from feat/cassette-ops-consumer into dev
Reviewed-on: #106
2026-09-23 21:31:07 +00:00
65f5f982ec docs(adr): record that the operations wire shipped
Decisions 1 through 4 are built on both sides, so the status section says
what landed rather than what is planned, and decision 4a is marked
superseded.

4a is kept rather than deleted. It is the calibration for what a warning
dialog is worth: it depended on an operator reading it at the end of a
refill round, and it could not help at all when the stale value was
already in the form. The dialog and the endpoint behind it are both gone
now, which is a stronger guarantee than any wording could be.

Also records the one gap left open on purpose. An operation recorded
while the relay is unreachable waits for the operator's next action,
because only an operator action triggers a publish.
2026-09-23 12:56:19 +02:00
c889f7f0df feat(cassettes): consume operator operations instead of counts
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.
2026-09-23 12:55:52 +02:00
9077f9c299 Merge pull request 'docs(adr): record the cassette-state synchronization model' (#105) from docs/adr-cassette-sync into dev
Reviewed-on: #105
2026-09-22 22:26:11 +00:00
7e3112987d docs(adr): record the publish warning, and the live verification
Two things learned after the ADR was first written.

The dashboard's publish dialog already warns that the publish overwrites
the ATM's tracked counts and that decrements since the last baseline will
be lost, and says v2 reconciliation will replace it. That changes how the
gap should be read: a known risk with a human-factors mitigation, not an
oversight, and the product had already reached the same conclusion these
decisions formalise. Worth stating that a warning is the weakest control
available — it depends on an operator reading a dialog, and cannot help
when the stale value is the one already in the form.

Also records that decisions 5 to 8 were verified live on sintra rather
than only by unit test, and which of the listed failures those decisions
do not close.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 00:06:29 +02:00
e4742963a3 docs(adr): record the cassette-state synchronization model
This protocol spans two repos and decides how much cash a machine will
pay out, and its only specification was a closed issue and a chat log.
That is how four separate divergence bugs went unnoticed.

Records what the transport actually permits — an addressable event is an
unconditional overwrite ordered by a second-granularity clock, and a
relay acknowledges an event it then discards, so a losing writer is
never told — and why that rules out compare-and-swap and leads to the
ATM owning the count while the operator publishes operations.

Decisions 5 through 8 shipped in #104 and spirekeeper#44; 1 through 4
are the v2 operations wire and are not yet built.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 23:01:25 +02:00
8c0336c4f6 Merge pull request 'fix(cassettes): close the machine-side divergence paths' (#104) from fix/cassette-sync-machine into dev
Reviewed-on: #104
2026-09-22 20:30:23 +00:00
474903c38b fix(cassettes): say when the counts are unverified instead of reporting a guess
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>
2026-09-22 22:14:06 +02:00
54c59fadcc fix(cassettes): publish state on every change, and make the stamps monotonic
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>
2026-09-22 22:12:07 +02:00
db68e6e244 fix(cassettes): republish after a dispense the renderer didn't run
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>
2026-09-22 22:09:23 +02:00
0d43c4e033 fix(cassettes): report a drained machine as drained
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>
2026-09-22 22:07:35 +02:00
5b22447dae fix(cassettes): decrement bays on an operator remediation dispense
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>
2026-09-22 22:05:42 +02:00
ac27bc36e0 Merge pull request 'fix(deploy): guard the WireGuard peer units too, not just the interface' (#103) from fix/wg-peer-units-guard into dev
Reviewed-on: #103
2026-09-22 18:43:53 +00:00
e3b51eaa37 fix(deploy): guard the WireGuard peer units too, not just the interface
#101 skipped wireguard-wg0 when no key is provisioned, but the module
emits one unit per peer alongside it, and a condition-skipped unit is
not a failed dependency — so the peer unit still ran and died on
'Unable to modify interface: No such device'. Same exit 4 from
switch-to-configuration, different unit, so the nightly auto-upgrade is
still marked failed on an unprovisioned machine (seen on sintra today).

Guard the peers on the same key. Unit names come from the module's own
peers.*.name option rather than re-deriving its escaping here, with the
-refresh suffix following nixpkgs' peerUnitServiceName (a peer's null
interval falls back to the interface's). Verified by evaluation that
every wireguard-* unit in the installed config now carries the
condition, that each is a real unit with an ExecStart, and that the live
image — which mkForce's the interfaces away — still gets none.

Refs #98

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:38:35 +02:00
387bed0679 Merge pull request 'fix(deploy): nightly auto-upgrade failed on both ATMs, for different reasons' (#101) from fix/autoupgrade-known-hosts-and-wg into dev
Reviewed-on: #101
2026-09-22 18:28:37 +00:00
71b2691f8c fix(deploy): don't fail activation over an unprovisioned WireGuard tunnel
wg0.key is written per machine after flashing. Until it is, the unit's
`wg set … private-key` exits 1 with 'fopen: No such file or directory',
and one failed unit makes switch-to-configuration exit 4 — which marks
the whole nightly system.autoUpgrade run as failed even though the new
generation applied. sintra has reported a broken updater on that basis
alone; its tunnel was never provisioned and wg0 has never existed.

Skip the unit when there is no key rather than failing activation over
an interface that was never set up. A provisioned machine is unaffected.
Guarded on wg0 still being declared so the live image, which mkForce's
the interfaces away, doesn't inherit a unit with no ExecStart.

Refs #98

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:14:46 +02:00
012fecef5e fix(deploy): trust the Forgejo host key so auto-upgrade can fetch
system.autoUpgrade fetches the flake over ssh as root. A machine whose
root has never connected by hand has no known_hosts entry, so the run
dies at 'Host key verification failed' before it even reaches
authentication. batm3 did exactly that, silently, from its 2026-08-06
install until 09-22: six weeks on its install generation while a unit
nobody was watching reported failure every night. sintra only ever
worked because a human had ssh'd as root once and accepted the key.

Declaring the key means a freshly flashed ATM updates from first boot
with no manual step. Verified against the key sintra's root already
trusts.

Refs #98

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:14:46 +02:00
aab2d074c3 Merge pull request 'fix(access): instrument Complete, and say when a card can't sell' (#100) from fix/card-complete-blocked-feedback into dev
Reviewed-on: #100
2026-09-22 16:30:57 +00:00
61d5bf0231 fix(access): instrument Complete, and say when a card can't sell
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>
2026-09-22 18:29:58 +02:00
67763d6e8d Merge pull request 'fix(cash-out): settlement watch missed one-tap payments' (#99) from fix/cashout-settlement-race into dev
Reviewed-on: #99
2026-09-22 16:29:35 +00:00
67573008ee fix(machine): surface a payment taken with no cash dispensed
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>
2026-09-22 18:16:06 +02:00
ebb07ce22d fix(lightning): arm the cash-out settlement watch before the invoice is shown
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>
2026-09-22 18:16:06 +02:00
da4969d510 Merge pull request 'fix(access): session Complete failed on IPC clone; keep rate lookup off the unlock path' (#97) from fix/boltcard-session-ipc-clone into dev
Reviewed-on: #97
2026-09-22 14:02:47 +00:00
54244a4708 Merge pull request 'fix(machine): keep the mouse pointer visible on the web demo' (#96) from fix/demo-cursor-visible into dev
Reviewed-on: #96
2026-09-22 12:59:55 +00:00
aa22ba1c27 fix(machine): keep the mouse pointer visible on the web demo
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
2026-09-22 14:54:57 +02:00
8e11c41f62 perf(access): keep the rate lookup off the unlock path, log entry timing
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>
2026-09-22 14:24:13 +02:00
42e3657fe1 fix(access): pass plain objects over IPC for session Complete
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>
2026-09-22 14:24:13 +02:00
d304e59ef0 Merge pull request 'fix(idle): drop the redundant "Available: N sats" line' (#95) from fix/idle-drop-available-balance into dev
Reviewed-on: #95
2026-09-20 22:33:12 +00:00
a497f0ca08 fix(idle): drop the redundant 'Available: N sats' line from the centre
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>
2026-09-20 23:34:45 +02:00
3efbdf164b Merge pull request 'feat(access): verified Bolt Card session at entry + hidden-by-default balance' (#93) from feat/boltcard-session-balance into dev
Reviewed-on: #93
2026-09-20 15:16:41 +00:00
ec2b15c08f docs: Bolt Card session contract, ADR-003 amendment for verified entry
docs/boltcard-session.md is the /session wire contract (sibling of
boltcard-receive-resolver.md), including the trust boundary: the session
URL is derived from the card's own host, so open enrollment is still not
a security boundary (#91). ADR-003's amendment now records verified entry
via /session and the hidden-by-default balance display.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 17:07:17 +02:00
6779d8ef55 feat(machine): show the card holder's balance, hidden by default
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>
2026-09-20 17:07:17 +02:00
735132b032 feat(machine): open a verified Bolt Card session at tap-to-enter
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>
2026-09-20 17:07:16 +02:00
ce4b5a5dc6 Merge pull request 'fix(access): single-shot loaded card + ADR-003 amendment' (#92) from fix/access-gate-followups into dev
Reviewed-on: #92
2026-09-20 14:41:44 +00:00
f11aced450 chore(access): prune unwired readers, amend ADR-003 for what shipped
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>
2026-09-20 15:16:59 +02:00
a652089441 fix(access): drop the loaded Bolt Card after its first Complete attempt
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>
2026-09-20 15:16:47 +02:00
f84cad76a9 Merge pull request 'feat(access): Bolt Card tap-to-enter access gate (ADR-003)' (#86) from feat/access-control-skeleton into dev
Reviewed-on: #86
2026-09-20 13:16:22 +00:00
04767080a1 fix(access): dev unlock defaults OFF
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>
2026-09-20 14:11:38 +02:00
5114619fce fix(access): only accept END_SESSION from idle so the session cap can't strand funds
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>
2026-09-20 14:11:38 +02:00
82fbf12950 fix(access): reset idle timer on activity + add hard session cap
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
2026-09-19 10:34:45 +02:00
4ce68c1301 fix(access): put End Session ✕ left of Help, solid destructive red
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
2026-09-19 10:34:45 +02:00