Compare commits

..

18 commits

Author SHA1 Message Date
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
fbcaa3121f fix(access): move End Session to a red ✕ beside Help (top-left)
The top-right "End Session" button overlapped the centered balance /
commission chips, which wrap into the top-right corner on narrower
screens (sintra). Relocate it to a minimal red ✕ icon button grouped
next to the "?" help button in the top-left, clear of the chips. Same
endSession() behavior; shown only while the gate is active.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
2026-09-19 10:34:45 +02:00
d35faf1c93 feat(access): add End Session button to re-lock a tap-in session
A Bolt Card tap loads the holder's card for the whole session, so an
unattended idle menu is transactable by the next person until the 60s
IDLE_LOCK_TIMEOUT fires. Give the holder an explicit re-lock:

- END_SESSION event on `idle`, guarded to the active gate, targets
  `locked` (whose entry already clears the access session + loaded card).
  No-op on a gate-disabled machine that rests at idle.
- endSession() store action; IdleView shows a destructive-styled
  "End Session" button top-right only while accessControl.enabled.
- Tests: END_SESSION re-locks when the gate is active; no-op when off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
2026-09-19 10:34:45 +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
6676c26761 feat(access): auto re-lock the idle menu after inactivity
An unlocked session left unattended (card tapped in, no transaction) stayed
at idle indefinitely, so anyone could then transact on the loaded card. Add
an IDLE_LOCK_TIMEOUT (60s) after-transition on idle → locked, guarded by
accessGateActive so a gate-disabled machine (which rests at idle) never
re-locks. Selecting cash-in/out leaves idle and cancels the timer; the store
clears the loaded Bolt Card on re-lock. Transaction flows already re-lock on
their own inactivity timeouts.
2026-09-19 10:34:45 +02:00
Patrick Mulligan
44a5ebbd12 fix(access): reset router to home on re-lock so the reopened gate lands on idle
After a transaction with the gate enabled, the machine re-locks (cashOut/
cashIn → locked, not idle), so the view's isIdle watch never fires and the
router stays on /cash-out|/cash-in. When the next tap reopens the gate to
idle, the stale transaction view showed (e.g. a completed sell's collect
screen, stuck). Reset the route to / while locked (router-view hidden under
LockedView) so idle renders IdleView.
2026-09-19 10:34:45 +02:00
Patrick Mulligan
c7e312a63f feat(access): Bolt Card tap-to-enter — load card into session, one-press Complete
Builds on the access gate: a single Bolt Card tap at the locked screen both
unlocks the terminal AND pre-loads the card, so buy/sell just need "Complete"
— no second tap. Reuses the #83/#84 payment paths verbatim.

Soft entry, verify-at-payment: the tap is parsed LOCALLY (external_id only) so
the single-use SUN p/c stay valid; the cryptographic check happens at Complete
when the stored lnurlw actually moves sats (withdraw for sell, lnurlp-pay for
buy). Open-enrollment, card-only (no npub-QR, no PIN) per product decision.

- services/access: `boltcard` credential (externalId + lnurlw) in the AccessScan
  union; canonicalId + open-enrollment/allow-list authorize; parseBoltcardLnurlw
  (local, no server). Only external_id is hashed — p/c never enter authorize.
- store: loadedBoltCard (session-scoped, cleared on re-lock); handleBoltCardEntry
  (tap while locked → authorize → grant + load); completeWithCard (routes to the
  existing tap handlers); NFC listener routes locked→enter.
- LockedView: card-only "Tap your Bolt Card" screen (dropped camera/npub-QR/PIN).
- CashIn/CashOutView: "Complete Purchase/Sale" button + card chip when loaded.
- tests: boltcard authorize + parseBoltcardLnurlw (17 access tests total).

