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
52 changed files with 1981 additions and 70 deletions

View file

@ -73,6 +73,18 @@ VITE_SPIRE_SEED=
# Show "Under Service" screen and block all transactions
# 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)
# =============================================================================
@ -81,3 +93,29 @@ VITE_SPIRE_SEED=
# Set to 'true' for development/demo environments only
# When false (production default), initialization failures show a maintenance screen
# VITE_ALLOW_MOCK_FALLBACK=true
# =============================================================================
# Access Control (ADR-003)
# =============================================================================
# Badge-to-enter gate. When disabled (default), the machine boots straight to
# idle exactly as before. When enabled, it boots into a locked screen and
# requires a credential (prototype: an npub QR scanned by the camera, with an
# optional PIN) before transactions are reachable.
# ACCESS_CONTROL_ENABLED=true
# Prototype posture: admit ANY valid npub when the allow-list has no match.
# Turn OFF once a real allow-list (/var/lib/bitspire/access.json) is provisioned.
# ACCESS_OPEN_ENROLLMENT=true
# Allow the on-screen runtime dev/operator unlock button (default: allowed when
# the gate is on). Set to 'false' to hide it on a locked-down deployment.
# ACCESS_DEV_UNLOCK=false
# Per-machine salt for hashing credentials/PINs. Provision a real value in
# production (or in access.json); a fixed default is used if unset.
# ACCESS_SALT=change-me-per-machine
# Build/dev bypass — forces the gate OPEN even when enabled (browser dev / CI).
# Renderer-side (Vite) flag, never set in a production image.
# VITE_SKIP_ACCESS_GATE=true

View file

@ -164,6 +164,61 @@ function loadBranding(): BrandingConfig | null {
return { title, theme, customColors, customColorsDark, logoDataUrl, logoDarkDataUrl }
}
// Access-control config loader (ADR-003). Env toggles the gate; an optional
// /var/lib/bitspire/access.json carries the salt + allow-list. Defaults OFF —
// a machine with neither env nor file behaves as if there is no access layer.
// The allow-list shape mirrors the renderer's AllowListEntry (authorize.ts);
// duplicated here to avoid a cross-project (electron↔renderer) import.
interface AccessAllowListEntry {
idHash: string
role: 'user' | 'operator'
pinHash?: string
label?: string
}
function loadAccessControl() {
// Env provides defaults; access.json (writable, operator-provisioned — same
// spirit as branding/) overrides them, so the gate can be toggled on a
// deployed machine by dropping a file + restarting the service, with no image
// rebuild. Defaults OFF.
let enabled = process.env.ACCESS_CONTROL_ENABLED === 'true'
// Dev unlock allowed by default when the gate is on; opt out explicitly.
let devUnlock = process.env.ACCESS_DEV_UNLOCK !== 'false'
let openEnrollment = process.env.ACCESS_OPEN_ENROLLMENT === 'true'
let salt = process.env.ACCESS_SALT || ''
let allowList: AccessAllowListEntry[] = []
const jsonPath = path.join(
fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(),
'access.json'
)
if (fs.existsSync(jsonPath)) {
try {
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
if (typeof raw.enabled === 'boolean') enabled = raw.enabled
if (typeof raw.devUnlock === 'boolean') devUnlock = raw.devUnlock
if (typeof raw.openEnrollment === 'boolean') openEnrollment = raw.openEnrollment
if (typeof raw.salt === 'string' && raw.salt) salt = raw.salt
if (Array.isArray(raw.allowList)) {
allowList = (raw.allowList as unknown[]).filter(
(e): e is AccessAllowListEntry =>
!!e &&
typeof (e as AccessAllowListEntry).idHash === 'string' &&
((e as AccessAllowListEntry).role === 'user' ||
(e as AccessAllowListEntry).role === 'operator')
)
}
} catch (e) {
console.warn('[Electron] Failed to parse access.json:', e)
}
}
// A gated machine needs a stable salt for deterministic hashing. Fall back to
// a fixed default (prototype); production should provision a real salt.
if (!salt) salt = 'bitspire-access-v1'
return { enabled, devUnlock, openEnrollment, salt, allowList }
}
// Determine if we're in development
const isDev =
process.env.ELECTRON_FORCE_PROD !== '1' &&
@ -311,6 +366,9 @@ ipcMain.handle('get-config', () => {
// Operator branding (logo/title/theme) — null when no override
branding: loadBranding(),
// Access-control gate (ADR-003) — `enabled` defaults false (no gate).
accessControl: loadAccessControl(),
}
})

View file

@ -13,6 +13,8 @@
* 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 interface NfcStatus {
state: NfcState
@ -83,7 +85,11 @@ export async function readNdefLnurlw(
const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256)
// 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
}
@ -107,6 +113,29 @@ export async function readNdefLnurlw(
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.
* 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
// after a failure. Successful reads don't cool down.
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 () => {
if (Date.now() < cooldownUntil) return
onStatus({ state: 'reading', reader: name })
@ -167,13 +200,30 @@ export async function startNfcReader(
// user simply re-taps.
try {
const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen))
consecutiveFailures = 0
if (lnurlw) {
onCard(lnurlw)
return
}
onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' })
} 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
}
cooldownUntil = Date.now() + 1500
@ -182,7 +232,9 @@ export async function startNfcReader(
r.on('error', (err: unknown) =>
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) }))

View file

@ -2,7 +2,7 @@
<html lang="en" class="dark">
<head>
<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" />
<!--
Content Security Policy:
@ -18,7 +18,7 @@
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'"
/>
<title>Lamassu ATM</title>
<title>bitSpire ATM</title>
<style>
/* Prevent text selection and context menu on kiosk */
* {

View file

@ -15,6 +15,7 @@
"dev:vite": "vite",
"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:web": "vite build",
"build:electron": "pnpm build && electron-builder",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",

View file

@ -1,18 +1,39 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useAtmStore } from '@/stores/atm'
import { useTheme } from '@/composables/useTheme'
import { useSessionSecurity } from '@/composables/useSessionSecurity'
import { setBranding } from '@/composables/useBranding'
import { classifyInitError } from '@/services/init-error'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Sun, Moon } from 'lucide-vue-next'
import PairingWizard from '@/components/PairingWizard.vue'
import LockedView from '@/views/LockedView.vue'
import ColorModeToggle from '@/components/ColorModeToggle.vue'
const atmStore = useAtmStore()
const route = useRoute()
const router = useRouter()
const { current: currentTheme, themes, colorMode } = useTheme()
// ADR-003 session security: idle-inactivity re-lock (resets on touch) + an
// absolute hard session cap. Enforced here at the always-mounted shell so it
// spans the whole unlocked session, not just the idle view.
useSessionSecurity()
// When the machine re-locks after a transaction (access gate enabled), the
// router is still on /cash-in or /cash-out under the LockedView overlay. Reset
// it to home so that when the gate reopens to `idle`, IdleView shows — not the
// stale transaction view (e.g. a completed cash-out's collect screen). Runs
// while locked, so the router-view is hidden; no flicker. The disabled-gate path
// (never dwells in `locked`) still returns home via each view's isIdle watch.
watch(
() => atmStore.isLocked,
(locked) => {
if (locked && route.path !== '/') void router.push('/')
}
)
const debugExpanded = ref(false)
const isSupport = computed(() => route.path === '/support')
// Network detected dynamically from Lightning invoice prefix
@ -110,9 +131,7 @@ onMounted(async () => {
// Same env → pairing-seed precedence as lightning.ts: on a blank-.env
// seed-driven machine the relay comes from the pairing transport, not env.
const relayUrl =
config?.relayUrl ||
import.meta.env.VITE_RELAY_URL ||
resolved?.transport?.relays?.[0]
config?.relayUrl || import.meta.env.VITE_RELAY_URL || resolved?.transport?.relays?.[0]
if (signer && relayUrl) {
const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
await client.connect()
@ -289,6 +308,11 @@ function toggleLiveServices() {
</Button>
</div>
<!-- Access gate (ADR-003): shown when the machine is healthy but locked,
below the init/maintenance gates above. Never renders when access
control is disabled (the machine never dwells in `locked`). -->
<LockedView v-else-if="atmStore.isLocked" />
<template v-else>
<router-view />
@ -340,16 +364,10 @@ function toggleLiveServices() {
</div>
<!-- Light/dark toggle (production only — debug panel has this in dev) -->
<Button
<ColorModeToggle
v-if="!atmStore.allowMockFallback"
variant="outline"
class="fixed bottom-3 right-3 lg:bottom-6 lg:right-6 z-50 h-10 px-3 py-1 text-sm rounded-lg lg:h-[7vh] lg:min-h-[70px] lg:px-8 lg:py-3 lg:text-2xl lg:rounded-xl gap-2 lg:gap-3"
@click="colorMode = colorMode === 'dark' ? 'light' : 'dark'"
>
<Sun v-if="colorMode === 'dark'" class="w-5 h-5 lg:w-7 lg:h-7" />
<Moon v-else class="w-5 h-5 lg:w-7 lg:h-7" />
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
</Button>
class="fixed bottom-3 right-3 z-50 lg:bottom-6 lg:right-6"
/>
<!-- Debug overlay (dev only) -->
<div

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
/**
* Light/dark toggle — the single reusable control for switching color mode.
*
* Kiosk-sized by default (large touch target for a public display). colorMode
* is global + persisted (toggles `.dark` on <html>, and dark mode pulls
* branding.json's dark palette + logo-dark.png), so this stays in sync
* wherever it's used. Position it via a fallthrough `class` on the consumer,
* e.g. `<ColorModeToggle class="fixed bottom-6 right-6" />`.
*/
import { useTheme } from '@/composables/useTheme'
import { Button } from '@/components/ui/button'
import { Sun, Moon } from 'lucide-vue-next'
const { colorMode } = useTheme()
function toggle() {
colorMode.value = colorMode.value === 'dark' ? 'light' : 'dark'
}
</script>
<template>
<Button
variant="outline"
class="h-10 gap-2 rounded-lg px-3 py-1 text-sm lg:h-[7vh] lg:min-h-[70px] lg:gap-3 lg:rounded-xl lg:px-8 lg:py-3 lg:text-2xl"
:aria-label="colorMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggle"
>
<Sun v-if="colorMode === 'dark'" class="h-5 w-5 lg:h-7 lg:w-7" />
<Moon v-else class="h-5 w-5 lg:h-7 lg:w-7" />
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
</Button>
</template>

View file

@ -0,0 +1,95 @@
import { watch } from 'vue'
import { useEventListener, useIntervalFn } from '@vueuse/core'
import { useAtmStore } from '@/stores/atm'
/**
* Session security timeouts for the ADR-003 access gate.
*
* Enforced at the DOM layer on purpose: the state machine can't observe raw
* pointer events, so an XState `after` delay can only count from state entry —
* it never resets on a screen touch and therefore can't measure *inactivity*.
* Two independent, fail-closed limits, both of which re-lock via the machine's
* root-level END_SESSION transition:
*
* - SOFT idle (SOFT_IDLE_MS): re-lock after this long with no trusted user
* input while on the idle menu. Resets on every genuine pointer/touch/key
* event. Scoped to `idle` so it never interrupts an in-flight cash-in/out
* (those carry their own, longer machine timeouts).
* - HARD cap (HARD_CAP_MS): re-lock this long after the session began,
* regardless of activity. Anchored to unlock time and never reset — an
* absolute ceiling a forgotten or relayed card can't hold open.
*
* Security properties:
* - Only `event.isTrusted` input resets the soft timer, so synthetic/scripted
* events in the renderer can't keep a session alive.
* - Limits are wall-clock deadline comparisons, not chained setTimeouts: a
* suspended/resumed renderer re-locks on the very next tick instead of
* silently extending the session past its deadline.
* - Both limits only ever *lock*. The machine's accessGateActive guard makes
* END_SESSION a no-op when the gate is off, so this is inert on a
* gate-disabled machine.
* - One-shot per session: after firing, it disarms until the next unlock, so
* a lock that (under dev bypass) doesn't take can't spin.
*
* Call once from the always-mounted App shell.
*/
const SOFT_IDLE_MS = 60_000 // 60s of no interaction on the idle menu
const HARD_CAP_MS = 600_000 // 10min absolute session ceiling
export function useSessionSecurity() {
const atm = useAtmStore()
// Wall-clock anchors. `null` sessionStartedAt == disarmed (no live session).
let sessionStartedAt: number | null = null
let lastActivityAt = 0
function arm() {
const now = Date.now()
sessionStartedAt = now
lastActivityAt = now
}
function disarm() {
sessionStartedAt = null
}
// Arm on each locked → unlocked edge; disarm on lock. Anchored to the
// isLocked transition so the hard cap starts at unlock and does NOT restart
// when moving idle → cashIn → idle within a single session.
watch(
() => atm.isLocked,
(locked, wasLocked) => {
if (wasLocked && !locked && atm.accessControl.enabled) arm()
else if (locked) disarm()
}
)
// Only genuine hardware input counts as activity. Passive + capture so it
// observes every touch without interfering with handling. useEventListener
// auto-detaches on unmount.
const onActivity = (e: Event) => {
if (e.isTrusted) lastActivityAt = Date.now()
}
for (const type of ['pointerdown', 'touchstart', 'keydown', 'wheel'] as const) {
useEventListener(document, type, onActivity, { passive: true, capture: true })
}
// Single 1s evaluator — cheap, and coarse enough that timer drift/suspend
// can only ever make it fire late-then-immediately, never early.
useIntervalFn(() => {
if (sessionStartedAt === null || atm.isLocked || !atm.accessControl.enabled) return
const now = Date.now()
// Hard cap first — absolute, activity-independent.
if (now - sessionStartedAt >= HARD_CAP_MS) {
disarm() // one-shot; re-arms on next unlock
atm.endSession('session-cap')
return
}
// Soft inactivity — idle menu only, resets on trusted input.
if (atm.currentState === 'idle' && now - lastActivityAt >= SOFT_IDLE_MS) {
disarm()
atm.endSession('inactivity')
}
}, 1000)
}

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
const pinia = createPinia()

View file

@ -0,0 +1,166 @@
import { describe, it, expect } from 'vitest'
import { npubEncode, nprofileEncode } from 'nostr-tools/nip19'
import { authorize, hashId, hashPin, type AllowListEntry } from '../authorize'
import { parseBoltcardLnurlw } from '../boltcard'
const SALT = 'test-salt'
const HEX_A = 'aa'.repeat(32)
const HEX_B = 'bb'.repeat(32)
const NPUB_A = npubEncode(HEX_A)
const NPUB_B = npubEncode(HEX_B)
describe('access authorize (ADR-003)', () => {
describe('open enrollment (prototype)', () => {
it('grants any valid npub as user', async () => {
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], {
salt: SALT,
openEnrollment: true,
})
expect(out.status).toBe('granted')
expect(out).toMatchObject({ status: 'granted', role: 'user' })
})
it('rejects a stray / non-npub QR', async () => {
const out = await authorize({ kind: 'npub', npub: 'https://example.com/not-an-npub' }, [], {
salt: SALT,
openEnrollment: true,
})
expect(out.status).toBe('denied')
expect(out).toMatchObject({ reason: 'not a valid npub' })
})
it('accepts a `nostr:` URI prefix (with surrounding whitespace)', async () => {
const out = await authorize({ kind: 'npub', npub: ` nostr:${NPUB_A}\n` }, [], {
salt: SALT,
openEnrollment: true,
})
expect(out.status).toBe('granted')
})
it('accepts an nprofile and resolves to the same identity as its npub', async () => {
const nprofile = nprofileEncode({ pubkey: HEX_A, relays: ['wss://relay.example'] })
const viaNprofile = await authorize({ kind: 'npub', npub: nprofile }, [], {
salt: SALT,
openEnrollment: true,
})
const viaNpub = await authorize({ kind: 'npub', npub: NPUB_A }, [], {
salt: SALT,
openEnrollment: true,
})
expect(viaNprofile.status).toBe('granted')
// Same underlying pubkey → same credential hash.
expect(viaNprofile.credentialIdHash).toBe(viaNpub.credentialIdHash)
})
})
describe('allow-list (closed)', () => {
it('denies an unlisted npub when not open-enrollment', async () => {
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], { salt: SALT })
expect(out).toMatchObject({ status: 'denied', reason: 'not authorized' })
})
it('grants a listed npub with its role, no PIN', async () => {
const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'operator' }
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [entry], { salt: SALT })
expect(out).toMatchObject({ status: 'granted', role: 'operator' })
})
it('does not match npub B against npub A entry', async () => {
const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'user' }
const out = await authorize({ kind: 'npub', npub: NPUB_B }, [entry], { salt: SALT })
expect(out.status).toBe('denied')
})
})
describe('PIN second factor', () => {
const makeEntry = async (): Promise<AllowListEntry> => ({
idHash: await hashId(HEX_A, SALT),
role: 'user',
pinHash: await hashPin('1234', SALT),
})
it('asks for a PIN when one is configured and none supplied', async () => {
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
salt: SALT,
})
expect(out.status).toBe('pin-required')
})
it('grants on correct PIN', async () => {
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
salt: SALT,
pin: '1234',
})
expect(out).toMatchObject({ status: 'granted', role: 'user' })
})
it('denies on wrong PIN', async () => {
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
salt: SALT,
pin: '9999',
})
expect(out).toMatchObject({ status: 'denied', reason: 'incorrect PIN' })
})
})
describe('challenge credential (v2 seam)', () => {
it('is not yet authorized', async () => {
const out = await authorize({ kind: 'challenge', pubkey: HEX_A, nonce: 'n', sig: 's' }, [], {
salt: SALT,
openEnrollment: true,
})
expect(out.status).toBe('denied')
})
})
describe('boltcard credential (tap-to-enter)', () => {
it('open-enrollment grants any card as user', async () => {
const out = await authorize(
{ kind: 'boltcard', externalId: 'abc123', lnurlw: 'lnurlw://h/scan/abc123?p=1&c=2' },
[],
{ salt: SALT, openEnrollment: true }
)
expect(out).toMatchObject({ status: 'granted', role: 'user' })
})
it('rejects a card with no external_id', async () => {
const out = await authorize({ kind: 'boltcard', externalId: '', lnurlw: '' }, [], {
salt: SALT,
openEnrollment: true,
})
expect(out).toMatchObject({ status: 'denied', reason: 'not a valid card' })
})
it('allow-list matches by external_id hash', async () => {
const idHash = await hashId('abc123', SALT)
const list: AllowListEntry[] = [{ idHash, role: 'operator' }]
const out = await authorize(
{ kind: 'boltcard', externalId: 'abc123', lnurlw: 'lnurlw://h/scan/abc123?p=1&c=2' },
list,
{ salt: SALT, openEnrollment: false }
)
expect(out).toMatchObject({ status: 'granted', role: 'operator' })
})
})
})
describe('parseBoltcardLnurlw', () => {
it('extracts external_id from a tapped lnurlw', () => {
expect(
parseBoltcardLnurlw('lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEAD&c=BEEF')
).toEqual({ externalId: 'abc123' })
})
it('strips a lightning: prefix and accepts https', () => {
expect(parseBoltcardLnurlw('lightning:lnurlw://h/boltcards/api/v1/scan/xyz?p=1')).toEqual({
externalId: 'xyz',
})
expect(parseBoltcardLnurlw('https://h/boltcards/api/v1/scan/xyz?p=1')).toEqual({
externalId: 'xyz',
})
})
it('returns null for non-card / malformed input', () => {
expect(parseBoltcardLnurlw('https://h/something/else')).toBeNull()
expect(parseBoltcardLnurlw('not a url')).toBeNull()
expect(parseBoltcardLnurlw('')).toBeNull()
})
})

View file

@ -0,0 +1,148 @@
/**
* Credential authorization (ADR-003).
*
* PROTOTYPE (this PR): a QR "badge" carrying an npub grants terminal access,
* with an OPTIONAL PIN as a second factor. Matching is against a local
* allow-list of hashed identities; `openEnrollment` admits any valid npub
* (no allow-list) for early prototyping. Only salted hashes are compared or
* stored — never the raw npub/UID (KYC-free).
*
* Identity id per scan kind:
* - npub → hex pubkey (decoded, canonical), then hashed
* - uid → raw UID hashed (NFC, PR4)
* - challenge → v2 seam (PR5), not yet authorized
*/
import { decode as nip19Decode } from 'nostr-tools/nip19'
import type { AccessRole } from '@bitSpire/state-machine'
import type { AccessScan } from './types'
/** One authorized identity. `idHash` = hashId(<canonical id>, salt). */
export interface AllowListEntry {
idHash: string
role: AccessRole
/** When set, access requires this PIN (hashPin(pin, salt)) as a 2nd factor. */
pinHash?: string
/** Optional operator-facing label (never a person's real identity). */
label?: string
}
export interface AuthorizeOptions {
/** Per-machine salt for all hashing. */
salt: string
/** Admit any valid credential when the allow-list has no match (prototype). */
openEnrollment?: boolean
/** PIN supplied on the follow-up call after a `pin-required` outcome. */
pin?: string
}
/**
* Three outcomes, so the caller can drive a two-step flow:
* - `granted` → send ACCESS_GRANTED
* - `pin-required` → prompt for a PIN, then call authorize() again with `pin`
* - `denied` → send ACCESS_DENIED(reason)
*/
export type AuthorizeOutcome =
| { status: 'granted'; role: AccessRole; credentialIdHash: string }
| { status: 'pin-required'; credentialIdHash: string }
| { status: 'denied'; credentialIdHash: string; reason: string }
/** Salted SHA-256, hex-encoded. */
async function sha256Hex(input: string): Promise<string> {
const data = new TextEncoder().encode(input)
const digest = await crypto.subtle.digest('SHA-256', data)
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
}
export const hashId = (id: string, salt: string): Promise<string> => sha256Hex(`id:${salt}:${id}`)
export const hashPin = (pin: string, salt: string): Promise<string> =>
sha256Hex(`pin:${salt}:${pin}`)
/**
* Resolve a scan to a canonical identity string, or `null` if malformed.
* npub is decoded to its hex pubkey so npub/hex forms compare equal and a
* stray (non-npub) QR is rejected.
*/
function canonicalId(scan: AccessScan): string | null {
if (scan.kind === 'uid') return scan.uid || null
if (scan.kind === 'boltcard') return scan.externalId || null
if (scan.kind === 'challenge') return null // v2 — handled separately
// Tolerate real-world nostr QR shapes: a bare `npub1…`, a `nostr:` URI
// prefix, and `nprofile1…` (npub + relay hints, what many clients export).
const raw = scan.npub.trim().replace(/^nostr:/i, '')
try {
const decoded = nip19Decode(raw)
if (decoded.type === 'npub' && typeof decoded.data === 'string') {
return decoded.data
}
if (
decoded.type === 'nprofile' &&
decoded.data &&
typeof (decoded.data as { pubkey?: unknown }).pubkey === 'string'
) {
return (decoded.data as { pubkey: string }).pubkey
}
return null
} catch {
return null
}
}
/**
* Decide whether a scanned credential is authorized.
* Always resolves (never throws) so the caller can uniformly react.
*/
export async function authorize(
scan: AccessScan,
allowList: AllowListEntry[],
opts: AuthorizeOptions
): Promise<AuthorizeOutcome> {
if (scan.kind === 'challenge') {
return {
status: 'denied',
credentialIdHash: '',
reason: 'challenge-response credentials not yet supported',
}
}
const id = canonicalId(scan)
if (!id) {
return {
status: 'denied',
credentialIdHash: '',
reason:
scan.kind === 'npub'
? 'not a valid npub'
: scan.kind === 'boltcard'
? 'not a valid card'
: 'invalid credential',
}
}
const credentialIdHash = await hashId(id, opts.salt)
const entry = allowList.find((e) => e.idHash === credentialIdHash)
if (!entry) {
if (opts.openEnrollment) {
return { status: 'granted', role: 'user', credentialIdHash }
}
return { status: 'denied', credentialIdHash, reason: 'not authorized' }
}
// No PIN configured → single-factor grant.
if (!entry.pinHash) {
return { status: 'granted', role: entry.role, credentialIdHash }
}
// PIN configured but not yet supplied → ask for it.
if (opts.pin === undefined) {
return { status: 'pin-required', credentialIdHash }
}
// PIN supplied → verify.
const pinHash = await hashPin(opts.pin, opts.salt)
if (pinHash !== entry.pinHash) {
return { status: 'denied', credentialIdHash, reason: 'incorrect PIN' }
}
return { status: 'granted', role: entry.role, credentialIdHash }
}

