Commit graph

88 commits

Author SHA1 Message Date
9b07880925 fix(deploy): the .env migration called sed and grep by bare name on the activation PATH
NixOS activation scripts run with a minimal PATH that has coreutils but
not gnugrep or gnused. On sintra the snippet printed its success line and
then failed with "sed: command not found" (127) — the keys were never
renamed, the new app booted on fallback config, and switch-to-
configuration exited 2. Both binaries are now referenced by store path.
2026-10-10 22:08:48 +02:00
bf2b9427aa refactor(config): rename VITE_LAMASSU_* machine-config env vars to VITE_BITSPIRE_*
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.
2026-10-09 21:57:38 +02:00
4d6ea8f163 fix(ops): atm-transactions queried fee_percent, which no longer exists
The column was renamed to fee_fraction (schema_version 13), so the main
query has been failing outright with "no such column: t.fee_percent" —
the tool only ever worked in --summary and --inventory mode. Caught
while reconciling sintra's cassettes, where listing transactions was
the obvious first step and didn't work.

Refs #40
2026-10-09 09:44:02 +02:00
ee28275bf3 feat(ops): atm-reconcile — check cassette ledgers against recorded history
The cassettes table is a running total, so it can be re-derived: an
absolute truth point (a recount, or an empty) plus the refills and
dispenses since. A derived count that disagrees with the stored one is
evidence of something the ledger never saw.

Reconciliation deliberately refuses to start from a refill. A refill is
a delta, and applying deltas on top of a wrong number just carries the
error forward — which is how sintra's 20-EUR bay ran 10 notes high for
weeks while its 50-EUR bay, zeroed by an `empty` before refilling,
reconciled exactly. A bay with no baseline is reported as
unreconcilable rather than silently assumed good.

Also surfaces the two things that make a count untrustworthy: the
counts-uncertain flag, and any transaction still sitting in
dispense_error / partial.