Enabling the gate is a provisioning step (access.json enabled+openEnrollment);
other machines default off → unchanged.
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
2ea3df01d1 Merge pull request 'chore: scrub "Lamassu" from shipped labels' (#89) from chore/scrub-lamassu-labels into dev
Reviewed-on: #89
2026-09-19 08:18:21 +00:00
cb236703d6 fix(machine): point the favicon at logo.png
index.html still linked Vite's scaffold favicon at /vite.svg, which does
not exist in public/ — so every browser tab (the public demo included)
showed a broken icon next to the title. Use the bitSpire logo that is
already shipped for the idle screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
2026-09-19 09:58:50 +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
59e8a9de02 Merge pull request 'feat: support a public browser demo of the kiosk' (#88) from feat/web-demo into dev
Reviewed-on: #88
2026-09-06 18:04:50 +00:00
8264dd7472 feat(machine): VITE_DEMO_TAG for the public web demo
The browser path (no electronAPI) is already a first-class code path:
initializeWithLightning() resolves an EPHEMERAL LocalSigner, allows mock
fallback and leaves debugMode on, so the bill simulator stands in for the
validator. That is what makes a hosted kiosk demo possible at all. Two
things still needed fixing for it.

1. Cursor. `cursor: none` was applied globally for the touchscreen, which in
   an ordinary browser reads as a broken page. Scope it to `.kiosk`, set on
   <html> by main.ts unless VITE_DEMO_TAG is present — so every real machine
   keeps today's behavior and only the demo build shows a pointer.

2. Cleanup. An ephemeral identity per page load is the right call (it isolates
   concurrent visitors, and each fresh account gets its own auto-credit under
   LNBITS_DEMO_MODE, whereas a single baked-in key would be credited once and
   then drain). The cost is a throwaway LNbits account per visit, and nothing
   in an auto-created row distinguishes one: pubkey-set/prvkey-NULL equally
   describes a real ATM.

   A nostr pubkey can't carry a marker — grinding a vanity prefix is far too
   slow to do on page load — and the account/wallet the server auto-creates
   isn't nameable by the client. So when VITE_DEMO_TAG is set the ATM mints
   one extra, never-used wallet whose NAME is the tag, turning the sweep into
   an exact string match instead of a heuristic about what looks disposable.

Both are inert on a real machine: the var is unset outside the demo build.
The marker call is fire-and-forget — losing it degrades cleanup, not the demo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
2026-09-06 19:24:15 +02:00
ac40ea9bb6 feat(lnbits): wrap the create_wallet RPC
The transport has exposed `create_wallet` (AUTH_ACCOUNT) since the RPC
registry was written, but LnbitsClient never wrapped it — the ATM only ever
needed the auto-created default wallet from `list_wallets`.

Add `createWallet(name)` plus its `CreatedWallet` reply type. Account-scoped,
so the envelope deliberately carries no `wallet_id`: that absence is what
makes the server resolve auth to the Account rather than a Wallet. Not
wrapped in `idempotent()` — a retry would mint a duplicate wallet, same
reasoning as create_invoice.

The reply carries the new wallet's adminkey/inkey, hence the type-level note
not to log it verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
2026-09-06 19:24:15 +02:00
1b671bf407 build(machine): add a web-only build:web target
The machine app's `build` script runs vue-tsc, the Vite build, two electron
tsc passes and an esbuild bundle. Serving the kiosk as a plain SPA needs only
the middle one, and the electron passes drag in native-addon typings that a
web build has no use for.

Add `build:web` (just `vite build`) with a turbo task that still builds the
workspace packages first via `dependsOn: ["^build"]`, so a consumer can run
`pnpm build:web` at the repo root and get `apps/machine/dist`.

`env: ["VITE_*"]` is declared on the task because the Vite vars are baked into
the bundle at build time — without it turbo would happily serve a cached
build produced under different env.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4
2026-09-06 19:24:15 +02:00
83300784ec Merge pull request 'fix(nfc): auto-recover a wedged CCID reader via USB power-cycle' (#85) from fix/nfc-reader-auto-recovery into dev
Reviewed-on: #85
2026-08-09 16:36:46 +00: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
27 changed files with 193 additions and 25 deletions

View file

@ -73,6 +73,18 @@ VITE_SPIRE_SEED=
# Show "Under Service" screen and block all transactions # Show "Under Service" screen and block all transactions
# VITE_MAINTENANCE_MODE=true # VITE_MAINTENANCE_MODE=true
# =============================================================================
# Public Web Demo
# =============================================================================
# Set ONLY for the browser demo build (atm.demo.aiolabs.dev). Leave blank on
# every real machine. When set it:
# - keeps the mouse cursor visible (kiosk builds hide it)
# - mints one extra, never-used LNbits wallet named with this exact string,
# so the throwaway accounts the demo creates (one per page load, each with
# its own ephemeral identity) can be swept by name instead of guessed at.
# VITE_DEMO_TAG=bitspire-web-demo
# ============================================================================= # =============================================================================
# Mock Fallback (Production Safety) # Mock Fallback (Production Safety)
# ============================================================================= # =============================================================================

View file

@ -13,6 +13,8 @@
* QR path keeps working — cash-out never depends on this. * QR path keeps working — cash-out never depends on this.
*/ */
import { execFile } from 'node:child_process'
export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable' export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable'
export interface NfcStatus { export interface NfcStatus {
state: NfcState state: NfcState
@ -83,7 +85,11 @@ export async function readNdefLnurlw(
const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256) const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256)
// Select the NDEF Tag Application (AID D2760000850101). // Select the NDEF Tag Application (AID D2760000850101).
if (!swOk(await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]))) { if (
!swOk(
await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00])
)
) {
return null return null
} }
@ -107,6 +113,29 @@ export async function readNdefLnurlw(
let stopFn: (() => void) | null = null let stopFn: (() => void) | null = null
// ── Wedge auto-recovery ───────────────────────────────────────────────────
// Cheap CCID readers (the Feitian R502-CL especially) occasionally wedge: they
// keep detecting a card but every APDU returns "card absent or mute", and ONLY
// a USB power-cycle clears it — pcscd/app restarts do NOT. When we see a run of
// consecutive read failures we trigger nfc-reader-reset.service (a root oneshot
// that re-binds the reader's USB device = a software replug); nfc-pcsc then
// re-detects the reader on hotplug with no app restart. The trigger is gated by
// a cooldown so a still-wedged reader can't reset-loop. A quality reader (e.g.
// ACR1252U) wedges far less; this is belt-and-suspenders for any reader.
const WEDGE_FAILURE_THRESHOLD = 3
const RESET_COOLDOWN_MS = 30_000
// Persist across reader re-enumerations (a reset spawns a fresh reader closure).
let lastReaderResetAt = 0
/** Trigger the privileged USB power-cycle of the reader. Best-effort. */
function resetWedgedReader(): void {
// NixOS: the app runs unprivileged as `bitspire`; a polkit rule authorises it
// to start this one unit. systemctl lives at a stable path on the device.
execFile('/run/current-system/sw/bin/systemctl', ['start', 'nfc-reader-reset.service'], () => {
/* best-effort — if it fails the reader stays wedged until a manual reset */
})
}
/** /**
* Start listening for Bolt Card taps. Idempotent. Returns a stop function. * Start listening for Bolt Card taps. Idempotent. Returns a stop function.
* Never throws — failures surface via onStatus. * Never throws — failures surface via onStatus.
@ -159,6 +188,10 @@ export async function startNfcReader(
// a present↔empty storm when hammered, so ignore re-detections for a beat // a present↔empty storm when hammered, so ignore re-detections for a beat
// after a failure. Successful reads don't cool down. // after a failure. Successful reads don't cool down.
let cooldownUntil = 0 let cooldownUntil = 0
// Consecutive failed reads → wedge detection (see resetWedgedReader above).
// A completed read (Bolt Card or not) proves the reader is healthy and
// clears the count; only a run of thrown transmits trips the reset.
let consecutiveFailures = 0
r.on('card', async () => { r.on('card', async () => {
if (Date.now() < cooldownUntil) return if (Date.now() < cooldownUntil) return
onStatus({ state: 'reading', reader: name }) onStatus({ state: 'reading', reader: name })
@ -167,13 +200,30 @@ export async function startNfcReader(
// user simply re-taps. // user simply re-taps.
try { try {
const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen)) const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen))
consecutiveFailures = 0
if (lnurlw) { if (lnurlw) {
onCard(lnurlw) onCard(lnurlw)
return return
} }
onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' }) onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' })
} catch (e) { } catch (e) {
onStatus({ state: 'error', reader: name, message: 'card read failed — hold steady & retap' }) consecutiveFailures++
if (
consecutiveFailures >= WEDGE_FAILURE_THRESHOLD &&
Date.now() - lastReaderResetAt > RESET_COOLDOWN_MS
) {
// Reader looks wedged — auto power-cycle it (only fix that works).
lastReaderResetAt = Date.now()
consecutiveFailures = 0
onStatus({ state: 'error', reader: name, message: 'reader stuck — auto-resetting…' })
resetWedgedReader()
} else {
onStatus({
state: 'error',
reader: name,
message: 'card read failed — hold steady & retap',
})
}
void e void e
} }
cooldownUntil = Date.now() + 1500 cooldownUntil = Date.now() + 1500
@ -182,7 +232,9 @@ export async function startNfcReader(
r.on('error', (err: unknown) => r.on('error', (err: unknown) =>
onStatus({ state: 'error', reader: name, message: errMsg(err) }) onStatus({ state: 'error', reader: name, message: errMsg(err) })
) )
r.on('end', () => onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' })) r.on('end', () =>
onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' })
)
}) })
nfc.on('error', (err: unknown) => onStatus({ state: 'error', message: errMsg(err) })) nfc.on('error', (err: unknown) => onStatus({ state: 'error', message: errMsg(err) }))

View file

@ -2,7 +2,7 @@
<html lang="en" class="dark"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<!-- <!--
Content Security Policy: Content Security Policy:
@ -18,7 +18,7 @@
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss: http: https:; img-src 'self' data: blob:; font-src 'self'; frame-src 'none'; object-src 'none'" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss: http: https:; img-src 'self' data: blob:; font-src 'self'; frame-src 'none'; object-src 'none'"
/> />
<title>Lamassu ATM</title> <title>bitSpire ATM</title>
<style> <style>
/* Prevent text selection and context menu on kiosk */ /* Prevent text selection and context menu on kiosk */
* { * {

View file

@ -15,6 +15,7 @@
"dev:vite": "vite", "dev:vite": "vite",
"electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js", "electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js",
"build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --external:better-sqlite3 --outfile=dist-electron/fund-atm.bundle.cjs", "build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --external:better-sqlite3 --outfile=dist-electron/fund-atm.bundle.cjs",
"build:web": "vite build",
"build:electron": "pnpm build && electron-builder", "build:electron": "pnpm build && electron-builder",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",

View file

@ -24,6 +24,13 @@ const router = createRouter({
], ],
}) })
// Kiosk chrome (hidden cursor) is the default — every real machine is a
// touchscreen. The public web demo (VITE_DEMO_TAG) runs in a normal browser,
// where an invisible pointer just reads as broken.
if (!import.meta.env.VITE_DEMO_TAG) {
document.documentElement.classList.add('kiosk')
}
// Create Pinia store // Create Pinia store
const pinia = createPinia() const pinia = createPinia()

View file

@ -505,6 +505,31 @@ export async function initializeLightningServices(options?: {
} }
console.log('[Lightning] LNbits wallet:', lnbitsWalletId) console.log('[Lightning] LNbits wallet:', lnbitsWalletId)
// ── Public web demo: stamp the throwaway account so it can be swept ──────
// The browser demo (atm.demo.aiolabs.dev) runs with an EPHEMERAL identity —
// a fresh keypair per page load — so LNbits mints a new account + a fresh
// auto-credited wallet for every visitor. That isolation is the point (a
// single baked-in key would be credited exactly once and then drain), but it
// leaves throwaway accounts behind, and nothing in an auto-created row says
// "demo": pubkey-set/prvkey-NULL also describes a real ATM.
//
// A nostr pubkey can't carry a marker (you'd have to grind a vanity prefix,
// far too slow to do on page load), and the account/wallet the server
// auto-creates isn't nameable by the client. So we mint one extra,
// never-used wallet whose NAME is the tag: sweeping is then an exact string
// match on wallet name rather than a heuristic about what looks disposable.
//
// Unset on every real machine, so this is inert outside the demo build. The
// call is fire-and-forget: losing the marker degrades cleanup, not the demo.
const demoTag = (import.meta.env.VITE_DEMO_TAG as string | undefined)?.trim()
if (demoTag) {
void lnbits
.createWallet(demoTag)
// Never log the reply — create_wallet returns adminkey/inkey.
.then(() => console.log('[Lightning] Demo marker wallet created:', demoTag))
.catch((e) => console.warn('[Lightning] Demo marker wallet failed:', e))
}
// #70 P1: pull operator pubkey + fee config from LNbits over the authenticated // #70 P1: pull operator pubkey + fee config from LNbits over the authenticated
// transport (spirekeeper#41 `get_machine_config`). A seed-only machine has no // transport (spirekeeper#41 `get_machine_config`). A seed-only machine has no
// VITE_OPERATOR_PUBKEYS, so without this it can't trust its fee config and sits // VITE_OPERATOR_PUBKEYS, so without this it can't trust its fee config and sits

