From 4f0e284a96f90f53e86b191133586a7d89e007e9 Mon Sep 17 00:00:00 2001
From: Patrick Mulligan
Date: Thu, 6 Aug 2026 22:37:09 +0200
Subject: [PATCH] =?UTF-8?q?feat(access):=20Bolt=20Card=20tap-to-enter=20?=
=?UTF-8?q?=E2=80=94=20load=20card=20into=20session,=20one-press=20Complet?=
=?UTF-8?q?e?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../access/__tests__/authorize.test.ts | 65 +++-
apps/machine/src/services/access/authorize.ts | 11 +-
apps/machine/src/services/access/boltcard.ts | 27 ++
apps/machine/src/services/access/index.ts | 1 +
apps/machine/src/services/access/types.ts | 6 +
apps/machine/src/stores/atm.ts | 84 ++++-
apps/machine/src/views/CashInView.vue | 18 +
apps/machine/src/views/CashOutView.vue | 18 +
apps/machine/src/views/LockedView.vue | 343 ++++--------------
9 files changed, 286 insertions(+), 287 deletions(-)
create mode 100644 apps/machine/src/services/access/boltcard.ts
diff --git a/apps/machine/src/services/access/__tests__/authorize.test.ts b/apps/machine/src/services/access/__tests__/authorize.test.ts
index 0f175af..521cfcb 100644
--- a/apps/machine/src/services/access/__tests__/authorize.test.ts
+++ b/apps/machine/src/services/access/__tests__/authorize.test.ts
@@ -1,6 +1,7 @@
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)
@@ -79,7 +80,9 @@ describe('access authorize (ADR-003)', () => {
})
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 })
+ const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
+ salt: SALT,
+ })
expect(out.status).toBe('pin-required')
})
@@ -102,12 +105,62 @@ describe('access authorize (ADR-003)', () => {
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 }
- )
+ 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()
+ })
})
diff --git a/apps/machine/src/services/access/authorize.ts b/apps/machine/src/services/access/authorize.ts
index a6c99be..86aac4d 100644
--- a/apps/machine/src/services/access/authorize.ts
+++ b/apps/machine/src/services/access/authorize.ts
@@ -55,7 +55,8 @@ async function sha256Hex(input: string): Promise {
}
export const hashId = (id: string, salt: string): Promise => sha256Hex(`id:${salt}:${id}`)
-export const hashPin = (pin: string, salt: string): Promise => sha256Hex(`pin:${salt}:${pin}`)
+export const hashPin = (pin: string, salt: string): Promise =>
+ sha256Hex(`pin:${salt}:${pin}`)
/**
* Resolve a scan to a canonical identity string, or `null` if malformed.
@@ -64,6 +65,7 @@ export const hashPin = (pin: string, salt: string): Promise => sha256Hex
*/
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).
@@ -108,7 +110,12 @@ export async function authorize(
return {
status: 'denied',
credentialIdHash: '',
- reason: scan.kind === 'npub' ? 'not a valid npub' : 'invalid credential',
+ reason:
+ scan.kind === 'npub'
+ ? 'not a valid npub'
+ : scan.kind === 'boltcard'
+ ? 'not a valid card'
+ : 'invalid credential',
}
}
diff --git a/apps/machine/src/services/access/boltcard.ts b/apps/machine/src/services/access/boltcard.ts
new file mode 100644
index 0000000..1160f14
--- /dev/null
+++ b/apps/machine/src/services/access/boltcard.ts
@@ -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/?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/
+ const m = u.pathname.match(/\/scan\/([^/?#]+)/)
+ if (!m || !m[1]) return null
+ return { externalId: decodeURIComponent(m[1]) }
+ } catch {
+ return null
+ }
+}
diff --git a/apps/machine/src/services/access/index.ts b/apps/machine/src/services/access/index.ts
index fb1e26c..a3ed586 100644
--- a/apps/machine/src/services/access/index.ts
+++ b/apps/machine/src/services/access/index.ts
@@ -23,6 +23,7 @@ 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[] {
diff --git a/apps/machine/src/services/access/types.ts b/apps/machine/src/services/access/types.ts
index b7719f7..d6ecec3 100644
--- a/apps/machine/src/services/access/types.ts
+++ b/apps/machine/src/services/access/types.ts
@@ -32,6 +32,12 @@ export type AccessReaderKind = 'qr-npub' | 'mock' | 'nfc-web' | 'nfc-serial'
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 {
diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts
index 28eb75b..a65f900 100644
--- a/apps/machine/src/stores/atm.ts
+++ b/apps/machine/src/stores/atm.ts
@@ -27,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
@@ -305,6 +306,11 @@ export const useAtmStore = defineStore('atm', () => {
})
// 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:` envelope from satmachineadmin)
@@ -494,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)
@@ -671,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) {
@@ -704,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.
*/
@@ -1716,6 +1788,10 @@ 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,
diff --git a/apps/machine/src/views/CashInView.vue b/apps/machine/src/views/CashInView.vue
index 915c113..b75a2a4 100644
--- a/apps/machine/src/views/CashInView.vue
+++ b/apps/machine/src/views/CashInView.vue
@@ -363,6 +363,24 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
+
+
+
+ Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }}
+
+
+
+
diff --git a/apps/machine/src/views/CashOutView.vue b/apps/machine/src/views/CashOutView.vue
index 6257882..5a037c3 100644
--- a/apps/machine/src/views/CashOutView.vue
+++ b/apps/machine/src/views/CashOutView.vue
@@ -328,6 +328,24 @@ function formatFiat(cents: number): string {
+
+
+
+ Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }}
+
+
+
+
diff --git a/apps/machine/src/views/LockedView.vue b/apps/machine/src/views/LockedView.vue
index 90c4ac0..86115e9 100644
--- a/apps/machine/src/views/LockedView.vue
+++ b/apps/machine/src/views/LockedView.vue
@@ -1,316 +1,109 @@
-
![]()
+
{{ title }}
-
-
-
Enter your PIN
-
- {{ '•'.repeat(pinEntry.length) || '—' }}
+
+
+
+
-
-
-
-
-
-
-
+
+
+ {{ reading ? 'Reading card…' : 'Tap your Bolt Card to begin' }}
+
+
+
+
+ {{ denyReason }}
+
+
+ {{ nfc.message }}
+
+
+ Hold your card flat against the reader
+
-
-
-
-
-
-
-
-
-
-
-
-
-
- Scan to enter
-
- Present your access QR to the camera
-
-
- {{ denyReason }}
-
-
- {{ statusMessage }}
-
-
-
-
-
+
+
+
+
+
-
-
+