The SQL uses scalar subqueries rather than joins on purpose — joining
transaction_bills to cassettes fans out across bays, and a LEFT JOIN
whose rows are all excluded by the baseline cutoff collapses to NULL
and poisons the arithmetic downstream (the first draft read "expected:
blank" for exactly that reason).

Exits non-zero on any gap or missing baseline so it can be run as a
check after a test session.

Refs #122
2026-10-09 09:40:41 +02:00
6042d69356 fix(deploy): tejo had no WireGuard address, so it had no way back in
`networking.wireguard.interfaces.wg0.ips` was set in hardware/douro.nix
and hardware/batm3.nix, but hardware/upboard.nix is shared by tejo and
sintra — an address there would be claimed by both machines on the same
/24, so neither got one. tejo therefore evaluated to `wg0.ips = [ ]`:
the interface comes up with no IP and the tunnel is silently dead. On a
machine with no other route in, that is how you lose a box.

Replace the two per-hardware definitions with one `wireguardIpForModel`
table in flake.nix, keyed on model like fiatCodeForModel /
upgradeWindowForModel / nfcReaderForModel, and give tejo 10.0.0.3/24 —
the address it answers on today under its factory Debian.

douro (10.0.0.4/24) and batm3 (10.0.0.5/24) evaluate unchanged; sintra
stays deliberately unlisted, since it is reachable on the LAN and has
never had a tunnel address.

The address is only half of it: the VPS maps peer pubkey to tunnel IP,
so the machine still needs /var/lib/wireguard/wg0.key carried over from
its previous install (or a fresh key added to the VPS peer list). Both
wireguard units are ConditionPathExists-guarded on that key, so a
keyless first boot is clean and the tunnel starts once it is dropped in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 19:26:14 +02:00
c3e01c9cd3 feat(deploy): USB-bootable tejo image (disk-image-tejo-usb)
The tejo still runs its factory Debian (ubilinux4, kernel 4.9) on
internal storage and has never had bitspire on it. Rather than flash
that drive, give it the run-from-USB shape douro and batm3 already use:
the stick is the system and the internal install is never touched.

- nixosConfigurations.tejo-usb — tejo-installed + usbBootModule +
  usbBusHardening + usbGrubHybridModule. Evaluates identically to
  sintra-usb, which shares hardware/upboard.nix.
- packages.disk-image-tejo-usb — hybrid table, GRUB, BIOS + UEFI.

NOT the efi/systemd-boot shape douro uses. The tejo is the same Aaeon
UP Board as sintra, whose firmware was found to USB-boot in Legacy/BIOS
mode; systemd-boot is UEFI-only, so a dd'd systemd-boot stick would not
be recognised as bootable at all. The hybrid image boots either path, so
it is also the safe choice if the firmware turns out to differ.

README documents both bootloader shapes and which models take which.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 19:18:47 +02:00
7000720ae9 Merge pull request 'fix(nfc): make the Bolt Card reader an opt-in machine capability' (#115) from fix/nfc-opt-in into dev
Reviewed-on: #115
2026-09-29 20:51:37 +00:00
bb2ad39628 feat(deploy): services.bitspire.nfc.enable — declare the reader per machine
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.
2026-09-29 22:49:59 +02:00
23fe4a59f1 feat(deploy): USB-bootable douro image (disk-image-douro-usb)
The douro cutover to bitspire is being done remotely with a USB stick
and the machine's internal drive is not NixOS, so the stick has to be
the system rather than an installer medium. Give douro the same
run-from-USB shape batm3 already has.

flake.nix
- Lift the batm3-usb module and image post-processing into shared
  `usbBootModule` / `mkUsbDiskImage` helpers (distinct nixos-usb/ESP-USB
  labels, nofail /boot, no growPartition, autoUpgrade off, ESP relabel).
  batm3-usb evaluates to the same fileSystems/upgrade config as before.
- Add `nixosConfigurations.douro-usb` and
  `packages.disk-image-douro-usb` on top of douro-installed.

douro.nix
- Blacklist uas and set usbcore.autosuspend=-1, the same bus-drop
  hardening batm3.nix carries, so a stick is a reliable boot medium on
  the Bay Trail box.

README
- Document the -usb outputs and the flash-with-Etcher, no-installer flow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 22:42:46 +02:00
db5f433706 Merge branch 'perf/mesa-no-llvm' into perf/gpu-acceleration 2026-09-24 23:39:39 +02:00
1691511aea perf(deploy): drop the gallium drivers this fleet cannot use
nixpkgs builds Mesa with 21 gallium drivers, the full Vulkan stack and the
VDPAU and VA state trackers, so one binary can serve every GPU and
cross-build case. This fleet is four Intel boards and a kiosk that never
asks for Vulkan.

  mesa closure  974.5 -> 88.5 MiB
  sintra 3940 -> 3201 MB    tejo  3940 -> 3201 MB
  batm3  3918 -> 3179 MB    douro 3894 -> 3156 MB

Keep crocus, i915 and softpipe. softpipe earns its place: it is the
software rasterizer that does NOT use LLVM, so a board whose KMS driver
fails still brings up X slowly rather than dying headless somewhere
nobody can reach it.

IRIS IS OUT, and that is what makes the rest of this possible. Mesa's
meson puts with_gallium_iris in with_driver_using_cl and then

  with_llvm.enable_if(with_clc, error_message : 'CLC requires LLVM')

so asking for iris drags in the OpenCL frontend and with it 540MB of
llvm-lib, and -Dllvm=disabled fails at configure. Nothing here needs iris.
The fleet was surveyed rather than assumed: sintra and tejo are Braswell
[8086:22b0], batm3 is Haswell GT2 [8086:0412], douro is Bay Trail. sintra
and batm3 were read off their running X logs and both say crocus.

That survey corrected an assumption an earlier draft of this commit was
built on. It claimed the UP Boards were the iris machines and crocus was
only for douro and batm3. sintra's Braswell is Gen8 and binds crocus
anyway. Had the list been trimmed to iris on that reasoning, which looked
like the tidier option, sintra would have dropped to software rendering or
lost its display outright.

With iris gone, LLVM goes: verified with patchelf, libgallium.so has no
libLLVM in its DT_NEEDED, not merely absent from the closure listing.
Dropping llvmpipe alone never achieved that.

THE COST IS FUTURE HARDWARE. A newer x86 board — a modern NUC, the "build
it from these parts" kiosk — will need iris, and re-adding it re-adds the
540MB. Until then such a board falls back to softpipe and renders in
software: it boots, it displays, it looks fine, and it is very slow. The
driver list carries that warning. Check `DRI driver:` in /var/log/X.0.log
on any new hardware rather than trusting the list still covers it.

Five secondary failures on the way here, each now a comment where it bites.
Two are nixpkgs' meson hook forcing auto_features=enabled, which turns
Mesa's soft driver guards into hard errors, so gallium-vdpau and gallium-va
must be disabled explicitly once the AMD and NVIDIA drivers are gone. One
is that outputs lists spirv2dxil and cross_tools unconditionally while only
d3d12, asahi and panfrost populate them; nix fails a build that leaves a
declared output unproduced, so they are created empty — and mesa sets
__structuredAttrs, so $outputs is a bash array and the obvious
`for o in $outputs` loop silently does nothing. The last two are the
asahi/panfrost cross tools and install-mesa-clc, which reference
prog_mesa_clc and so must go with LLVM.

Tested on sintra at the previous revision (with iris, 3744MB): X restarted
onto the pruned Mesa, glamor reported hardware acceleration on crocus, no
errors. This revision removes iris and LLVM and has NOT been on hardware
yet. douro and batm3 want their own nixos-rebuild test regardless; batm3
runs a different kernel and douro is the only Bay Trail.
2026-09-24 23:17:28 +02:00
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
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
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
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
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
c83b40fe5e feat(deploy): enable pcscd on upboard (sintra/tejo) for NFC gate
The access gate (ADR-003, #86) only wired services.pcscd + the pcsc
polkit rule into batm3.nix, so the tap-to-enter reader was invisible on
upboard machines. Port the same device-agnostic wiring to upboard.nix
(HID Global OMNIKEY 5022, 076b:5022) so the gate works on the sintra dev
unit — and on tejo — when #86 lands on dev and the nightly upgrade pulls
it.

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
Patrick Mulligan
a7b409b109 feat(access): access-control gate — npub-QR badge + PIN + dev bypass (ADR-003)
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.
2026-09-19 10:34:45 +02:00
46e52f6598 chore: scrub "Lamassu" from shipped labels
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
2026-09-19 09:58:42 +02:00
Patrick Mulligan
ffbacafe39 fix(nfc): auto-recover a wedged CCID reader via USB power-cycle
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>
2026-08-06 19:51:39 +02:00
Patrick Mulligan
d00f3c0bbd fix(batm3): make touch calibration immune to USB-boot timing race
egalax-calibrate polls 30s for the eGalax X device then gives up; on a
slow USB boot usbtouchscreen binds the panel later than that, so the
calibration matrix is never applied and touch registers in the wrong
place ("dead" panel). Seen on cold boots (2/2 today), fine on others —
a nondeterministic race, not a regression.

- Add a udev rule that (re)starts egalax-calibrate the instant the eGalax
  input node appears (SYSTEMD_WANTS) — device-driven, can't lose the race.
- Widen the calibrate poll window 30s -> 120s as a fallback.

Recovery when it does strand: `systemctl restart egalax-calibrate`, or
apply the matrix live via xinput set-prop.
2026-08-06 18:52:33 +02:00
Patrick Mulligan
0a3156855c feat(deploy): authorize bitspire for pcscd (polkit) + NFC diagnostics
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>
2026-08-05 05:01:08 +02:00
Patrick Mulligan
74c420fbd3 feat(deploy): enable pcscd on batm3 for the Bolt Card reader
The Feitian KP382 (096e:0608) is a CCID contactless reader; PC/SC must be
running for the CCID driver to bind it. The app will talk to pcscd's socket
via nfc-pcsc. Idle/harmless when no reader is attached.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 04:34:38 +02:00
Patrick Mulligan
f52d942e57 fix(deploy): eGalax touchscreen on batm3 (kernel 6.6 + calibration)
The Dell 9030 AIO's built-in eGalax SAW panel (0eef:0001) was unusable:
touches either didn't register or landed in the wrong place. Full fix:

- Pin linuxPackages_6_6. On 25.11's default 6.12 kernel hid-multitouch
  grabs the controller and mis-parses its HID report (axes read stuck)
  and usbtouchscreen refuses to bind. On 6.6 usbtouchscreen binds and
  produces a clean single-touch ABS device (the known-good internal-SATA
  install runs 6.6.68). Mirrors douro.nix's per-hardware kernel pin.

- udev rule now modprobes usbtouchscreen ITSELF before unbinding usbhid
  and handing over via new_id. On a USB boot systemd-udev-trigger fires
  this rule (~2s) before systemd-modules-load loads usbtouchscreen
  (~12s), so new_id previously hit a not-yet-loaded driver and the panel
  bound to nothing. Loading it inline removes the boot-ordering race.

- Add an X evdev InputClass (99-egalax.conf) so X uses evdev + the
  transformation matrix rather than libinput. Mirrors the working
  internal-SATA install.

- egalax-calibrate: add XAUTHORITY (=/home/bitspire/.Xauthority) — the
  actual boot-time bug. Without the auth cookie xinput died with
  "Invalid MIT-MAGIC-COOKIE-1 key / Unable to connect to X server", so
  the coordinate-transformation matrix was never applied and touches
  landed in the wrong place. Also replace the fixed ExecStartPre sleep
  with a 30s retry loop on the eGalax X device appearing — more robust
  to boot timing than a race against display-manager.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 23:56:41 +00:00
Patrick Mulligan
6edcb8b96d feat(deploy): USB-bootable batm3 test image (disk-image-batm3-usb)
Add a USB-bootable BATM3 disk-image target plus the batm3 hardware
changes that make a dd'd USB stick boot reliably on the Dell 9030 AIO.

flake.nix — new `disk-image-batm3-usb` target:
- Distinct partition labels (nixos-usb / ESP-USB) so stage-1 by-label
  resolution can't latch onto an internal SATA drive that already holds
  a generic nixos/ESP-labelled install. Post-build mlabel relabels the
  ESP FAT volume to ESP-USB (bootloader files untouched; UEFI still
  loads /EFI/BOOT/BOOTX64.EFI).
- /boot mounted nofail + short device-timeout: the firmware already
  loaded the bootloader before Linux; without nofail a slow/late ESP-USB
  enumeration drops to emergency mode with root locked — a dead end.
- NO growPartition/autoResize on the USB image: sfdisk rewriting the
  partition table on first boot is the single most bus-stressing write,
  and flaky USB bridges drop off the bus mid-rewrite (sfdisk wedges in
  uninterruptible D-state and ESP-USB vanishes with the device, so /boot
  times out too). Persistent state is a few MB and the image already
  ships ~2GB free in root. The internal-SATA disk-image-batm3 keeps
  growPartition — a real AHCI SSD won't drop the bus.
- autoUpgrade off (test image, not a managed fleet member).

batm3.nix — USB-boot reliability:
- Add usb_storage to initrd.availableKernelModules so stage-1 binds the
  stick and /dev/disk/by-label/* appears.
- Blacklist uas + usbcore.autosuspend=-1: force the slower-but-reliable
  Bulk-Only Transport path and stop the boot medium being power-suspended
  mid-I/O — both were causing "device offline error" bus drops.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 23:56:41 +00:00
936fc9fb46 feat(deploy): add factory-reset-atm.sh for a truly-fresh machine (#70)
Deterministically reproduce a brand-new machine so tests aren't masked by
leftover env/db values: stops bitspire, deletes state.db (+ WAL/SHM), truncates
.env to the minimal image-baked template (preserving model + fiat), restarts.
The ATM then boots unpaired into the wizard exactly like a fresh disk image.
Confirmation-gated (FORCE=1 to skip; ATM_USER= to override the SSH user).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
42c0d3e9ca chore(deploy): seed a minimal .env — stop pre-seeding maskable vars (#70)
The bitspire-env activation seeds .env only when ABSENT (never refreshes on
redeploy), and env WINS over the pairing seed — so any value written at first
boot is frozen for the disk's life and silently masks the seed's source. That's
how a dead relay.aiolabs.dev and a provisioned VITE_OPERATOR_PUBKEYS made stale
installs "work" while a fresh machine broke.

Seed ONLY image-baked, non-maskable values (model, fiat, ELECTRON_FORCE_PROD,
DISPLAY, empty VITE_SPIRE_SEED placeholder). Relay + server pubkey come from the
seed; operator pubkey + fee config come from LNbits over the transport — so those
keys are no longer pre-seeded at all. VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY
are emitted only when the operator deliberately pins them via the Nix options (an
explicit override). Also drops the inert RELAY_URL/LNBITS_SERVER_PUBKEY lines from
/etc/bitspire/config.env (never loaded — EnvironmentFile is forced to .env).

Verified: built sintra-installed .env template is 5 lines, 0 maskable vars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
e99628ef84 docs: relay + LNbits pubkey are seed-provided, not required (#70)
Env table (CLAUDE.md), .env.example, and the deploy README still framed
VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY as required/provisioned; they now come
from the pairing seed and are env overrides only. Also refresh the slimmed seed
shape, the relayUrl/pubkey module examples ("" not wss://relay.aiolabs.dev), and
the stale lamassu-next autoUpgrade flake URL (→ aiolabs/bitspire).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00
20dbc8ca80 fix(deploy): provision-atm.sh writes relay/pubkey only on explicit override (#70)
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>
2026-07-02 21:53:51 +00:00
7896c122da fix(deploy): relay + LNbits pubkey are seed-provided, not env-pinned (#70)
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>
2026-07-02 21:53:51 +00:00
7e90719508 refactor(deploy): share UP Board serial hardware between installed + live ISO
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>
2026-07-02 21:31:36 +02:00
5a420119df perf(deploy): slim the kiosk closure (disable TTS, Qt, docs)
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>
2026-07-02 21:31:36 +02:00
b2099c7d48 fix(deploy): make the live ISO boot cleanly
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>
2026-06-29 23:47:31 +02:00
482e0549b9 fix(deploy): make live ISOs USB-bootable (isoImage.makeUsbBootable)
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>
2026-06-29 22:12:28 +02:00
8a02d72bd1 feat(deploy): provision VITE_SPIRE_SEED for bunker pairing (Phase E)
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>
2026-06-21 12:49:30 +02:00
055afba894 chore(nix): thread cfg.relayUrl into fresh-boot .env stub (#57 follow-up)
The fresh-boot `/var/lib/bitspire/.env` template at flake.nix's
`bitspire-env` activation script seeded `VITE_RELAY_URL=` empty, which
forced every operator to run `provision-atm.sh` (or hand-edit .env)
before the renderer could resolve a relay. Meanwhile the NixOS option
`services.bitspire.relayUrl` was wired only to the dead-code
`/etc/bitspire/config.env` (mkForce-shadowed by `/var/lib/bitspire/.env`).

Thread the NixOS option through: seed `VITE_RELAY_URL=${cfg.relayUrl}`
on first boot. The .env override path remains intact — provision-atm.sh
or a hand edit still take precedence at runtime (the file is the
EnvironmentFile, not the activation-time template). Existing ATMs
already have a populated `.env` and aren't affected (the activation
script's `if [ ! -f ... ]` guard skips the rewrite).

Renderer resolution order:
  /var/lib/bitspire/.env → NixOS module default → renderer fallback
  (`ws://localhost:7777` in lightning.ts:63 / main.ts:284)

Also expand the `services.bitspire.relayUrl` option description so
future readers see the wire-through + the override path documented
where the option lives.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 20:42:05 +02:00
4f68ddc40b refactor(machine): drop VITE_LNBITS_HTTP_URL — lnurl now arrives populated from LNbits (#57 gap 2)
Closes gap 2 from coord log 2026-06-01T18:30Z. The LNbits withdraw
extension's nostr-transport RPC now populates `link.lnurl` from
`settings.lnbits_baseurl` (aiolabs/withdraw#1 / commit e9d911e), so the
ATM no longer needs a separate HTTP URL on the wire to compose the
LNURL-withdraw callback itself.

What goes:

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

What stays:

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

Why this is a net win:

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 20:33:28 +02:00
0af2a9bb29 docs(deploy): capture re-flash workflow gotchas from 25.11 reflash
Tonight's Sintra re-flash surfaced two procedure gaps the doc didn't
cover. Burn them in so future-us doesn't rediscover them:

1. Step 0 (re-flash only): preserve .env + state.db before powering
   off. The .env carries VITE_ATM_PRIVATE_KEY — without it LNbits
   treats the reflashed unit as new and spawns a fresh wallet,
   stranding the old wallet's balance. Also: push origin/dev +
   push-cache.sh BEFORE flashing, or the 04:00 auto-upgrade either
   fails to substitute or silently downgrades.

2. Step 5: e2fsck "No such file or directory" after parted resize.
   Kernel re-read the partition table (lsblk shows mmcblk0p2) but
   Alpine's udev didn't create the /dev/ node. partprobe and
   blockdev --rereadpt don't fix it — they refresh the kernel's view,
   not /dev/. Fix is udevadm trigger + settle, or mknod 179:N by
   hand. Caught tonight, cost ~4 round-trips of confusion.

Step 7 split into first-time vs re-flash provisioning paths — the
re-flash path sources from the step-0 backup so the ATM keeps its
wallet identity. Also added optional state.db restore command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:12:11 +02:00
9bbe2a8aef docs(deploy): bump dd count 2200 → 2800 for 25.11 image size
The 24.05-era image was ~6 GB actual; the README's count=2200 (8.8 GB)
gave a small margin. The 25.11 image is 7.3 GB actual (linux-firmware-
zstd grew, plus the version churn) — count=2200 would now truncate.

Bump to count=2800 (~11.2 GB) and note that future bumps may need
another increase — image size is the right thing to check via
\`ls -lh result/\`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:12:11 +02:00
01fcff45c1 chore(nix): tighten ATM GC — daily at 03:30, persistent
Was: weekly, no persistence. Problem: 15GB eMMC + ~7GB closure means
cross-release upgrades are always tight. Weekly cadence stacked up to
a week of generations under the 04:00 auto-upgrade. Persistent=false
also meant a Sintra powered off at 03:30 skipped GC until next week.

Now runs daily at 03:30, 30 min before the 04:00 nixos-upgrade so each
upgrade attempt gets the freshest headroom. 7d retention unchanged.

This is a periodic-cleanup fix only — cross-release in-place upgrades
on 15GB eMMC will still hit the disk-full wall (caught 2026-05-26 on
24.05 → 24.11). Reflash remains the answer there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:12:11 +02:00
3128975224 chore(nix): bump nixpkgs 25.05 → 25.11
isoImage.isoName was renamed to image.fileName in 25.11 (unified
image module). Split the rename out — the rest of isoImage.* stays
put (makeEfiBootable, makeBiosBootable, squashfsCompression).

All 8 nixosConfigurations evaluate clean on 25.11 with zero
deprecation warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:12:11 +02:00
7c1011d382 chore(nix): bump nixpkgs 24.05 → 24.11
Three breakages handled:

- hardware.opengl → hardware.graphics (renamed in 24.11). Touches
  upboard.nix, douro.nix, batm3.nix, live.nix.
- vaapiIntel dropped — legacy pre-Broadwell driver, removed in
  nixpkgs. UP Board (Cherry Trail) and OptiPlex 9030 (Haswell) both
  use intel-media-driver, which stays.
- vaapiVdpau renamed → libva-vdpau-driver.

system.stateVersion stays 24.05 — convention is to never bump after
install. Existing Sintra and fresh flashes keep the 24.05 state
semantics; that's correct.

All 8 nixosConfigurations (4 models × {live,installed}) evaluate
clean with zero deprecation warnings on 24.11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:12:11 +02:00
b6169da45d feat(machine): operator branding — optional logo-dark.png variant
Sibling-file convention: drop a logo-dark.png alongside logo.png in
/var/lib/bitspire/branding/ and the renderer uses it whenever the
effective color mode is dark, falling back to logo.png when absent.
No branding.json change — the file name itself is the contract.

Wiring:
- electron/main.ts:loadBranding() reads logo-dark.png and base64-encodes
  it into logoDarkDataUrl on the IPC payload
- composables/useTheme.ts exposes an `isDark` computed that resolves
  the 'system' colorMode via the prefers-color-scheme media query (and
  reacts to OS-level dark-mode changes via the existing listener)
- composables/useBranding.ts switches logoUrl reactively based on isDark
- IdleView already binds to logoUrl — no template change needed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:12:11 +02:00
f589b1c078 fix(deploy/push-cache): push build-time deps too, not just runtime closure
cachix push by default only walks the RUNTIME closure of the paths it
gets on stdin. Build-time inputs like fetchPnpmDeps tarballs are
consumed during a build and then thrown away — never referenced from
the final output — so they never make it into the cache.

This bites when an ATM has the cached runtime output for an older
version of bitspire-atm-app but then needs to rebuild it (e.g. because
a flake.nix change shifts the toplevel hash, cascading through to a
new app derivation). Sintra OOM-killed itself today (2026-05-25) doing
exactly this: 1.4 GB of pnpm install + node-headers on 1 GB of RAM.

Fix: query the .drv that produced each output path, then walk
`nix-store -qR --include-outputs <drv>` — that gives the full
build-time closure (every path needed to realize the build, including
fixed-output fetchers like fetchPnpmDeps). cachix push then uploads
all of them.

Cost: somewhat larger pushes, but the dev box has the headroom.
Benefit: ATM nixos-upgrade never falls into the rebuild-from-source
trap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:12:11 +02:00
b9a9d5aaa7 fix(deploy/push-cache): sintra target was pushing the live ISO, not the installed toplevel
`./deploy/push-cache.sh sintra` was building+pushing `iso-sintra` while
the Sintra's auto-upgrade pulls `nixosConfigurations.sintra-installed`.
Result: the cache had the ISO closure but not the installed closure, so
every Sintra nixos-upgrade ran the substitution loop, came up empty for
small text-stitch derivations (nix.conf, etc, system-units), and bailed
with `local builds are disabled (max-jobs = 0)`. Caught when
re-provisioning the dev unit to the demo LNbits.

Symmetric fix:
- `sintra` now pushes `nixosConfigurations.sintra-installed.…toplevel`
  (matches the douro/batm3 convention)
- New `sintra-live` target pushes the ISO (matches douro-live)
- `all` now includes `sintra-installed` (was silently missing)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:11:32 +02:00
c3353c409b feat(machine): operator branding — local-file source (issue #47 V1)
Read /var/lib/bitspire/branding/{logo.png,branding.json} on startup and
apply across the renderer. branding.json may set title, theme (one of
the 6 built-ins or "custom"), and a custom_colors map (with optional
.dark overlay) — unset CSS vars fall back to gruvbox.

Wiring:
- electron/main.ts:loadBranding() reads + validates the JSON and
  base64-encodes logo.png; surfaced via the existing get-config IPC
- composables/useBranding.ts holds reactive logoUrl/title refs and a
  single setBranding() setter — the seam where #48's Nostr-event
  source will eventually overlay the local-file source
- composables/useTheme.ts:applyBrandingTheme() handles built-in theme
  swap and injects a <style#branding-custom-theme> block for custom
- IdleView binds :src/title; App.vue calls setBranding() before the
  maintenance screen renders so "Under Service" wears operator branding

Provisioning: new deploy/nixos/provision-branding.sh rsyncs a local dir
to /var/lib/bitspire/branding/ via sudo-on-the-far-side and restarts
bitspire.service. The existing provision-atm.sh stays focused on .env.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:11:32 +02:00