View file

@ -1,10 +1,13 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @import 'tw-animate-css';
/* Hide cursor completely on touchscreen kiosk */ /* Hide cursor completely on touchscreen kiosk.
*, Scoped to .kiosk (set on <html> by main.ts) so the public web demo, which
*::before, runs in an ordinary browser with a mouse, keeps a visible pointer. */
*::after { .kiosk,
.kiosk *,
.kiosk *::before,
.kiosk *::after {
cursor: none !important; cursor: none !important;
} }

View file

@ -33,7 +33,7 @@ esac
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
echo "=== Building Lamassu ATM Live USB ISO (model: $MODEL) ===" echo "=== Building bitSpire ATM Live USB ISO (model: $MODEL) ==="
echo "" echo ""
echo "This is a pure Nix build — no local pnpm required." echo "This is a pure Nix build — no local pnpm required."
echo "" echo ""

View file

@ -94,7 +94,8 @@
# pcscd gates client access via polkit; without a rule the sandboxed # pcscd gates client access via polkit; without a rule the sandboxed
# `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize # `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize
# it to talk to the daemon and the card. # it to talk to the daemon and the card. The second rule lets the app trigger
# the NFC reader wedge-recovery service (see nfc-reader-reset below).
security.polkit.extraConfig = '' security.polkit.extraConfig = ''
polkit.addRule(function(action, subject) { polkit.addRule(function(action, subject) {
if ((action.id == "org.debian.pcsc-lite.access_pcsc" || if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
@ -103,8 +104,47 @@
return polkit.Result.YES; return polkit.Result.YES;
} }
}); });
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
action.lookup("unit") == "nfc-reader-reset.service" &&
subject.user == "bitspire") {
return polkit.Result.YES;
}
});
''; '';
# NFC reader wedge-recovery. The Feitian R502-CL CCID reader (and, less often,
# any CCID reader) 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. This oneshot re-binds the reader's USB device (a
# software replug); pcscd + nfc-pcsc then re-detect it on hotplug with no app
# restart (verified on-device). The app (unprivileged `bitspire`) starts it via
# the polkit rule above when it sees repeated read failures. Reader-agnostic:
# it matches the USB CCID interface class (0x0B), so it also covers a future
# ACR1252U swap without a config change.
systemd.services.nfc-reader-reset = {
description = "Power-cycle a wedged CCID NFC reader (USB re-bind)";
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "reset-nfc-reader" ''
set -u
found=0
for iface in /sys/bus/usb/devices/*:*/bInterfaceClass; do
[ -f "$iface" ] || continue
[ "$(${pkgs.coreutils}/bin/cat "$iface" 2>/dev/null)" = "0b" ] || continue
ifname=$(${pkgs.coreutils}/bin/basename "$(${pkgs.coreutils}/bin/dirname "$iface")")
dev=''${ifname%%:*}
echo "reset-nfc-reader: power-cycling CCID reader USB device $dev" >&2
echo -n "$dev" > /sys/bus/usb/drivers/usb/unbind 2>/dev/null || true
${pkgs.coreutils}/bin/sleep 2
echo -n "$dev" > /sys/bus/usb/drivers/usb/bind 2>/dev/null || true
found=1
done
[ "$found" = 1 ] || { echo "reset-nfc-reader: no CCID reader found" >&2; exit 1; }
'';
};
};
# Disable suspend/hibernate for kiosk # Disable suspend/hibernate for kiosk
systemd.targets = { systemd.targets = {
sleep.enable = false; sleep.enable = false;

View file

@ -1,4 +1,4 @@
# Lamassu ATM Live USB Configuration # bitSpire ATM Live USB Configuration
# Bootable ISO for testing on physical hardware without installing to disk. # Bootable ISO for testing on physical hardware without installing to disk.
# #
# Parameterized by machineModel (passed via specialArgs from flake.nix): # Parameterized by machineModel (passed via specialArgs from flake.nix):

View file

@ -1,4 +1,4 @@
# Lamassu ATM Hardware udev Rules # bitSpire ATM Hardware udev Rules
# Place in /etc/udev/rules.d/ or use services.udev.extraRules in NixOS # Place in /etc/udev/rules.d/ or use services.udev.extraRules in NixOS
# ============================================ # ============================================

View file

@ -1,5 +1,5 @@
{ {
description = "Lamassu Next - Nostr-Native Lightning ATM"; description = "bitSpire - Nostr-Native Lightning ATM";
inputs = { inputs = {
# Stable NixOS for the ATM OS base # Stable NixOS for the ATM OS base

View file

@ -1,4 +1,4 @@
# Pure Nix derivation for the Lamassu ATM Electron app. # Pure Nix derivation for the bitSpire ATM Electron app.
# #
# Uses fetchPnpmDeps + pnpmConfigHook to build entirely inside the Nix sandbox, # Uses fetchPnpmDeps + pnpmConfigHook to build entirely inside the Nix sandbox,
# eliminating the need for --impure or a local pnpm install. # eliminating the need for --impure or a local pnpm install.

View file

@ -7,6 +7,7 @@
"scripts": { "scripts": {
"dev": "turbo dev", "dev": "turbo dev",
"build": "turbo build", "build": "turbo build",
"build:web": "turbo build:web",
"test": "turbo test", "test": "turbo test",
"lint": "turbo lint", "lint": "turbo lint",
"format": "prettier --write .", "format": "prettier --write .",

View file

@ -1,7 +1,7 @@
{ {
"name": "@bitSpire/hal", "name": "@bitSpire/hal",
"version": "0.1.0", "version": "0.1.0",
"description": "Hardware Abstraction Layer for Lamassu ATM devices", "description": "Hardware Abstraction Layer for bitSpire ATM devices",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
@ -44,7 +44,7 @@
"src" "src"
], ],
"keywords": [ "keywords": [
"lamassu", "bitspire",
"atm", "atm",
"hardware", "hardware",
"bill-validator", "bill-validator",

View file

@ -1,7 +1,7 @@
/** /**
* @bitSpire/hal - Hardware Abstraction Layer * @bitSpire/hal - Hardware Abstraction Layer
* *
* Provides drivers for Lamassu ATM hardware devices: * Provides drivers for bitSpire ATM hardware devices:
* - Bill validators (JCM iVIZION via ID003 protocol) * - Bill validators (JCM iVIZION via ID003 protocol)
* - Bill dispensers (Fujitsu F53/F56) * - Bill dispensers (Fujitsu F53/F56)
* *

View file

@ -37,6 +37,7 @@ import type {
CreateInvoiceBody, CreateInvoiceBody,
PayInvoiceBody, PayInvoiceBody,
WalletInfo, WalletInfo,
CreatedWallet,
MachineConfigResponse, MachineConfigResponse,
SubscribePaymentsBody, SubscribePaymentsBody,
SubscribeAck, SubscribeAck,
@ -211,6 +212,17 @@ export class LnbitsClient {
return data ?? [] return data ?? []
} }
/**
* Create an additional wallet on the calling account (`create_wallet`).
*
* Account-scoped (AUTH_ACCOUNT): the envelope carries no `wallet_id`, which
* is what makes the server resolve auth to the Account rather than a Wallet.
* NOT wrapped in `idempotent()` — a retry would mint a duplicate wallet.
*/
async createWallet(name: string): Promise<CreatedWallet> {
return this.sendRpc<CreatedWallet>('create_wallet', { body: { name } })
}
/** Pull server-delivered machine config (operator pubkey + fee config) over /** Pull server-delivered machine config (operator pubkey + fee config) over
* the authenticated transport — spirekeeper's `get_machine_config` RPC * the authenticated transport — spirekeeper's `get_machine_config` RPC
* (bitspire#70 P1). Lets a seed-only ATM configure itself with no per-machine * (bitspire#70 P1). Lets a seed-only ATM configure itself with no per-machine

View file

@ -66,6 +66,7 @@ export type {
CreateInvoiceBody, CreateInvoiceBody,
PayInvoiceBody, PayInvoiceBody,
WalletInfo, WalletInfo,
CreatedWallet,
SubscribePaymentsBody, SubscribePaymentsBody,
SubscribeAck, SubscribeAck,
SubscribePush, SubscribePush,

View file

@ -112,6 +112,15 @@ export interface WalletInfo {
balance: number balance: number
} }
/** Reply shape of the `create_wallet` RPC — unlike WalletInfo it carries the
* fresh wallet's keys, so never log it verbatim. */
export interface CreatedWallet {
id: string
name: string
adminkey: string
inkey: string
}
// ============================================================================ // ============================================================================
// Subscriptions // Subscriptions
// ============================================================================ // ============================================================================

View file

@ -1,7 +1,7 @@
{ {
"name": "@bitSpire/nostr-client", "name": "@bitSpire/nostr-client",
"version": "0.1.0", "version": "0.1.0",
"description": "Nostr client library for Lamassu ATM", "description": "Nostr client library for bitSpire ATM",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",

View file

@ -1,5 +1,5 @@
/** /**
* Nostr client for Lamassu ATM * Nostr client for bitSpire ATM
* *
* Manages connections to Nostr relays with support for: * Manages connections to Nostr relays with support for:
* - NIP-42 authentication * - NIP-42 authentication

View file

@ -1,5 +1,5 @@
/** /**
* Event creation utilities for Lamassu ATM * Event creation utilities for bitSpire ATM
*/ */
import { type Event, type EventTemplate, type VerifiedEvent, getEventHash } from 'nostr-tools' import { type Event, type EventTemplate, type VerifiedEvent, getEventHash } from 'nostr-tools'

View file

@ -1,7 +1,7 @@
/** /**
* @bitSpire/nostr-client * @bitSpire/nostr-client
* *
* Nostr client library for Lamassu ATM communication. * Nostr client library for bitSpire ATM communication.
* *
* Features: * Features:
* - NIP-42 authentication for private relays * - NIP-42 authentication for private relays

View file

@ -1,5 +1,5 @@
/** /**
* Nostr client type definitions for Lamassu ATM * Nostr client type definitions for bitSpire ATM
*/ */
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'

View file

@ -1,7 +1,7 @@
{ {
"name": "@bitSpire/ui-shared", "name": "@bitSpire/ui-shared",
"version": "0.1.0", "version": "0.1.0",
"description": "Shared Vue 3 components for Lamassu ATM and dashboard", "description": "Shared Vue 3 components for bitSpire ATM and dashboard",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",

View file

@ -1,7 +1,7 @@
/** /**
* @bitSpire/ui-shared * @bitSpire/ui-shared
* *
* Shared Vue 3 components for Lamassu ATM and dashboard. * Shared Vue 3 components for bitSpire ATM and dashboard.
* This package will contain common UI components like: * This package will contain common UI components like:
* - QR code display * - QR code display
* - Number pad * - Number pad

View file

@ -5,6 +5,11 @@
"dependsOn": ["^build"], "dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"] "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
}, },
"build:web": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["VITE_*"]
},
"dev": { "dev": {
"cache": false, "cache": false,
"persistent": true "persistent": true