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:
Patrick Mulligan 2026-08-06 22:37:09 +02:00 • committed by Padreug
commit c7e312a63f
9 changed files with 283 additions and 284 deletions

View file

@ -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()
})
}) })

View file

@ -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',
} }
} }

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

@ -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[] {

View file

@ -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 {

View file

@ -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,

View file

@ -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">

View file

@ -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]">

View file

@ -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>