View file

@ -0,0 +1,27 @@
/**
* Bolt Card lnurlw parsing for the access gate (ADR-003).
*
* The tap-to-enter flow reads a Bolt Card's `lnurlw://…/scan/<external_id>?p=&c=`
* voucher and needs the `external_id` for the session identity — WITHOUT hitting
* the server (that would burn the single-use SUN p/c we want to reuse at
* Complete). So this is a purely local parse: extract the id from the URL path;
* the p/c ride along in the stored lnurlw and are only spent at payment time.
*/
/** Extract a Bolt Card's `external_id` from its tapped lnurlw. Null if not one. */
export function parseBoltcardLnurlw(lnurlw: string): { externalId: string } | null {
let s = lnurlw.trim()
if (!s) return null
if (s.toLowerCase().startsWith('lightning:')) s = s.slice('lightning:'.length)
const https = s.replace(/^lnurlw:\/\//i, 'https://').replace(/^lnurl:\/\//i, 'https://')
if (!/^https:\/\//i.test(https)) return null
try {
const u = new URL(https)
// …/boltcards/api/v1/scan/<external_id>
const m = u.pathname.match(/\/scan\/([^/?#]+)/)
if (!m || !m[1]) return null
return { externalId: decodeURIComponent(m[1]) }
} catch {
return null
}
}

View file

@ -0,0 +1,43 @@
/**
* Access-control module surface (ADR-003).
*
* `availableAccessReaders()` returns the readers this device can run, in
* preference order: the camera npub-QR badge first (prototype, works on the
* batm3 today), the mock reader as a keyboard/console fallback. PR3 adds a
* Web-NFC reader and PR4 the serial `/dev/ttyNFC` reader ahead of these.
*/
import { QrNpubAccessReader } from './qr-npub-reader'
import { MockAccessReader } from './mock-reader'
import type { AccessReader } from './types'
export type {
AccessReader,
AccessReaderKind,
AccessReaderStartOptions,
AccessScan,
AccessRole,
StopCapture,
} from './types'
export { QrNpubAccessReader } from './qr-npub-reader'
export { MockAccessReader, MOCK_NPUB } from './mock-reader'
export { authorize, hashId, hashPin } from './authorize'
export type { AllowListEntry, AuthorizeOptions, AuthorizeOutcome } from './authorize'
export { parseBoltcardLnurlw } from './boltcard'
/** All readers in preference order, regardless of availability. */
export function allAccessReaders(): AccessReader[] {
// PR3: WebNfcAccessReader, PR4: SerialNfcAccessReader — inserted ahead of the
// camera once real NFC hardware is present.
return [new QrNpubAccessReader(), new MockAccessReader()]
}
/**
* Only the readers this device can run, in preference order. The mock reader
* is always available, so it lands last as a guaranteed fallback.
*/
export async function availableAccessReaders(): Promise<AccessReader[]> {
const readers = allAccessReaders()
const flags = await Promise.all(readers.map((r) => r.isAvailable()))
return readers.filter((_, i) => flags[i])
}

View file

@ -0,0 +1,53 @@
/**
* Mock access reader (ADR-003) — SCAFFOLD, no hardware.
*
* A keyboard/console fallback for when no camera or NFC reader is present
* (headless dev, CI, a batm3 with a dead camera). Emits an npub scan on
* demand via two triggers:
* - `window.__bitspireMockCard(npub?)` — from the LockedView dev button or
* the devtools console.
* - the `F9` key — a quick tap on the physical machine.
*
* Registered only when it is the sole available reader (see `index.ts`), so it
* never shadows the real camera/NFC path.
*/
import { npubEncode } from 'nostr-tools/nip19'
import type { AccessReader, AccessReaderStartOptions, StopCapture } from './types'
/**
* Default npub the mock emits when none is supplied — derived from a fixed
* (all-ones) hex pubkey so it carries a valid bech32 checksum and survives
* `authorize()`'s nip19 decode. Not a real key; dev-only.
*/
export const MOCK_NPUB = npubEncode('11'.repeat(32))
interface MockCardGlobal {
__bitspireMockCard?: (npub?: string) => void
}
export class MockAccessReader implements AccessReader {
readonly kind = 'mock' as const
readonly label = 'Mock reader (dev)'
async isAvailable(): Promise<boolean> {
return true
}
async start(opts: AccessReaderStartOptions): Promise<StopCapture> {
const emit = (npub: string = MOCK_NPUB) => opts.onScan({ kind: 'npub', npub })
const g = globalThis as unknown as MockCardGlobal
g.__bitspireMockCard = emit
const onKey = (e: KeyboardEvent) => {
if (e.key === 'F9') emit()
}
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('keydown', onKey)
if (g.__bitspireMockCard === emit) delete g.__bitspireMockCard
}
}
}

View file

@ -0,0 +1,79 @@
/**
* QR-npub access reader (ADR-003, PROTOTYPE).
*
* Until the NFC reader hardware exists, the batm3's camera — the same one the
* pairing wizard uses — reads a QR "badge" that encodes the user's npub. The
* decoded npub is handed to `authorize()`, which admits it (optionally behind
* a PIN). This is a thin adapter onto the same `qr/dom.js` decode loop as
* `pairing/qr-source.ts`; see that file for the capture-resolution rationale.
*
* It emits the raw decoded string as an `npub` scan and lets `authorize()`
* validate it — a stray, non-npub QR is rejected there, not here.
*/
import { QRCanvas, frontalCamera, frameLoop } from 'qr/dom.js'
import type { AccessReader, AccessReaderStartOptions, StopCapture } from './types'
export class QrNpubAccessReader implements AccessReader {
readonly kind = 'qr-npub' as const
readonly label = 'Camera (npub QR)'
async isAvailable(): Promise<boolean> {
return (
typeof navigator !== 'undefined' &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getUserMedia === 'function'
)
}
async start(opts: AccessReaderStartOptions): Promise<StopCapture> {
const { onScan, onError, video } = opts
if (!video) throw new Error('QrNpubAccessReader requires a <video> element')
const camera = await frontalCamera(video)
// Match the pairing source's deliberate capture resolution (see
// pairing/qr-source.ts) — 1280x960 balances px/module against decode speed
// on this fixed-focus panel. Soft `ideal` so a camera that can't honor it
// degrades instead of throwing.
try {
const stream = video.srcObject
if (stream instanceof MediaStream) {
await stream.getVideoTracks()[0]?.applyConstraints({
width: { ideal: 1280 },
height: { ideal: 960 },
})
}
} catch (e) {
onError?.(e)
}
const canvas = new QRCanvas() // decode-only
let stopped = false
let cancel: (() => void) | null = null
const stop: StopCapture = () => {
if (stopped) return
stopped = true
cancel?.()
camera.stop()
}
cancel = frameLoop(() => {
if (stopped) return
try {
const result = camera.readFrame(canvas, true)
if (result) {
// Stop on first decode so one badge isn't ingested repeatedly; the
// store restarts the reader if authorization fails.
stop()
onScan({ kind: 'npub', npub: result.trim() })
}
} catch (e) {
onError?.(e)
}
})
return stop
}
}

View file

@ -0,0 +1,66 @@
/**
* Access-control reader abstraction (ADR-003).
*
* Mirrors the `services/pairing` `PairingSource` seam: an `AccessReader`
* captures a credential from whatever hardware the machine has and hands an
* `AccessScan` to the store, which authorizes it and grants/denies terminal
* access. Implementations live next to this file:
* - `qr-npub-reader.ts` — camera scans an npub QR "badge" (PROTOTYPE, works
* on batm3 today — same camera the pairing wizard uses)
* - `mock-reader.ts` — dev, no hardware (keyboard / console trigger)
* - `web-nfc-reader.ts` — Web NFC / NDEFReader, laptop/phone dev (PR3)
* - `serial-reader.ts` — /dev/ttyNFC via main-process HAL + IPC (PR4)
*
* The reader emits a RAW credential; hashing/authorization (and the optional
* PIN second factor) is the store's job (see `authorize.ts`), so raw ids never
* leave this layer (KYC-free).
*/
import type { AccessRole } from '@bitSpire/state-machine'
export type { AccessRole }
export type AccessReaderKind = 'qr-npub' | 'mock' | 'nfc-web' | 'nfc-serial'
/**
* A raw credential captured by a reader. Discriminated union so new factors
* are additive:
* - `npub` — prototype QR badge (this PR)
* - `uid` — NFC card UID (PR4)
* - `challenge` — card-signed nonce, challenge-response (PR5)
*/
export type AccessScan =
| { kind: 'npub'; npub: string }
| { kind: 'uid'; uid: string }
// A tapped Bolt Card: `externalId` is the identity (from the lnurlw path);
// `lnurlw` is the full voucher (p/c intact) the session reuses at Complete to
// move sats. Only `externalId` is ever hashed/authorized — the p/c never enter
// the authorize layer (KYC-free; they're single-use secrets held transiently
// by the store for the one transaction).
| { kind: 'boltcard'; externalId: string; lnurlw: string }
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string }
export interface AccessReaderStartOptions {
/** Called with each captured credential. */
onScan: (scan: AccessScan) => void
/** Non-fatal capture error (e.g. a frame decode glitch). */
onError?: (error: unknown) => void
/**
* The <video> element a camera reader renders into. Required by camera
* readers (qr-npub); ignored by readers with no viewfinder (mock, NFC).
*/
video?: HTMLVideoElement
}
/** Releases the reader (camera stream, event listeners, serial handle). Idempotent. */
export type StopCapture = () => void
export interface AccessReader {
readonly kind: AccessReaderKind
/** Short human label for status/debug UI. */
readonly label: string
/** Whether this reader can run in the current environment. */
isAvailable(): Promise<boolean>
/** Begin capturing; resolves once the reader is live. */
start(opts: AccessReaderStartOptions): Promise<StopCapture>
}

View file

@ -505,6 +505,31 @@ export async function initializeLightningServices(options?: {
}
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
// 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

View file

@ -8,7 +8,9 @@ import {
type ActorRefFrom,
type SnapshotFrom,
type ATMMachine,
type AccessRole,
} from '@bitSpire/state-machine'
import type { AccessControlConfig } from '@/types/electron'
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
import { classifyInitError } from '@/services/init-error'
import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config'
@ -25,6 +27,7 @@ import {
} from '@bitSpire/clink'
import type { TransactionRecord } from '@/types/state'
import { useAvailabilityBroadcast } from '@/composables/useAvailabilityBroadcast'
import { authorize, parseBoltcardLnurlw, type AccessScan } from '@/services/access'
// Check if we're running in Electron
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
@ -291,6 +294,23 @@ export const useAtmStore = defineStore('atm', () => {
// is in flight (settlement still arrives via the normal invoice watcher).
const nfcStatus = ref<{ state: string; message?: string } | null>(null)
const boltCardProcessing = ref(false)
// Access-control gate config (ADR-003). Defaults disabled → the machine's
// `locked` state bypasses straight to `idle` (behaviour identical to no gate).
// Populated from RuntimeConfig.accessControl in initializeForProduction.
const accessControl = ref<AccessControlConfig>({
enabled: false,
devUnlock: false,
openEnrollment: false,
salt: 'bitspire-access-v1',
allowList: [],
})
// Build/dev bypass — opens the gate even when enabled (browser dev / CI).
const accessBypassFlag = import.meta.env.VITE_SKIP_ACCESS_GATE === 'true'
// Tap-to-enter (ADR-003): the Bolt Card tapped at the locked screen is held
// here for the whole session so buy/sell just need "Complete" — no second tap.
// Carries the full lnurlw (single-use SUN p/c intact; spent only at payment).
// Cleared when the session ends (machine re-locks). Never logged.
const loadedBoltCard = ref<{ externalId: string; lnurlw: string } | null>(null)
const fiatCode = ref('USD')
// Defaults are 0 — the operator's fee config (received via Nostr
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
@ -421,6 +441,11 @@ export const useAtmStore = defineStore('atm', () => {
const isIdle = computed(() => currentState.value === 'idle')
// ADR-003: the machine is sitting at the access gate. When the gate is
// disabled this is never true (the `locked` state bypasses to `idle` on
// start), so App.vue's LockedView branch never renders on a non-access machine.
const isLocked = computed(() => currentState.value === 'locked')
const isCashIn = computed(() => {
const state = snapshot.value?.value
return typeof state === 'object' && 'cashIn' in state
@ -448,6 +473,8 @@ export const useAtmStore = defineStore('atm', () => {
currency: fiatCode.value,
cashInFeeFraction: cashInFeeFraction.value,
cashOutFeeFraction: cashOutFeeFraction.value,
accessControlEnabled: accessControl.value.enabled,
accessBypassFlag,
})
actor.value = createActor(machine)
@ -473,6 +500,12 @@ export const useAtmStore = defineStore('atm', () => {
lnurlCleanupFn()
}
// Drop the tapped-at-entry Bolt Card when the session ends (machine
// re-locks) so the next customer starts fresh — never carry a card over.
if (state === 'locked' && loadedBoltCard.value) {
loadedBoltCard.value = null
}
// Detect network from first invoice we see
if (newSnapshot.context.invoice) {
detectNetworkFromInvoice(newSnapshot.context.invoice)
@ -650,21 +683,76 @@ export const useAtmStore = defineStore('atm', () => {
}
}
/**
* A tapped Bolt Card at the locked screen (ADR-003 tap-to-enter). Soft entry:
* parse the external_id LOCALLY (no server call, so the single-use SUN p/c stay
* valid), authorize (open-enrollment or allow-list), then hold the full lnurlw
* for the session. The cryptographic check happens later, at Complete, when the
* stored lnurlw actually moves sats.
*/
async function handleBoltCardEntry(lnurlw: string) {
if (!isLocked.value) return
if (boltCardProcessing.value) return
const parsed = parseBoltcardLnurlw(lnurlw)
if (!parsed) {
nfcStatus.value = { state: 'declined', message: 'Not a Bolt Card' }
denyAccess('not a Bolt Card')
return
}
boltCardProcessing.value = true
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
try {
const scan: AccessScan = { kind: 'boltcard', externalId: parsed.externalId, lnurlw }
const outcome = await authorize(scan, accessControl.value.allowList, {
salt: accessControl.value.salt,
openEnrollment: accessControl.value.openEnrollment,
})
if (outcome.status === 'granted') {
loadedBoltCard.value = { externalId: parsed.externalId, lnurlw }
nfcStatus.value = { state: 'accepted', message: 'Card accepted' }
grantAccess(outcome.role, outcome.credentialIdHash)
} else {
// pin-required can't occur for card-only open-enrollment; treat as denied.
const reason = outcome.status === 'denied' ? outcome.reason : 'card not authorized'
nfcStatus.value = { state: 'declined', message: reason }
denyAccess(reason, outcome.credentialIdHash)
}
} finally {
boltCardProcessing.value = false
}
}
/**
* Complete a buy/sell using the Bolt Card loaded at entry — no second tap.
* Cash-out pulls via the stored lnurlw; cash-in resolves it to the card
* wallet's lnurlp and pays. Reuses the tap handlers verbatim.
*/
function completeWithCard() {
const card = loadedBoltCard.value
if (!card) return
if (isCashOut.value) void handleBoltCardTap(card.lnurlw)
else if (isCashIn.value) void handleBoltCardReceive(card.lnurlw)
}
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
function setupNfcListener() {
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
window.electronAPI.onNfcCardTapped((lnurlw) => {
// Route the same physical tap by flow: cash-out pulls, cash-in receives.
if (isCashOut.value && nestedState.value === 'displayingInvoice') {
// Route the tap by state: locked → enter + load the card; then cash-out
// pulls, cash-in receives (fallback if no card was loaded at entry).
if (isLocked.value) {
void handleBoltCardEntry(lnurlw)
} else if (isCashOut.value && nestedState.value === 'displayingInvoice') {
void handleBoltCardTap(lnurlw)
} else if (isCashIn.value && nestedState.value === 'displayingQR') {
void handleBoltCardReceive(lnurlw)
}
})
window.electronAPI.onNfcStatus?.((status) => {
// Only surface reader status on a tap screen, and don't clobber an
// in-flight tap's message.
// Surface reader status on a tap screen (locked / invoice / QR); don't
// clobber an in-flight tap's message.
const onTapScreen =
isLocked.value ||
(isCashOut.value && nestedState.value === 'displayingInvoice') ||
(isCashIn.value && nestedState.value === 'displayingQR')
if (onTapScreen && !boltCardProcessing.value) {
@ -683,6 +771,11 @@ export const useAtmStore = defineStore('atm', () => {
void handleBoltCardReceive(lnurlw)
}
/** Dev/mock: simulate tapping a card at the locked screen (tap-to-enter). */
function simulateBoltCardEntry(lnurlw: string) {
void handleBoltCardEntry(lnurlw)
}
/**
* Group an array of inserted bill denominations into { denomination, count } pairs.
*/
@ -1183,6 +1276,19 @@ export const useAtmStore = defineStore('atm', () => {
const runtimeFiatCode = runtimeConfig.fiatCode || 'USD'
fiatCode.value = runtimeFiatCode
// Access-control gate (ADR-003). Must be set BEFORE the machine is built
// (initialize() reads accessControl.value to seed the `locked` state).
if (runtimeConfig.accessControl) {
accessControl.value = runtimeConfig.accessControl
if (runtimeConfig.accessControl.enabled) {
console.log(
`[ATM] Access control ENABLED (openEnrollment=${runtimeConfig.accessControl.openEnrollment}, ` +
`allowList=${runtimeConfig.accessControl.allowList.length} entries, ` +
`devUnlock=${runtimeConfig.accessControl.devUnlock})`
)
}
}
// Load persisted operator fee config (aiolabs/lamassu-next#57). If no
// config has ever been applied (fresh ATM, pre-operator-publish),
// enter the 'awaiting-fees' maintenance state — UI shows the operator
@ -1491,6 +1597,61 @@ export const useAtmStore = defineStore('atm', () => {
actor.value.send(event)
}
// === Access control (ADR-003) ===
/**
* Audit stub — records an access decision. PR1 logs only (hashed id, never a
* raw credential); a fast-follow persists to state.db and optionally a Nostr
* event (see ADR-003).
*/
function recordAccessAudit(outcome: {
result: 'granted' | 'denied'
role?: AccessRole
credentialIdHash: string
reason?: string
}) {
console.info('[Access] audit', {
result: outcome.result,
role: outcome.role ?? null,
// Truncate the hash in logs — it's already non-reversible, but no need to
// splash the full value across the journal.
credentialIdHash: outcome.credentialIdHash.slice(0, 12),
reason: outcome.reason ?? null,
at: Date.now(),
})
}
/** Grant terminal access after a credential (and any PIN) is authorized. */
function grantAccess(role: AccessRole, credentialIdHash: string) {
recordAccessAudit({ result: 'granted', role, credentialIdHash })
send({ type: 'ACCESS_GRANTED', role, credentialIdHash })
}
/** Reject an access attempt; the machine stays locked and shows the reason. */
function denyAccess(reason: string, credentialIdHash = '') {
recordAccessAudit({ result: 'denied', credentialIdHash, reason })
send({ type: 'ACCESS_DENIED', reason })
}
/** Runtime dev/operator unlock (gated by the machine's devUnlockAllowed guard). */
function devUnlock() {
recordAccessAudit({ result: 'granted', role: 'operator', credentialIdHash: 'dev-unlock' })
send({ type: 'DEV_UNLOCK' })
}
/**
* End the current tap-in session and re-lock immediately (drops the loaded
* Bolt Card via `locked`'s entry). Routed through the machine's root-level
* END_SESSION so it locks from any unlocked state. Callers: the "End session"
* button, the idle-inactivity timer, and the absolute session cap. `reason`
* is recorded for the access audit trail — never a raw credential. No-op
* unless the gate is active (guarded in the machine).
*/
function endSession(reason: 'user' | 'inactivity' | 'session-cap' = 'user') {
console.info(`[ATM] Ending session — reason=${reason}`)
send({ type: 'END_SESSION' })
}
// Convenience methods for common events
function selectCashIn() {
send({ type: 'SELECT_CASH_IN' })
@ -1640,6 +1801,18 @@ export const useAtmStore = defineStore('atm', () => {
boltCardProcessing,
simulateBoltCardTap,
simulateBoltCardReceive,
// Tap-to-enter: card loaded at the locked screen, reused at Complete
loadedBoltCard,
completeWithCard,
simulateBoltCardEntry,
// Access control (ADR-003)
accessControl,
isLocked,
grantAccess,
denyAccess,
devUnlock,
endSession,
// Actions
initialize,

View file

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

View file

@ -2,6 +2,26 @@
* Type declarations for Electron API exposed via preload
*/
import type { AllowListEntry } from '../services/access/authorize'
/**
* Access-control config (ADR-003). Loaded by the main process from env +
* an optional /var/lib/bitspire/access.json. `enabled` defaults false, so a
* machine with no access config behaves exactly as before.
*/
export interface AccessControlConfig {
/** Master switch for the badge-to-enter gate. */
enabled: boolean
/** Allow the runtime dev/operator unlock gesture on the locked screen. */
devUnlock: boolean
/** Prototype: admit any valid npub when the allow-list has no match. */
openEnrollment: boolean
/** Per-machine salt for hashing credentials/PINs. */
salt: string
/** Authorized identities (hashed). Empty in open-enrollment prototype mode. */
allowList: AllowListEntry[]
}
export interface RuntimeConfig {
relayUrl: string
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
@ -17,6 +37,8 @@ export interface RuntimeConfig {
maintenanceMode: boolean
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
branding: BrandingConfig | null
/** Access-control gate config (ADR-003). Always present; `enabled` defaults false. */
accessControl: AccessControlConfig
}
/** Operator branding config. Wire payload from Electron IPC; renderer applies via useBranding(). */

View file

@ -363,6 +363,24 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
</p>
</div>
<!-- Tap-to-enter: card already loaded → one-press Complete (no re-tap) -->
<div
v-if="atmStore.loadedBoltCard"
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
>
<p class="text-xs uppercase tracking-wide text-muted-foreground">
Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }}
</p>
<Button
class="w-full bg-success text-success-foreground"
size="kiosk-lg"
:disabled="atmStore.boltCardProcessing"
@click="atmStore.completeWithCard()"
>
{{ atmStore.boltCardProcessing ? 'Completing…' : 'Complete Purchase' }}
</Button>
</div>
<!-- LNURL URI (web-ui only) -->
<div v-if="!isElectron && currentQrValue" class="pt-4 text-center space-y-1">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">

View file

@ -328,6 +328,24 @@ function formatFiat(cents: number): string {
</p>
</div>
<!-- Tap-to-enter: card already loaded → one-press Complete (no re-tap) -->
<div
v-if="atmStore.loadedBoltCard"
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
>
<p class="text-xs uppercase tracking-wide text-muted-foreground">
Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }}
</p>
<Button
class="w-full bg-success text-success-foreground"
size="kiosk-lg"
:disabled="atmStore.boltCardProcessing"
@click="atmStore.completeWithCard()"
>
{{ atmStore.boltCardProcessing ? 'Completing…' : 'Complete Sale' }}
</Button>
</div>
<!-- Invoice info with copy button (web-ui only) -->
<div v-if="context?.invoice && !isElectron" class="pt-4 text-center">
<p class="font-mono-code mb-2 truncate text-xs text-muted-foreground max-w-[300px]">

View file

@ -173,15 +173,37 @@ function handleCashOut() {
</div>
</div>
<!-- Help button (top-left) -->
<Button
variant="outline"
size="icon"
class="absolute top-4 left-4 lg:top-8 lg:left-8 h-14 w-14 lg:h-20 lg:w-20 rounded-full border-2 border-muted-foreground/30 text-2xl lg:text-4xl text-muted-foreground"
@click="$router.push('/support')"
>
?
</Button>
<!-- Top-left utility buttons: Help, plus an End-session "✕" while the access
gate is engaged. Kept in the left corner (not top-right) so they never
collide with the centered balance/commission chips, which wrap into the
top-right on narrower screens (e.g. sintra). A tap-in loads the holder's
Bolt Card for the whole session, so the ✕ gives them an explicit way to
re-lock the moment they're done rather than waiting out the idle timeout
(which would leave the card usable by the next person meanwhile). -->
<div class="absolute top-4 left-4 lg:top-8 lg:left-8 flex items-center gap-2 lg:gap-3">
<!-- End session first (leftmost): a solid `destructive` swatch so the exit
reads as red in every theme (--destructive is theme-scoped). Shown
only while the access gate is engaged. -->
<Button
v-if="atmStore.accessControl.enabled"
variant="destructive"
size="icon"
class="h-14 w-14 lg:h-20 lg:w-20 rounded-full text-2xl lg:text-4xl font-bold"
aria-label="End session"
@click="atmStore.endSession()"
>
✕
</Button>
<Button
variant="outline"
size="icon"
class="h-14 w-14 lg:h-20 lg:w-20 rounded-full border-2 border-muted-foreground/30 text-2xl lg:text-4xl text-muted-foreground"
aria-label="Help"
@click="$router.push('/support')"
>
?
</Button>
</div>
<!-- Debug toggle (only visible when debug bar is hidden, never in production) -->
<Button

View file

@ -0,0 +1,109 @@
<script setup lang="ts">
/**
* Access gate — "tap your Bolt Card" screen (ADR-003, tap-to-enter).
*
* Shown when the machine is healthy but locked (App.vue's `isLocked` branch).
* Entry is a single Bolt Card tap: the card is read by the main-process NFC
* reader and routed to the store (`handleBoltCardEntry`) which authorizes it
* (open-enrollment) and, on grant, loads the card into the session so buy/sell
* only need "Complete". This view is presentation-only — it shows the prompt
* and live reader status; the store owns the tap handling and machine events.
*
* Card-only by design: no camera/npub-QR, no PIN. A dev paste-box (debug builds)
* and a dev-unlock button remain for testing without hardware.
*/
import { computed, ref } from 'vue'
import { useAtmStore } from '@/stores/atm'
import { useBranding } from '@/composables/useBranding'
import { Button } from '@/components/ui/button'
import ColorModeToggle from '@/components/ColorModeToggle.vue'
import { Nfc } from 'lucide-vue-next'
const atmStore = useAtmStore()
const { logoUrl, title } = useBranding()
const denyReason = computed(() => atmStore.snapshot?.context.accessDenyReason ?? null)
const showDevUnlock = computed(() => atmStore.accessControl.devUnlock)
const nfc = computed(() => atmStore.nfcStatus)
const reading = computed(() => atmStore.boltCardProcessing)
// Dev: paste an lnurlw to simulate a tap-to-enter without a card.
const mockLnurlw = ref('')
</script>
<template>
<div
class="relative flex flex-1 flex-col items-center justify-center gap-10 bg-background p-8 text-foreground"
>
<!-- Light/dark toggle — shared kiosk-sized component -->
<ColorModeToggle class="absolute right-4 top-4 z-10" />
<!-- Brand: logo + title only, colours from the active theme (branding.json) -->
<div class="flex flex-col items-center gap-4">
<img v-if="logoUrl" :src="logoUrl" alt="" class="h-[16vh] max-h-44 w-auto object-contain" />
<h1 class="text-3xl font-bold tracking-tight lg:text-5xl">{{ title }}</h1>
</div>
<!-- Tap target -->
<div class="flex flex-col items-center gap-6">
<div
class="flex items-center justify-center rounded-full border-4 border-primary bg-card shadow-xl"
:class="reading ? 'animate-pulse' : ''"
style="width: min(48vw, 15rem); aspect-ratio: 1 / 1"
>
<Nfc class="size-24 text-primary lg:size-28" />
</div>
<p class="text-2xl font-semibold text-foreground lg:text-4xl">
{{ reading ? 'Reading card…' : 'Tap your Bolt Card to begin' }}
</p>
<!-- Reader status / denial reason -->
<p v-if="denyReason" class="text-lg font-medium text-destructive lg:text-2xl">
{{ denyReason }}
</p>
<p
v-else-if="nfc?.message"
class="text-base lg:text-xl"
:class="
nfc.state === 'declined' || nfc.state === 'error'
? 'text-destructive'
: 'text-muted-foreground'
"
>
{{ nfc.message }}
</p>
<p v-else class="max-w-md text-center text-base text-muted-foreground lg:text-xl">
Hold your card flat against the reader
</p>
</div>
<!-- Dev affordances -->
<div class="mt-2 flex flex-col items-center gap-2">
<Button
v-if="showDevUnlock"
variant="ghost"
size="sm"
class="text-muted-foreground opacity-40 transition-opacity hover:opacity-100"
@click="atmStore.devUnlock()"
>
Dev unlock
</Button>
<div v-if="atmStore.debugMode" class="flex items-center gap-2">
<input
v-model="mockLnurlw"
placeholder="lnurlw://… (paste to simulate a tap)"
class="w-56 rounded border border-input bg-background px-2 py-1 text-xs"
/>
<Button
variant="outline"
size="sm"
:disabled="!mockLnurlw"
@click="atmStore.simulateBoltCardEntry(mockLnurlw)"
>
Tap
</Button>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,5 @@
{
"enabled": true,
"openEnrollment": true,
"devUnlock": true
}

View file

@ -33,7 +33,7 @@ esac
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && 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 "This is a pure Nix build — no local pnpm required."
echo ""

View file

@ -94,7 +94,8 @@
# pcscd gates client access via polkit; without a rule the sandboxed
# `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 = ''
polkit.addRule(function(action, subject) {
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
@ -103,8 +104,47 @@
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
systemd.targets = {
sleep.enable = false;

View file

@ -91,6 +91,27 @@
cpuFreqGovernor = "performance";
};
# PC/SC daemon for the HID Global OMNIKEY 5022 contactless reader
# (076b:5022, a CCID smart-card reader) used for Bolt Card tap-to-enter
# (ADR-003). pcscd binds the CCID driver; the app talks to pcscd's socket
# (via nfc-pcsc) rather than the USB device directly. Device-agnostic —
# same wiring as batm3's Feitian KP382; harmless if no reader is attached,
# pcscd just idles. Shared by every upboard machine (sintra, tejo).
services.pcscd.enable = true;
# pcscd gates client access via polkit; without a rule the sandboxed
# `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize
# it to talk to the daemon and the card.
security.polkit.extraConfig = ''
polkit.addRule(function(action, subject) {
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
action.id == "org.debian.pcsc-lite.access_card") &&
subject.user == "bitspire") {
return polkit.Result.YES;
}
});
'';
# Disable suspend/hibernate for kiosk
systemd.targets = {
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.
#
# Parameterized by machineModel (passed via specialArgs from flake.nix):

View file

@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Provision the access-control gate (ADR-003) to a deployed bitSpire ATM.
# Pushes an access.json to /var/lib/bitspire/ and restarts the service, so the
# gate can be toggled on a machine without an image rebuild (mirrors
# provision-branding.sh). Env defaults are overridden by whatever this file sets.
#
# Usage:
# bash provision-access.sh <access.json> # SSH to localhost:2222 (QEMU)
# bash provision-access.sh <access.json> 192.168.1.50 # a real ATM on the LAN
# bash provision-access.sh <access.json> 192.168.1.50 22 # custom SSH port
#
# access.json schema (all keys optional; omitted keys fall back to env/defaults):
# {
# "enabled": true, // master switch for the gate
# "openEnrollment": true, // prototype: admit any valid npub
# "devUnlock": true, // allow the on-screen dev/operator unlock
# "salt": "per-machine", // hashing salt (provision a real one for prod)
# "allowList": [ // authorized identities (hashed); empty in open mode
# { "idHash": "<hashId(hexpubkey,salt)>", "role": "user", "pinHash": "<hashPin(pin,salt)>" }
# ]
# }
#
# To DISABLE the gate again: push a file with {"enabled": false} (or delete
# /var/lib/bitspire/access.json on the machine) and restart.
set -euo pipefail
ACCESS_FILE="${1:-}"
ATM_HOST="${2:-localhost}"
ATM_SSH_PORT="${3:-2222}"
ATM_USER="bitspire"
REMOTE_FILE="/var/lib/bitspire/access.json"
if [ -z "$ACCESS_FILE" ]; then
echo "Usage: $0 <access.json> [host] [port]" >&2
echo " $0 ./access.json (QEMU on localhost:2222)" >&2
echo " $0 ./access.json 192.168.1.50 (real ATM)" >&2
exit 1
fi
if [ ! -f "$ACCESS_FILE" ]; then
echo "ERROR: access file not found: $ACCESS_FILE" >&2
exit 1
fi
# Fail fast on malformed JSON before touching the machine.
if command -v jq >/dev/null 2>&1; then
jq empty "$ACCESS_FILE" || { echo "ERROR: $ACCESS_FILE is not valid JSON" >&2; exit 1; }
fi
echo "=== Provisioning access gate to $ATM_HOST:$ATM_SSH_PORT ==="
echo "Local file : $ACCESS_FILE"
echo "Remote file: $REMOTE_FILE"
cat "$ACCESS_FILE"
echo ""
# Copy over SSH. --rsync-path=sudo because /var/lib/bitspire is owned by the
# bitspire service user, not the SSH user.
rsync -avz \
--rsync-path="sudo rsync" \
-e "ssh -o StrictHostKeyChecking=no -p $ATM_SSH_PORT" \
"$ACCESS_FILE" \
"$ATM_USER@$ATM_HOST:$REMOTE_FILE"
# Restart so loadAccessControl() re-reads the file.
ssh -o StrictHostKeyChecking=no -p "$ATM_SSH_PORT" "$ATM_USER@$ATM_HOST" \
"sudo systemctl restart bitspire"
echo ""
echo "=== Access gate provisioned. Service restarted. ==="

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
# ============================================

View file

@ -0,0 +1,201 @@
# ADR-003: NFC Access-Control Layer (badge-to-enter) + Developer Bypass
**Status:** Accepted
**Date:** 2026-07-29
**Context:** batm3 gaining a physical access layer — an NFC card must be presented to unlock the machine before anyone can transact. Reader hardware is not yet on hand; this ADR defines the direction and a non-breaking skeleton that is fully testable without it.
## Decision
1. **Add a top-level `locked` state to the ATM state machine, and make it the initial state.** It sits *below* the existing initialization gates (`unpaired` / `awaiting-fees` / `maintenance` / `signer-unreachable`, which live in `App.vue`). A healthy, paired machine boots into `locked` and only reveals `idle` (Buy/Sell) after an access grant.
2. **Access control is opt-in via runtime config (`accessControl.enabled`, default `false`).** When disabled, the machine behaves exactly as today (boots straight to `idle`). This makes the whole feature non-breaking for the current test unit and for production ATMs, and lets a half-built access layer never brick a working box. This is the single most important constraint on the design.
3. **The reader lives behind an `AccessReader` abstraction that mirrors the existing `PairingSource` seam.** First implementation is a `MockAccessReader` (dev button / hotkey), so the locked→idle→transaction path is exercisable today with zero hardware. Web NFC and serial-NFC implementations follow.
4. **Credential model is a discriminated union with an explicit upgrade path.** v1 = card UID matched against a hashed allow-list. v2 = challenge-response (card-held key signs a machine nonce), verified against an operator-authorized set. Ship v1; design the types so v2 is additive.
5. **Three-tier developer bypass**, following existing conventions: a build flag (`VITE_SKIP_ACCESS_GATE`), the config disable (`accessControl.enabled=false`), and a runtime operator/dev unlock gesture that dispatches a synthetic grant.
6. **Authorization is owned by the machine operator, not the SaaS operator** — consistent with [ADR-002](./002-remote-access-and-fleet-management.md). The card allow-list is authorized by the operator key (the [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) allow-list mechanism), local first, operator-synced later. When access control is *enabled* and the reader is absent/broken, the machine **fails closed** (with the operator/dev unlock as the escape hatch); when *disabled*, reader state is irrelevant.
7. **Every grant/deny is audited** to `state.db` (hashed credential + timestamp + role + outcome), with optional later publication as a Nostr event. No PII, consistent with the KYC-free principle.
## Context
### What this is (and what it is not)
This is the **end-user physical access plane**: a person must badge in to use the machine. It is distinct from the three planes in [ADR-002](./002-remote-access-and-fleet-management.md) — it is *not* the operator's SSH/NetBird recovery plane, and *not* the SaaS payment plane. It shares one idea with ADR-002: **the machine operator owns who is authorized**, expressed through the operator key / #42 allow-list.
### Why the codebase is well-shaped for this
Three seams already exist; we extend them rather than invent:
- **The idle→transaction transition is unguarded.** `packages/state-machine/src/machine.ts` starts at `initial: 'idle'` (~L431) and `idle` moves to `cashIn`/`cashOut` via plain `SELECT_CASH_IN` / `SELECT_CASH_OUT` transitions with no guards (~L457-464). Inserting a `locked` predecessor state is a localized change.
- **`PairingSource` is a reader abstraction designed to grow.** Its doc (`apps/machine/src/services/pairing/types.ts`) explicitly anticipates *"an NFC reader or a HAL barcode scanner… a HAL-scanner source can be added the same way without touching the wizard."* `AccessReader` mirrors it: `qr-source.ts` / `nfc-source.ts` → `mock-reader.ts` / `web-nfc-reader.ts` / `serial-reader.ts`.
- **Dev-flag and config conventions are established.** `import.meta.env.VITE_* === 'true'` (e.g. `VITE_MAINTENANCE_MODE`, `VITE_FORCE_MOCK`), plus Electron `get-config` fields that the renderer reads (`electron/main.ts` L280–312: `maintenanceMode`, `branding`). A new `accessControl` config field and a `VITE_SKIP_ACCESS_GATE` flag follow the same shape.
### Hardware reality check (important)
The batm3 already exposes an NFC device, but it is **serial**: `deploy/nixos/hardware/batm3.nix` L154 maps udev serial `A9ZF8ELY` → `/dev/ttyNFC`. The *existing* `pairing/nfc-source.ts` uses **Web NFC** (`NDEFReader`), which drives a phone/laptop NFC radio, **not** a serial reader. So the real batm3 access reader needs a **main-process serial driver** (HAL-style, per [ADR-001](./001-hal-architecture.md)) exposing card events to the renderer over IPC — the Web NFC path is only useful for laptop/phone dev. This ADR keeps that driver as a clearly-scoped later PR so the skeleton doesn't pretend the scaffold "just works" on the panel.
## Architecture
### Boot / render layering
```
Electron get-config ─┐
▼
App.vue init gates (unchanged):
unpaired? → PairingWizard
initError (maintenance / awaiting-fees / signer-unreachable)? → maintenance screen
else ▼
State machine (paired + healthy):
┌───────────────────────────────────────────────┐
│ locked ──ACCESS_GRANTED──▶ idle │ ← NEW initial state
│ ▲ │ SELECT_CASH_* │
│ │ re-lock (session end / ▼ │
│ │ inactivity / complete) cashIn / cashOut │
│ └──────────────────────────┘ │
└───────────────────────────────────────────────┘
(when accessControl.enabled === false,
`locked` immediately `always`-bypasses to `idle`)
```
The access gate is strictly below App.vue's init gates: a machine that is unpaired or in maintenance never reaches `locked`.
### 1. State machine (`packages/state-machine`)
- New top-level state `locked`, `initial: 'locked'`.
- New events on the machine's event union: `ACCESS_GRANTED` (carries an authorized `CardCredential` + resolved role), `ACCESS_DENIED` (carries a reason), `DEV_UNLOCK`.
- `locked` transitions:
- `always: [{ guard: 'accessBypass', target: 'idle' }]` — instant pass-through when disabled/bypassed (no UI flicker; the view is gated on the same predicate).
- `on: { ACCESS_GRANTED: { target: 'idle', actions: ['startSession', 'recordAccessGrant'] }, ACCESS_DENIED: { actions: 'recordAccessDeny' }, DEV_UNLOCK: { guard: 'devUnlockAllowed', target: 'idle', actions: 'startDevSession' } }`.
- Re-lock: the existing `complete` auto-return (currently 60s → `idle`) and the inactivity timeouts (`INACTIVITY_TIMEOUT`/`TIMEOUT_MS`, ~L422-429) target `locked` instead of `idle`. Because `locked` `always`-bypasses when disabled, this is one code path for both modes.
- Purity: the state-machine package must not read Vite env. `accessControl.enabled` and the bypass boolean are passed in as **actor input → context** (`context.accessControlEnabled`, `context.accessBypass`); guard `accessBypass` reads context only. New context fields: `accessControlEnabled`, `accessBypass`, `session` (`{ role, grantedAt, credentialIdHash } | null`).
- Guards: `accessBypass`, `devUnlockAllowed`. Actions: `startSession`, `startDevSession`, `recordAccessGrant`, `recordAccessDeny`, and `resetContext` extended to clear `session`.
### 2. Config plumbing
- `apps/machine/src/types/electron.d.ts` — extend `RuntimeConfig` (L5) with:
```ts
accessControl: {
enabled: boolean // default false
devUnlock: boolean // allow the runtime operator/dev unlock gesture
// v2: allowListSource, challengeRequired, …
}
```
- `apps/machine/electron/main.ts` — the `get-config` handler (L280) returns `accessControl`, sourced from env for now (`ACCESS_CONTROL_ENABLED === 'true'`, `VITE_SKIP_ACCESS_GATE` → forces `enabled:false`), later from a provisioned file under `/var/lib/bitspire/` alongside branding.
- `apps/machine/.env.example` — document `VITE_SKIP_ACCESS_GATE=true` (browser/dev straight to idle) and `ACCESS_CONTROL_ENABLED`.
### 3. Reader abstraction (`apps/machine/src/services/access/`)
Mirrors `services/pairing/`:
```
services/access/
types.ts # AccessReader, CardCredential (union), AccessRole, StopCapture
mock-reader.ts # PR1 — fires a card event on demand (dev button / hotkey)
web-nfc-reader.ts # PR3 — NDEFReader, dev on laptop/phone
serial-reader.ts # PR4 — /dev/ttyNFC via main-process HAL + IPC
authorize.ts # allow-list check + role resolution (hashed UID v1)
index.ts # availableAccessReaders(): AccessReader[]
__tests__/
```
```ts
export type AccessRole = 'user' | 'operator'
export type CardCredential =
| { kind: 'uid'; uidHash: string } // v1
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string } // v2 (seam)
export interface AccessReader {
readonly kind: 'mock' | 'nfc-web' | 'nfc-serial'
readonly label: string
isAvailable(): Promise<boolean>
start(opts: {
onCard: (cred: CardCredential) => void
onError?: (e: unknown) => void
}): Promise<StopCapture>
}
```
### 4. Renderer wiring
- `apps/machine/src/views/LockedView.vue` (new) — the badge-in screen. Shows brand/logo + "Present your card", a live reader status, and (when `accessControl.devUnlock`) a discreet operator/dev unlock affordance (hidden long-press corner, or a button on the existing debug bar).
- `apps/machine/src/App.vue` — add a `locked` render branch mirroring the `PairingWizard` branch (L179) and the `initError` branch (L183): `<LockedView v-else-if="atmStore.isLocked" />`, then the existing `<router-view>` only when unlocked. Keeps rendering state-driven and matches the current shape.
- `apps/machine/src/stores/atm.ts` —
- `createActor(machine, { input: { accessControlEnabled, accessBypass } })` at the existing `createActor(machine)` site (L452), seeded from `RuntimeConfig`.
- `isLocked` computed off the snapshot (peer of `isIdle`, ~L422).
- `grantAccess(cred, role)` / `denyAccess(reason)` / `devUnlock()` that `send({ type: 'ACCESS_GRANTED' | 'ACCESS_DENIED' | 'DEV_UNLOCK', … })` (peers of `selectCashIn` at L1382, using the existing `send` at L1378).
- On init, when `accessControl.enabled`, subscribe to `availableAccessReaders()[0]`; on `onCard`, run `authorize()` → `grantAccess`/`denyAccess`. When disabled, do nothing (machine `always`-bypasses).
### 5. Audit
- Add `recordAccessEvent({ credentialIdHash, role, outcome, at })` alongside the existing state.db handlers (`state:record-transaction` etc. in `electron/main.ts`, exposed via `preload.ts`). v1 writes locally; a later PR can mirror to a replaceable Nostr event.
### Session semantics (decided)
**One badge = one transaction-scoped session.** A grant unlocks `idle`, the user runs a single transaction (Buy or Sell), and the machine re-locks on `complete`, on inactivity, or on an explicit "Done". The `session` context field is deliberately shaped as a general access session (`{ role, grantedAt, credentialIdHash }`), not a transaction handle, because this terminal may later handle **non-transaction functions** — so "unlock the terminal" and "authorize a transaction" stay separate concepts.
**Step-up authorization (future seam, not in PR1).** The badge tap grants *terminal access*; a specific sensitive action can independently *request re-authorization* — e.g. "tap your phone" or "enter a PIN" — without conflating the two. This is why the credential model is a union and the machine carries a `session` rather than a boolean "unlocked": a later `REQUIRE_STEPUP` event can gate an individual action against a fresh credential/PIN while the terminal session stays open. PR1 ships only the entry gate; step-up is a documented extension.
## Alternatives considered
- **Gate between `idle` and the transaction** (idle visible, tap requires a card). Rejected: the requirement is "gain access to the exchange" — the whole machine should be locked, not just the transact button. A `locked` predecessor matches the mental model and gives a clean re-lock boundary.
- **Web NFC only** (reuse `nfc-source.ts` as-is). Rejected: the batm3 reader is serial (`ttyNFC`); Web NFC can't drive it. Web NFC stays a dev-only convenience.
- **Fail-open by default** (no card → allow). Rejected for an access-control feature; but note the *disabled* default sidesteps this — access is simply off until an operator turns it on, at which point it fails **closed**.
- **OS/kiosk-level lock** (lock the desktop, not the app). Rejected: too coarse, no per-transaction audit, no role model, and it fights the existing state-driven UI.
- **UID allow-list as the permanent model.** Rejected as an endpoint (UIDs clone trivially) but accepted as v1 behind a union type, so challenge-response is additive.
## Security considerations
- **UID cloning** — card UIDs are not secret and are cloneable; v1 is "better than nothing" and is explicitly labeled upgradeable. The v2 challenge-response path (card signs a machine nonce) is the real security boundary; design the credential union and the `authorize()` seam for it now.
- **Data at rest** — store only a salted hash of the credential id; never raw UIDs or any PII (KYC-free). Never log a card secret or nsec (repo security priority #1).
- **Fail-closed when enabled** — reader absent/broken + `enabled` ⇒ locked, escape hatch = operator/dev unlock. Reader problems on a *disabled* machine are inert.
- **Operator ownership** — authorization derives from the operator key / #42 allow-list, not the SaaS operator (ADR-002 boundary). Local allow-list first; operator-published (NIP-51-style) sync later.
- **Dev bypass blast radius** — `VITE_SKIP_ACCESS_GATE` is build-time and never set in a production image; `devUnlock` is gated by `accessControl.devUnlock` (off in a locked-down deployment) and every dev unlock is audited with role `operator`/`dev`.
## Resolved decisions (2026-07-29)
1. **Session model** — ✅ one badge = one **transaction-scoped session**, with the `session` context modeled generally (terminal access, not a transaction handle) to allow non-transaction functions and per-action **step-up auth** (tap phone / PIN) later. See *Session semantics* above.
2. **v1 credential** — ✅ **UID allow-list first** (hashed), behind a `CardCredential` union so challenge-response is additive (PR5).
3. **Allow-list home** — ✅ **local first** (`state.db` / provisioned `access.json`, peer of `branding/`); operator-Nostr sync is a later PR.
Still open (cosmetic, decide during PR2):
4. **Dev unlock affordance** — hidden long-press corner vs a labeled button on the existing debug bar.
---
## Implementation plan (phased PRs)
Each PR is independently mergeable. **PR1 changes nothing observable while `accessControl.enabled=false` (the default).**
### PR1 — Non-breaking skeleton (state + config + mock reader + dev bypass + audit stub)
**Goal:** the locked→idle→transaction path is exercisable on the batm3 today, and the flag-off machine is byte-for-byte behavior-identical.
- `packages/state-machine`: add `locked` state (`initial`), `ACCESS_GRANTED`/`ACCESS_DENIED`/`DEV_UNLOCK` events, `accessBypass`/`devUnlockAllowed` guards, `startSession`/`recordAccess*` actions, context fields + actor `input`. Re-point `complete`/inactivity re-lock targets to `locked`.
- `apps/machine/src/types/electron.d.ts`: `RuntimeConfig.accessControl`.
- `apps/machine/electron/main.ts`: `get-config` returns `accessControl` (env-sourced); `.env.example` documents `VITE_SKIP_ACCESS_GATE` + `ACCESS_CONTROL_ENABLED`.
- `apps/machine/src/services/access/`: `types.ts`, `mock-reader.ts`, `authorize.ts` (UID allow-list, hashed), `index.ts`.
- `apps/machine/src/stores/atm.ts`: actor `input`, `isLocked`, `grantAccess`/`denyAccess`/`devUnlock`, reader subscription (only when enabled).
- `apps/machine/src/views/LockedView.vue` + `App.vue` `locked` render branch.
- Audit stub: `recordAccessEvent` handler + preload exposure (local write only).
- **Tests:** state-machine `locked → idle` on grant; `always`-bypass when disabled; re-lock from `complete`; `authorize()` allow/deny; `devUnlock` gated by config.
- **Flag state on merge:** `enabled=false`. CI green = no behavior change.
### PR2 — Locked UX polish
- Reader status/animation, brand-aware LockedView, denied-flash + reason, inactivity copy, operator/dev unlock affordance per open-question #4. Pure renderer.
### PR3 — Web NFC reader (dev)
- `web-nfc-reader.ts` (`NDEFReader`), registered in `availableAccessReaders()` behind availability check. Lets a laptop/phone drive the gate for demos/dev. No hardware dependency.
### PR4 — Serial NFC HAL driver (real batm3 hardware)
- Main-process serial driver for `/dev/ttyNFC` (HAL-style per ADR-001), IPC channel `access:watch-card` + `preload.ts` exposure; `serial-reader.ts` renderer client. Requires the physical reader to validate. Document the reader's protocol/baud in `docs/device-configuration.md`.
### PR5 — Challenge-response credential + operator allow-list sync
- Extend `CardCredential` with the `challenge` variant; `authorize.ts` verifies a signature over a machine nonce against the operator-authorized set; allow-list synced from an operator-published event (#42 mechanism). This is the real security upgrade; v1 UID path stays as a fallback/dev mode.
### Cross-cutting
- **Docs:** update `docs/machine-installation.md` (enabling access control, enrolling cards) and `deploy/nixos/README.md` (the `accessControl` config + `/dev/ttyNFC`) as PR4/PR5 land.
- **Provisioning:** a later change can add an `access.json` under `/var/lib/bitspire/` (peer of `branding/`) with the allow-list + `enabled`, plus a `provision-access.sh` mirroring `provision-branding.sh`.

View file

@ -1,5 +1,5 @@
{
description = "Lamassu Next - Nostr-Native Lightning ATM";
description = "bitSpire - Nostr-Native Lightning ATM";
inputs = {
# 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,
# eliminating the need for --impure or a local pnpm install.

View file

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

View file

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

View file

@ -1,7 +1,7 @@
/**
* @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 dispensers (Fujitsu F53/F56)
*

View file

@ -37,6 +37,7 @@ import type {
CreateInvoiceBody,
PayInvoiceBody,
WalletInfo,
CreatedWallet,
MachineConfigResponse,
SubscribePaymentsBody,
SubscribeAck,
@ -211,6 +212,17 @@ export class LnbitsClient {
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
* the authenticated transport — spirekeeper's `get_machine_config` RPC
* (bitspire#70 P1). Lets a seed-only ATM configure itself with no per-machine

View file

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

View file

@ -112,6 +112,15 @@ export interface WalletInfo {
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
// ============================================================================

View file

@ -1,7 +1,7 @@
{
"name": "@bitSpire/nostr-client",
"version": "0.1.0",
"description": "Nostr client library for Lamassu ATM",
"description": "Nostr client library for bitSpire ATM",
"type": "module",
"main": "./dist/index.js",
"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:
* - 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'

View file

@ -1,7 +1,7 @@
/**
* @bitSpire/nostr-client
*
* Nostr client library for Lamassu ATM communication.
* Nostr client library for bitSpire ATM communication.
*
* Features:
* - 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'

View file

@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest'
import { createActor } from 'xstate'
import { createATMMachine } from '../machine.js'
// ADR-003 access gate. Key invariant: with the gate DISABLED (the default),
// the machine is behaviourally identical to the pre-access machine — it
// settles into `idle` on start via the `locked` state's `always` bypass.
describe('ATM access control (ADR-003)', () => {
describe('gate disabled (default)', () => {
it('settles into idle on start (non-breaking)', () => {
const actor = createActor(createATMMachine())
actor.start()
expect(actor.getSnapshot().value).toBe('idle')
})
it('settles into idle even with accessControlEnabled:false explicit', () => {
const actor = createActor(createATMMachine({}, { accessControlEnabled: false }))
actor.start()
expect(actor.getSnapshot().value).toBe('idle')
})
})
describe('gate enabled', () => {
it('stays locked on start', () => {
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
actor.start()
expect(actor.getSnapshot().value).toBe('locked')
expect(actor.getSnapshot().context.accessSession).toBeNull()
})
it('ACCESS_GRANTED unlocks to idle and records the session', () => {
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
actor.start()
actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc123' })
const snap = actor.getSnapshot()
expect(snap.value).toBe('idle')
expect(snap.context.accessSession).toMatchObject({ role: 'user', credentialIdHash: 'abc123' })
expect(typeof snap.context.accessSession?.grantedAt).toBe('number')
})
it('ACCESS_DENIED stays locked and surfaces the reason', () => {
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
actor.start()
actor.send({ type: 'ACCESS_DENIED', reason: 'card not authorized' })
const snap = actor.getSnapshot()
expect(snap.value).toBe('locked')
expect(snap.context.accessDenyReason).toBe('card not authorized')
})
it('DEV_UNLOCK unlocks to idle (operator session)', () => {
const actor = createATMMachine({}, { accessControlEnabled: true })
const running = createActor(actor)
running.start()
running.send({ type: 'DEV_UNLOCK' })
const snap = running.getSnapshot()
expect(snap.value).toBe('idle')
expect(snap.context.accessSession?.role).toBe('operator')
})
})
// NOTE: idle inactivity re-lock is no longer an XState `after` delay — an
// entry-anchored timer can't measure inactivity (it never resets on screen
// touches). It's enforced at the DOM layer (useSessionSecurity), which sends
// END_SESSION on true idleness / at the hard cap. The machine's contract is
// just: END_SESSION re-locks from any unlocked state when the gate is active.
describe('session end (button / inactivity / hard cap all route here)', () => {
it('END_SESSION re-locks immediately from idle when the gate is active', () => {
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
actor.start()
actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc' })
expect(actor.getSnapshot().value).toBe('idle')
actor.send({ type: 'END_SESSION' })
expect(actor.getSnapshot().value).toBe('locked')
})
it('END_SESSION re-locks from an in-flight cash-out (hard-cap path)', () => {
// The absolute session cap must be able to lock mid-transaction, so the
// transition lives at the machine root, not only on `idle`.
const rateServices = {
getExchangeRate: () => Promise.resolve(2500),
getAvailableBalance: () => Promise.resolve(1_000_000),
}
const actor = createActor(createATMMachine(rateServices, { accessControlEnabled: true }))
actor.start()
actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc' })
actor.send({ type: 'SELECT_CASH_OUT' })
expect(actor.getSnapshot().value).not.toBe('locked') // now inside cashOut
actor.send({ type: 'END_SESSION' })
expect(actor.getSnapshot().value).toBe('locked')
})
it('END_SESSION is a no-op when the gate is disabled (stays at idle)', () => {
const actor = createActor(createATMMachine()) // gate off → rests at idle
actor.start()
expect(actor.getSnapshot().value).toBe('idle')
actor.send({ type: 'END_SESSION' })
expect(actor.getSnapshot().value).toBe('idle')
})
})
describe('build/dev bypass', () => {
it('accessBypassFlag opens the gate even when enabled', () => {
const actor = createActor(
createATMMachine({}, { accessControlEnabled: true, accessBypassFlag: true })
)
actor.start()
expect(actor.getSnapshot().value).toBe('idle')
})
it('DEV_UNLOCK is a no-op while bypassing (already idle)', () => {
const actor = createActor(
createATMMachine({}, { accessControlEnabled: true, accessBypassFlag: true })
)
actor.start()
// already idle; DEV_UNLOCK guard is false, so no throw / no change
actor.send({ type: 'DEV_UNLOCK' })
expect(actor.getSnapshot().value).toBe('idle')
})
})
})

View file

@ -52,6 +52,8 @@ export {
type PaymentMethod,
type DispenseCashResult,
type CassetteBillResult,
type AccessRole,
type AccessSession,
initialContext,
} from './types.js'

View file

@ -22,6 +22,14 @@ export interface ATMMachineOptions {
currency?: string
cashInFeeFraction?: number
cashOutFeeFraction?: number
/**
* ADR-003 access gate. When true, the machine boots into `locked` and
* waits for an ACCESS_GRANTED (or DEV_UNLOCK) before reaching `idle`.
* Defaults false → `locked` immediately bypasses to `idle` (no gate).
*/
accessControlEnabled?: boolean
/** Build/dev bypass (VITE_SKIP_ACCESS_GATE) — opens the gate even when enabled. */
accessBypassFlag?: boolean
}
export function createATMMachine(
@ -167,9 +175,41 @@ export function createATMMachine(
inventory: context.inventory,
cashInFeeFraction: context.cashInFeeFraction,
cashOutFeeFraction: context.cashOutFeeFraction,
// Preserve the access-gate config across resets — it comes from
// machine options, not the transaction, and must survive re-lock.
accessControlEnabled: context.accessControlEnabled,
accessBypassFlag: context.accessBypassFlag,
// Preserve the active access session: `idle`'s entry runs resetContext
// AFTER the locked→idle transition action that set the session, so
// without this the just-granted session would be wiped. The session is
// cleared instead on re-lock (locked's entry), i.e. when access ends.
accessSession: context.accessSession,
cashInSessionId: null,
dispenseResult: null,
})),
// ADR-003 access-control actions
startAccessSession: assign({
accessSession: ({ event }) => {
if (event.type !== 'ACCESS_GRANTED') return null
return { role: event.role, grantedAt: Date.now(), credentialIdHash: event.credentialIdHash }
},
accessDenyReason: null,
}),
startDevAccessSession: assign({
accessSession: () => ({
role: 'operator' as const,
grantedAt: Date.now(),
credentialIdHash: 'dev-unlock',
}),
accessDenyReason: null,
}),
setAccessDenyReason: assign({
accessDenyReason: ({ event }) => (event.type === 'ACCESS_DENIED' ? event.reason : null),
}),
clearAccessDenyReason: assign({ accessDenyReason: null }),
// On (re-)entering `locked`, access has ended: drop any prior session so a
// stale grant can't leak across the gate.
clearAccessSession: assign({ accessSession: null }),
setStartTime: assign({
startedAt: () => Date.now(),
txid: () => generateTxId(),
@ -376,6 +416,18 @@ export function createATMMachine(
}),
},
guards: {
// ADR-003: gate is open when access control is off, or the build/dev
// bypass is set. Used by `locked`'s eventless `always` transition so a
// machine with the gate disabled settles straight into `idle`.
accessBypass: ({ context }) => !context.accessControlEnabled || context.accessBypassFlag,
// The dev unlock is only meaningful when the gate is actually engaged.
devUnlockAllowed: ({ context }) => context.accessControlEnabled && !context.accessBypassFlag,
// Gate is actively engaged (enabled + not bypassed) — used to auto re-lock
// the `idle` menu on inactivity so an unattended unlocked session (a tapped
// card left behind) can't be used by the next person. Same condition as
// devUnlockAllowed; named for the lock-timeout intent.
accessGateActive: ({ context }) =>
context.accessControlEnabled && !context.accessBypassFlag,
hasInsertedBills: ({ context }) => context.billsInserted.length > 0,
// Legacy brain.js parity: "send coins" is a no-op while a bill is
// between the stack command and the validator's stacked-confirmation.
@ -426,10 +478,16 @@ export function createATMMachine(
COMPLETE_DELAY: 60000,
DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond
DISPENSE_ERROR_TIMEOUT: 30000, // 30s like brain.js _timedState
// NOTE: idle inactivity re-lock + hard session cap are enforced at the
// DOM layer (useSessionSecurity), not as XState `after` delays — see the
// idle state comment. No IDLE_LOCK_TIMEOUT delay here by design.
},
}).createMachine({
id: 'atm',
initial: 'idle',
// ADR-003: `locked` is the resting state. With the gate disabled (the
// default), its `always` transition bypasses straight to `idle` on start,
// so behavior is identical to the pre-access machine.
initial: 'locked',
context: {
...initialContext,
...(options?.currency ? { currency: options.currency } : {}),
@ -437,6 +495,12 @@ export function createATMMachine(
...(options?.cashOutFeeFraction !== undefined
? { cashOutFeeFraction: options.cashOutFeeFraction }
: {}),
...(options?.accessControlEnabled !== undefined
? { accessControlEnabled: options.accessControlEnabled }
: {}),
...(options?.accessBypassFlag !== undefined
? { accessBypassFlag: options.accessBypassFlag }
: {}),
},
// Root-level handler: lets the operator-fees subscriber update the
// active fee fractions reactively. Next cashIn/cashOut entry will
@ -449,10 +513,41 @@ export function createATMMachine(
cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction,
}),
},
// Root-level session kill switch (ADR-003). Any unlocked state re-locks
// on END_SESSION — the "End session" button, the idle-inactivity timer,
// and the absolute session cap all route here (see useSessionSecurity).
// Placed at the root so the hard cap can lock mid cash-in/out, not just
// from idle. Guarded to the active gate so a gate-disabled machine (which
// rests at idle) can't be knocked out of it. `locked`'s entry clears the
// access session + loaded card. Fail-closed: locking is always allowed.
END_SESSION: { guard: 'accessGateActive', target: '#atm.locked' },
},
states: {
// === ACCESS GATE (ADR-003) ===
// Resting/locked state. A paired, healthy machine sits here until a
// valid credential is presented. When the gate is disabled (default)
// the eventless `always` transition immediately hands off to `idle`,
// so a non-access machine never dwells here.
locked: {
entry: ['clearAccessDenyReason', 'clearAccessSession'],
always: [{ guard: 'accessBypass', target: 'idle' }],
on: {
ACCESS_GRANTED: { target: 'idle', actions: 'startAccessSession' },
DEV_UNLOCK: { guard: 'devUnlockAllowed', target: 'idle', actions: 'startDevAccessSession' },
// A denied tap keeps us locked; record the reason for the screen.
ACCESS_DENIED: { actions: 'setAccessDenyReason' },
},
},
idle: {
entry: 'resetContext',
// Idle inactivity re-lock is NOT modeled here as an XState `after`:
// that timer is anchored to state ENTRY and never resets on screen
// touches (the machine can't see raw pointer events), so it would fire
// a fixed countdown regardless of activity. Inactivity is measured at
// the DOM layer (useSessionSecurity) and drives END_SESSION on true
// idleness. The hard session cap is handled the same way. Both re-lock
// via the root-level END_SESSION transition above.
on: {
SELECT_CASH_IN: {
target: 'cashIn',
@ -491,7 +586,7 @@ export function createATMMachine(
INACTIVITY_TIMEOUT: [
{
guard: ({ context }) => context.billsInserted.length === 0,
target: '#atm.idle',
target: '#atm.locked',
},
{
target: 'confirmAbandon',
@ -524,7 +619,7 @@ export function createATMMachine(
{
// No bills inserted yet: safe to cancel
guard: ({ context }) => context.billsInserted.length === 0,
target: '#atm.idle',
target: '#atm.locked',
},
{
// Bills already stacked: warn user before abandoning
@ -534,7 +629,7 @@ export function createATMMachine(
TIMEOUT: [
{
guard: ({ context }) => context.billsInserted.length === 0,
target: '#atm.idle',
target: '#atm.locked',
},
{
target: 'confirmAbandon',
@ -586,10 +681,10 @@ export function createATMMachine(
// like the rest — clear the marker so nothing blocks on it.
entry: 'clearBillPending',
after: {
60000: '#atm.idle',
60000: '#atm.locked',
},
on: {
CANCEL: '#atm.idle', // User confirms they want to leave
CANCEL: '#atm.locked', // User confirms they want to leave
RETRY: 'generatingNdebit', // Go back and try again
},
},
@ -614,10 +709,10 @@ export function createATMMachine(
},
complete: {
after: {
COMPLETE_DELAY: '#atm.idle',
COMPLETE_DELAY: '#atm.locked',
},
on: {
CANCEL: '#atm.idle',
CANCEL: '#atm.locked',
},
},
error: {
@ -646,7 +741,7 @@ export function createATMMachine(
{
// No bills inserted: safe to cancel
guard: ({ context }) => context.billsInserted.length === 0,
target: '#atm.idle',
target: '#atm.locked',
},
{
// Bills inserted: show abandon warning first
@ -689,7 +784,7 @@ export function createATMMachine(
// User selects denomination buttons to build up the cash amount
// UI shows: available denominations, running total, sats equivalent
after: {
INACTIVITY_TIMEOUT: '#atm.idle',
INACTIVITY_TIMEOUT: '#atm.locked',
},
entry: 'clearCashOutSelection',
on: {
@ -709,8 +804,8 @@ export function createATMMachine(
target: 'generatingInvoice',
actions: 'calculateDispenseFromSelection',
},
CANCEL: '#atm.idle',
TIMEOUT: '#atm.idle',
CANCEL: '#atm.locked',
TIMEOUT: '#atm.locked',
},
},
generatingInvoice: {
@ -758,7 +853,7 @@ export function createATMMachine(
TIMEOUT: {
target: 'selectingAmount',
},
CANCEL: '#atm.idle',
CANCEL: '#atm.locked',
},
},
dispensingCash: {
@ -826,20 +921,20 @@ export function createATMMachine(
},
complete: {
after: {
COMPLETE_DELAY: '#atm.idle',
COMPLETE_DELAY: '#atm.locked',
},
on: {
CANCEL: '#atm.idle',
CANCEL: '#atm.locked',
},
},
dispenseError: {
// Payment received but cash not (fully) dispensed.
// Show error + txid for 30s, then auto-idle (matches brain.js _timedState).
after: {
DISPENSE_ERROR_TIMEOUT: '#atm.idle',
DISPENSE_ERROR_TIMEOUT: '#atm.locked',
},
on: {
CANCEL: '#atm.idle',
CANCEL: '#atm.locked',
},
},
error: {
@ -849,7 +944,7 @@ export function createATMMachine(
target: 'fetchingRate',
actions: 'incrementRetry',
},
CANCEL: '#atm.idle',
CANCEL: '#atm.locked',
},
},
},

View file

@ -48,8 +48,47 @@ export interface OfferRequestEvent {
description?: string
}
/**
* Access-control role resolved from a presented credential.
* `user` may transact; `operator` may additionally reach operator
* functions (config/maintenance/enrollment) — reserved for later PRs.
* See ADR-003.
*/
export type AccessRole = 'user' | 'operator'
/**
* An active access session, created when a valid credential is presented
* (or via the dev unlock). Modeled as general *terminal access*, not a
* transaction handle, so the terminal can later gate non-transaction
* functions and per-action step-up auth (ADR-003).
*/
export interface AccessSession {
role: AccessRole
/** ms epoch when access was granted */
grantedAt: number
/** salted hash of the presented credential id — never the raw UID (KYC-free) */
credentialIdHash: string
}
/** ATM machine context */
export interface ATMContext {
// Access control (ADR-003)
/**
* Whether the badge-to-enter access gate is active. When false (the
* default), the machine's `locked` initial state immediately bypasses
* to `idle` — behavior is identical to a machine with no access layer.
*/
accessControlEnabled: boolean
/**
* Build/dev bypass (VITE_SKIP_ACCESS_GATE). Forces the gate open even
* when accessControlEnabled is true — for browser dev / CI.
*/
accessBypassFlag: boolean
/** Active access session, or null while locked. */
accessSession: AccessSession | null
/** Reason for the last denied access attempt (for the locked screen). */
accessDenyReason: string | null
// Transaction details
/** Fiat amount in cents */
fiatCents: number
@ -136,6 +175,14 @@ export type ATMEvent =
| { type: 'SELECT_CASH_IN' }
| { type: 'SELECT_CASH_OUT' }
| { type: 'CANCEL' }
// Access control (ADR-003)
| { type: 'ACCESS_GRANTED'; role: AccessRole; credentialIdHash: string }
| { type: 'ACCESS_DENIED'; reason: string }
| { type: 'DEV_UNLOCK' }
// User-initiated end of a tap-in session: re-lock immediately instead of
// waiting out IDLE_LOCK_TIMEOUT, so a loaded Bolt Card can't be reused by
// the next person the moment its holder steps away.
| { type: 'END_SESSION' }
| { type: 'SELECT_AMOUNT'; amount: number }
| { type: 'FINISH_INSERTING' }
| { type: 'USER_SCANNED_NPUB'; npub: string }
@ -173,6 +220,12 @@ export type ATMEvent =
/** Initial context values */
export const initialContext: ATMContext = {
// Access control defaults OFF — a machine built without the access
// options behaves exactly as before (locked → bypass → idle). See ADR-003.
accessControlEnabled: false,
accessBypassFlag: false,
accessSession: null,
accessDenyReason: null,
fiatCents: 0,
satsAmount: 0,
currency: 'USD',

View file

@ -1,7 +1,7 @@
{
"name": "@bitSpire/ui-shared",
"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",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",

View file

@ -1,7 +1,7 @@
/**
* @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:
* - QR code display
* - Number pad

View file

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