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.
This commit is contained in:
parent
a7b409b109
commit
c7e312a63f
9 changed files with 283 additions and 284 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { npubEncode, nprofileEncode } from 'nostr-tools/nip19'
|
import { npubEncode, nprofileEncode } from 'nostr-tools/nip19'
|
||||||
import { authorize, hashId, hashPin, type AllowListEntry } from '../authorize'
|
import { authorize, hashId, hashPin, type AllowListEntry } from '../authorize'
|
||||||
|
import { parseBoltcardLnurlw } from '../boltcard'
|
||||||
|
|
||||||
const SALT = 'test-salt'
|
const SALT = 'test-salt'
|
||||||
const HEX_A = 'aa'.repeat(32)
|
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 () => {
|
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')
|
expect(out.status).toBe('pin-required')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -102,12 +105,62 @@ describe('access authorize (ADR-003)', () => {
|
||||||
|
|
||||||
describe('challenge credential (v2 seam)', () => {
|
describe('challenge credential (v2 seam)', () => {
|
||||||
it('is not yet authorized', async () => {
|
it('is not yet authorized', async () => {
|
||||||
const out = await authorize(
|
const out = await authorize({ kind: 'challenge', pubkey: HEX_A, nonce: 'n', sig: 's' }, [], {
|
||||||
{ kind: 'challenge', pubkey: HEX_A, nonce: 'n', sig: 's' },
|
salt: SALT,
|
||||||
[],
|
openEnrollment: true,
|
||||||
{ salt: SALT, openEnrollment: true }
|
})
|
||||||
)
|
|
||||||
expect(out.status).toBe('denied')
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,8 @@ async function sha256Hex(input: string): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const hashId = (id: string, salt: string): Promise<string> => sha256Hex(`id:${salt}:${id}`)
|
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}`)
|
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.
|
* Resolve a scan to a canonical identity string, or `null` if malformed.
|
||||||
|
|
@ -64,6 +65,7 @@ export const hashPin = (pin: string, salt: string): Promise<string> => sha256Hex
|
||||||
*/
|
*/
|
||||||
function canonicalId(scan: AccessScan): string | null {
|
function canonicalId(scan: AccessScan): string | null {
|
||||||
if (scan.kind === 'uid') return scan.uid || 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
|
if (scan.kind === 'challenge') return null // v2 — handled separately
|
||||||
// Tolerate real-world nostr QR shapes: a bare `npub1…`, a `nostr:` URI
|
// Tolerate real-world nostr QR shapes: a bare `npub1…`, a `nostr:` URI
|
||||||
// prefix, and `nprofile1…` (npub + relay hints, what many clients export).
|
// prefix, and `nprofile1…` (npub + relay hints, what many clients export).
|
||||||
|
|
@ -108,7 +110,12 @@ export async function authorize(
|
||||||
return {
|
return {
|
||||||
status: 'denied',
|
status: 'denied',
|
||||||
credentialIdHash: '',
|
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',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
27
apps/machine/src/services/access/boltcard.ts
Normal file
27
apps/machine/src/services/access/boltcard.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ export { QrNpubAccessReader } from './qr-npub-reader'
|
||||||
export { MockAccessReader, MOCK_NPUB } from './mock-reader'
|
export { MockAccessReader, MOCK_NPUB } from './mock-reader'
|
||||||
export { authorize, hashId, hashPin } from './authorize'
|
export { authorize, hashId, hashPin } from './authorize'
|
||||||
export type { AllowListEntry, AuthorizeOptions, AuthorizeOutcome } from './authorize'
|
export type { AllowListEntry, AuthorizeOptions, AuthorizeOutcome } from './authorize'
|
||||||
|
export { parseBoltcardLnurlw } from './boltcard'
|
||||||
|
|
||||||
/** All readers in preference order, regardless of availability. */
|
/** All readers in preference order, regardless of availability. */
|
||||||
export function allAccessReaders(): AccessReader[] {
|
export function allAccessReaders(): AccessReader[] {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,12 @@ export type AccessReaderKind = 'qr-npub' | 'mock' | 'nfc-web' | 'nfc-serial'
|
||||||
export type AccessScan =
|
export type AccessScan =
|
||||||
| { kind: 'npub'; npub: string }
|
| { kind: 'npub'; npub: string }
|
||||||
| { kind: 'uid'; uid: 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 }
|
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string }
|
||||||
|
|
||||||
export interface AccessReaderStartOptions {
|
export interface AccessReaderStartOptions {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
} from '@bitSpire/clink'
|
} from '@bitSpire/clink'
|
||||||
import type { TransactionRecord } from '@/types/state'
|
import type { TransactionRecord } from '@/types/state'
|
||||||
import { useAvailabilityBroadcast } from '@/composables/useAvailabilityBroadcast'
|
import { useAvailabilityBroadcast } from '@/composables/useAvailabilityBroadcast'
|
||||||
|
import { authorize, parseBoltcardLnurlw, type AccessScan } from '@/services/access'
|
||||||
|
|
||||||
// Check if we're running in Electron
|
// Check if we're running in Electron
|
||||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
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).
|
// Build/dev bypass — opens the gate even when enabled (browser dev / CI).
|
||||||
const accessBypassFlag = import.meta.env.VITE_SKIP_ACCESS_GATE === 'true'
|
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')
|
const fiatCode = ref('USD')
|
||||||
// Defaults are 0 — the operator's fee config (received via Nostr
|
// Defaults are 0 — the operator's fee config (received via Nostr
|
||||||
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
||||||
|
|
@ -494,6 +500,12 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
lnurlCleanupFn()
|
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
|
// Detect network from first invoice we see
|
||||||
if (newSnapshot.context.invoice) {
|
if (newSnapshot.context.invoice) {
|
||||||
detectNetworkFromInvoice(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). */
|
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
|
||||||
function setupNfcListener() {
|
function setupNfcListener() {
|
||||||
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
|
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
|
||||||
window.electronAPI.onNfcCardTapped((lnurlw) => {
|
window.electronAPI.onNfcCardTapped((lnurlw) => {
|
||||||
// Route the same physical tap by flow: cash-out pulls, cash-in receives.
|
// Route the tap by state: locked → enter + load the card; then cash-out
|
||||||
if (isCashOut.value && nestedState.value === 'displayingInvoice') {
|
// 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)
|
void handleBoltCardTap(lnurlw)
|
||||||
} else if (isCashIn.value && nestedState.value === 'displayingQR') {
|
} else if (isCashIn.value && nestedState.value === 'displayingQR') {
|
||||||
void handleBoltCardReceive(lnurlw)
|
void handleBoltCardReceive(lnurlw)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
window.electronAPI.onNfcStatus?.((status) => {
|
window.electronAPI.onNfcStatus?.((status) => {
|
||||||
// Only surface reader status on a tap screen, and don't clobber an
|
// Surface reader status on a tap screen (locked / invoice / QR); don't
|
||||||
// in-flight tap's message.
|
// clobber an in-flight tap's message.
|
||||||
const onTapScreen =
|
const onTapScreen =
|
||||||
|
isLocked.value ||
|
||||||
(isCashOut.value && nestedState.value === 'displayingInvoice') ||
|
(isCashOut.value && nestedState.value === 'displayingInvoice') ||
|
||||||
(isCashIn.value && nestedState.value === 'displayingQR')
|
(isCashIn.value && nestedState.value === 'displayingQR')
|
||||||
if (onTapScreen && !boltCardProcessing.value) {
|
if (onTapScreen && !boltCardProcessing.value) {
|
||||||
|
|
@ -704,6 +771,11 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
void handleBoltCardReceive(lnurlw)
|
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.
|
* Group an array of inserted bill denominations into { denomination, count } pairs.
|
||||||
*/
|
*/
|
||||||
|
|
@ -1716,6 +1788,10 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
boltCardProcessing,
|
boltCardProcessing,
|
||||||
simulateBoltCardTap,
|
simulateBoltCardTap,
|
||||||
simulateBoltCardReceive,
|
simulateBoltCardReceive,
|
||||||
|
// Tap-to-enter: card loaded at the locked screen, reused at Complete
|
||||||
|
loadedBoltCard,
|
||||||
|
completeWithCard,
|
||||||
|
simulateBoltCardEntry,
|
||||||
|
|
||||||
// Access control (ADR-003)
|
// Access control (ADR-003)
|
||||||
accessControl,
|
accessControl,
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,24 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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) -->
|
<!-- LNURL URI (web-ui only) -->
|
||||||
<div v-if="!isElectron && currentQrValue" class="pt-4 text-center space-y-1">
|
<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">
|
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,24 @@ function formatFiat(cents: number): string {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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) -->
|
<!-- Invoice info with copy button (web-ui only) -->
|
||||||
<div v-if="context?.invoice && !isElectron" class="pt-4 text-center">
|
<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]">
|
<p class="font-mono-code mb-2 truncate text-xs text-muted-foreground max-w-[300px]">
|
||||||
|
|
|
||||||
|
|
@ -1,302 +1,87 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* Access gate — "present your badge" screen (ADR-003).
|
* 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).
|
* Shown when the machine is healthy but locked (App.vue's `isLocked` branch).
|
||||||
* Until NFC hardware exists, the PROTOTYPE reader is the camera: the user
|
* Entry is a single Bolt Card tap: the card is read by the main-process NFC
|
||||||
* shows a QR encoding their npub. On decode we authorize() it (optionally
|
* reader and routed to the store (`handleBoltCardEntry`) which authorizes it
|
||||||
* behind a PIN) and grant/deny access on the state machine. A mock reader
|
* (open-enrollment) and, on grant, loads the card into the session so buy/sell
|
||||||
* (F9 / console) is the keyboard fallback when no camera is present.
|
* 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.
|
||||||
*
|
*
|
||||||
* Reader lifecycle lives here (this view owns the <video>), mirroring
|
* Card-only by design: no camera/npub-QR, no PIN. A dev paste-box (debug builds)
|
||||||
* PairingWizard; the store owns the machine events (grant/deny/devUnlock).
|
* and a dev-unlock button remain for testing without hardware.
|
||||||
*/
|
*/
|
||||||
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useAtmStore } from '@/stores/atm'
|
import { useAtmStore } from '@/stores/atm'
|
||||||
import { useBranding } from '@/composables/useBranding'
|
import { useBranding } from '@/composables/useBranding'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import ColorModeToggle from '@/components/ColorModeToggle.vue'
|
import ColorModeToggle from '@/components/ColorModeToggle.vue'
|
||||||
import { ScanLine } from 'lucide-vue-next'
|
import { Nfc } from 'lucide-vue-next'
|
||||||
import {
|
|
||||||
availableAccessReaders,
|
|
||||||
authorize,
|
|
||||||
type AccessReader,
|
|
||||||
type AccessScan,
|
|
||||||
type StopCapture,
|
|
||||||
} from '@/services/access'
|
|
||||||
|
|
||||||
const atmStore = useAtmStore()
|
const atmStore = useAtmStore()
|
||||||
const { logoUrl, title } = useBranding()
|
const { logoUrl, title } = useBranding()
|
||||||
|
|
||||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
|
||||||
const reader = shallowRef<AccessReader | null>(null)
|
|
||||||
let stopCapture: StopCapture | null = null
|
|
||||||
|
|
||||||
// Two-step PIN flow: set when a scanned credential needs a PIN. Holds the
|
|
||||||
// original scan so the PIN can be verified against the same identity.
|
|
||||||
const pinPending = ref<AccessScan | null>(null)
|
|
||||||
const pinEntry = ref('')
|
|
||||||
const statusMessage = ref('')
|
|
||||||
|
|
||||||
const denyReason = computed(() => atmStore.snapshot?.context.accessDenyReason ?? null)
|
const denyReason = computed(() => atmStore.snapshot?.context.accessDenyReason ?? null)
|
||||||
const showDevUnlock = computed(() => atmStore.accessControl.devUnlock)
|
const showDevUnlock = computed(() => atmStore.accessControl.devUnlock)
|
||||||
|
const nfc = computed(() => atmStore.nfcStatus)
|
||||||
|
const reading = computed(() => atmStore.boltCardProcessing)
|
||||||
|
|
||||||
// The camera stays ON (always ready to scan), but the live stream is HIDDEN
|
// Dev: paste an lnurlw to simulate a tap-to-enter without a card.
|
||||||
// behind a branded overlay by default — showing a moving camera feed to every
|
const mockLnurlw = ref('')
|
||||||
// passer-by is distracting. Tapping the overlay reveals the preview so the
|
|
||||||
// person can aim their QR; after 90s of no interaction we hide it again.
|
|
||||||
const showStream = ref(false)
|
|
||||||
const INACTIVITY_MS = 90_000
|
|
||||||
let idleTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
function clearIdle() {
|
|
||||||
if (idleTimer) {
|
|
||||||
clearTimeout(idleTimer)
|
|
||||||
idleTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/** Hide the stream (and drop any half-entered PIN) back to the overlay. */
|
|
||||||
function returnToOverlay() {
|
|
||||||
clearIdle()
|
|
||||||
showStream.value = false
|
|
||||||
pinPending.value = null
|
|
||||||
pinEntry.value = ''
|
|
||||||
handling = false
|
|
||||||
}
|
|
||||||
function armIdle() {
|
|
||||||
clearIdle()
|
|
||||||
idleTimer = setTimeout(returnToOverlay, INACTIVITY_MS)
|
|
||||||
}
|
|
||||||
/** Reveal the live preview and (re)start the inactivity countdown. */
|
|
||||||
function revealStream() {
|
|
||||||
showStream.value = true
|
|
||||||
armIdle()
|
|
||||||
}
|
|
||||||
/** Any user interaction while the stream/PIN is up keeps it awake. */
|
|
||||||
function noteInteraction() {
|
|
||||||
if (showStream.value || pinPending.value) armIdle()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Camera-preview rotation is a per-hardware-mount value (the batm3's webcam
|
|
||||||
// sits differently from the Sintra's). Rather than hardcode-and-rebuild to
|
|
||||||
// find it, make it adjustable live: press "r" to rotate 90° per press. The
|
|
||||||
// choice persists (localStorage on the writable root) so it survives restarts,
|
|
||||||
// and QR decode is rotation-invariant so this is purely cosmetic.
|
|
||||||
const ROTATION_KEY = 'access-preview-rotation'
|
|
||||||
const previewRotation = ref(((Number(localStorage.getItem(ROTATION_KEY)) % 360) + 360) % 360)
|
|
||||||
const rotationClass = computed(
|
|
||||||
() =>
|
|
||||||
({ 0: '', 90: 'rotate-90', 180: 'rotate-180', 270: '-rotate-90' })[previewRotation.value] ?? ''
|
|
||||||
)
|
|
||||||
function cyclePreviewRotation() {
|
|
||||||
previewRotation.value = (previewRotation.value + 90) % 360
|
|
||||||
localStorage.setItem(ROTATION_KEY, String(previewRotation.value))
|
|
||||||
}
|
|
||||||
function onRotateKey(e: KeyboardEvent) {
|
|
||||||
noteInteraction()
|
|
||||||
// Only an operator/dev may re-orient the preview (gated like the unlock).
|
|
||||||
if (!showDevUnlock.value) return
|
|
||||||
if (e.key === 'r' || e.key === 'R') cyclePreviewRotation()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function teardown() {
|
|
||||||
if (stopCapture) {
|
|
||||||
try {
|
|
||||||
stopCapture()
|
|
||||||
} catch {
|
|
||||||
/* idempotent */
|
|
||||||
}
|
|
||||||
stopCapture = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startReader() {
|
|
||||||
await teardown()
|
|
||||||
const [first] = await availableAccessReaders()
|
|
||||||
reader.value = first ?? null
|
|
||||||
if (!first) {
|
|
||||||
statusMessage.value = 'No reader available.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
stopCapture = await first.start({
|
|
||||||
video: first.kind === 'qr-npub' ? (videoEl.value ?? undefined) : undefined,
|
|
||||||
onScan: handleScan,
|
|
||||||
onError: (e) => console.warn('[Access] capture glitch:', e),
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
statusMessage.value =
|
|
||||||
e instanceof Error ? e.message : 'Could not start the reader.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let handling = false
|
|
||||||
async function handleScan(scan: AccessScan) {
|
|
||||||
if (handling) return
|
|
||||||
handling = true
|
|
||||||
// A detected QR is an interaction — surface the preview so the result
|
|
||||||
// (grant / PIN prompt / denial) is visible even if the overlay was up.
|
|
||||||
revealStream()
|
|
||||||
await teardown() // reader off while we decide
|
|
||||||
|
|
||||||
const cfg = atmStore.accessControl
|
|
||||||
const outcome = await authorize(scan, cfg.allowList, {
|
|
||||||
salt: cfg.salt,
|
|
||||||
openEnrollment: cfg.openEnrollment,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (outcome.status === 'granted') {
|
|
||||||
atmStore.grantAccess(outcome.role, outcome.credentialIdHash)
|
|
||||||
return // machine leaves `locked`; view unmounts
|
|
||||||
}
|
|
||||||
if (outcome.status === 'pin-required') {
|
|
||||||
pinPending.value = scan
|
|
||||||
pinEntry.value = ''
|
|
||||||
statusMessage.value = ''
|
|
||||||
handling = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// denied — show reason and resume scanning
|
|
||||||
atmStore.denyAccess(outcome.reason, outcome.credentialIdHash)
|
|
||||||
handling = false
|
|
||||||
await startReader()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitPin() {
|
|
||||||
const scan = pinPending.value
|
|
||||||
if (!scan) return
|
|
||||||
const cfg = atmStore.accessControl
|
|
||||||
const outcome = await authorize(scan, cfg.allowList, {
|
|
||||||
salt: cfg.salt,
|
|
||||||
openEnrollment: cfg.openEnrollment,
|
|
||||||
pin: pinEntry.value,
|
|
||||||
})
|
|
||||||
if (outcome.status === 'granted') {
|
|
||||||
atmStore.grantAccess(outcome.role, outcome.credentialIdHash)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// wrong PIN (or anything else) — back to scanning
|
|
||||||
atmStore.denyAccess(outcome.status === 'denied' ? outcome.reason : 'access denied')
|
|
||||||
pinPending.value = null
|
|
||||||
pinEntry.value = ''
|
|
||||||
handling = false
|
|
||||||
await startReader()
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelPin() {
|
|
||||||
pinPending.value = null
|
|
||||||
pinEntry.value = ''
|
|
||||||
handling = false
|
|
||||||
void startReader()
|
|
||||||
}
|
|
||||||
|
|
||||||
function pressDigit(d: string) {
|
|
||||||
if (pinEntry.value.length < 12) pinEntry.value += d
|
|
||||||
}
|
|
||||||
function backspacePin() {
|
|
||||||
pinEntry.value = pinEntry.value.slice(0, -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
startReader()
|
|
||||||
window.addEventListener('keydown', onRotateKey)
|
|
||||||
})
|
|
||||||
onUnmounted(() => {
|
|
||||||
clearIdle()
|
|
||||||
teardown()
|
|
||||||
window.removeEventListener('keydown', onRotateKey)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="relative flex flex-1 flex-col items-center justify-center gap-10 bg-background p-8 text-foreground"
|
class="relative flex flex-1 flex-col items-center justify-center gap-10 bg-background p-8 text-foreground"
|
||||||
@pointerdown="noteInteraction"
|
|
||||||
>
|
>
|
||||||
<!-- Light/dark toggle — shared kiosk-sized component -->
|
<!-- Light/dark toggle — shared kiosk-sized component -->
|
||||||
<ColorModeToggle class="absolute right-4 top-4 z-10" />
|
<ColorModeToggle class="absolute right-4 top-4 z-10" />
|
||||||
|
|
||||||
<!-- Brand: logo + title only, colours from the active theme (branding.json) -->
|
<!-- Brand: logo + title only, colours from the active theme (branding.json) -->
|
||||||
<div class="flex flex-col items-center gap-4">
|
<div class="flex flex-col items-center gap-4">
|
||||||
<img
|
<img v-if="logoUrl" :src="logoUrl" alt="" class="h-[16vh] max-h-44 w-auto object-contain" />
|
||||||
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>
|
<h1 class="text-3xl font-bold tracking-tight lg:text-5xl">{{ title }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PIN entry (second factor) -->
|
<!-- Tap target -->
|
||||||
<div v-if="pinPending" class="flex flex-col items-center gap-6">
|
<div class="flex flex-col items-center gap-6">
|
||||||
<p class="text-lg text-muted-foreground lg:text-2xl">Enter your PIN</p>
|
|
||||||
<div class="font-mono text-4xl tracking-[0.5em] text-foreground">
|
|
||||||
{{ '•'.repeat(pinEntry.length) || '—' }}
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-3 gap-3">
|
|
||||||
<Button
|
|
||||||
v-for="d in ['1', '2', '3', '4', '5', '6', '7', '8', '9']"
|
|
||||||
:key="d"
|
|
||||||
size="kiosk-icon"
|
|
||||||
variant="outline"
|
|
||||||
@click="pressDigit(d)"
|
|
||||||
>{{ d }}</Button
|
|
||||||
>
|
|
||||||
<Button size="kiosk-icon" variant="ghost" @click="backspacePin">⌫</Button>
|
|
||||||
<Button size="kiosk-icon" variant="outline" @click="pressDigit('0')">0</Button>
|
|
||||||
<Button size="kiosk-icon" variant="default" @click="submitPin">✓</Button>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" @click="cancelPin">Cancel</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Camera viewfinder (npub QR badge). The camera keeps running underneath;
|
|
||||||
an opaque, pressable overlay hides the live feed until someone taps. -->
|
|
||||||
<template v-else>
|
|
||||||
<div class="flex flex-col items-center gap-5">
|
|
||||||
<div
|
<div
|
||||||
class="relative overflow-hidden rounded-3xl border-4 border-primary bg-black shadow-xl"
|
class="flex items-center justify-center rounded-full border-4 border-primary bg-card shadow-xl"
|
||||||
style="width: min(72vw, 26rem); aspect-ratio: 1 / 1"
|
:class="reading ? 'animate-pulse' : ''"
|
||||||
|
style="width: min(48vw, 15rem); aspect-ratio: 1 / 1"
|
||||||
>
|
>
|
||||||
<!-- Rotation is adjustable live (press "r"); persisted per machine. -->
|
<Nfc class="size-24 text-primary lg:size-28" />
|
||||||
<video
|
|
||||||
ref="videoEl"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
:class="rotationClass"
|
|
||||||
muted
|
|
||||||
autoplay
|
|
||||||
playsinline
|
|
||||||
></video>
|
|
||||||
<div class="pointer-events-none absolute inset-6 rounded-2xl border-2 border-primary/50"></div>
|
|
||||||
|
|
||||||
<!-- Default overlay: hides the stream + invites a tap to reveal it. -->
|
|
||||||
<button
|
|
||||||
v-if="!showStream"
|
|
||||||
class="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-card text-card-foreground transition-colors hover:bg-card/90"
|
|
||||||
@click="revealStream"
|
|
||||||
>
|
|
||||||
<ScanLine class="size-16 text-primary" />
|
|
||||||
<span class="text-xl font-semibold lg:text-2xl">Tap to scan</span>
|
|
||||||
<span class="max-w-[80%] text-center text-sm text-muted-foreground lg:text-base">
|
|
||||||
Show the camera to scan your access QR
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="showStream">
|
<p class="text-2xl font-semibold text-foreground lg:text-4xl">
|
||||||
<p class="text-2xl font-semibold text-foreground lg:text-3xl">Scan to enter</p>
|
{{ reading ? 'Reading card…' : 'Tap your Bolt Card to begin' }}
|
||||||
<p class="max-w-md text-center text-base text-muted-foreground lg:text-xl">
|
|
||||||
Present your access QR to the camera
|
|
||||||
</p>
|
</p>
|
||||||
<p v-if="denyReason" class="text-lg font-medium text-destructive lg:text-xl">
|
|
||||||
|
<!-- Reader status / denial reason -->
|
||||||
|
<p v-if="denyReason" class="text-lg font-medium text-destructive lg:text-2xl">
|
||||||
{{ denyReason }}
|
{{ denyReason }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="statusMessage" class="text-base text-muted-foreground">
|
<p
|
||||||
{{ statusMessage }}
|
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>
|
</p>
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showDevUnlock" class="mt-2 flex flex-col items-center gap-1">
|
<!-- Dev affordances -->
|
||||||
|
<div class="mt-2 flex flex-col items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
v-if="showDevUnlock"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="text-muted-foreground opacity-40 transition-opacity hover:opacity-100"
|
class="text-muted-foreground opacity-40 transition-opacity hover:opacity-100"
|
||||||
|
|
@ -304,13 +89,21 @@ onUnmounted(() => {
|
||||||
>
|
>
|
||||||
Dev unlock
|
Dev unlock
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
<div v-if="atmStore.debugMode" class="flex items-center gap-2">
|
||||||
class="text-xs text-muted-foreground opacity-40 transition-opacity hover:opacity-100"
|
<input
|
||||||
@click="cyclePreviewRotation"
|
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)"
|
||||||
>
|
>
|
||||||
press “r” to rotate camera · {{ previewRotation }}°
|
Tap
|
||||||
</button>
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue