diff --git a/apps/machine/.env.example b/apps/machine/.env.example
index 66541dc..3230ecb 100644
--- a/apps/machine/.env.example
+++ b/apps/machine/.env.example
@@ -81,3 +81,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
diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts
index 6cfcb20..736d89d 100644
--- a/apps/machine/electron/main.ts
+++ b/apps/machine/electron/main.ts
@@ -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(),
}
})
diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue
index d9bce92..07cb912 100644
--- a/apps/machine/src/App.vue
+++ b/apps/machine/src/App.vue
@@ -1,18 +1,39 @@
+
+
+
+
diff --git a/apps/machine/src/composables/useSessionSecurity.ts b/apps/machine/src/composables/useSessionSecurity.ts
new file mode 100644
index 0000000..6317474
--- /dev/null
+++ b/apps/machine/src/composables/useSessionSecurity.ts
@@ -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)
+}
diff --git a/apps/machine/src/services/access/__tests__/authorize.test.ts b/apps/machine/src/services/access/__tests__/authorize.test.ts
new file mode 100644
index 0000000..521cfcb
--- /dev/null
+++ b/apps/machine/src/services/access/__tests__/authorize.test.ts
@@ -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 => ({
+ 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()
+ })
+})
diff --git a/apps/machine/src/services/access/authorize.ts b/apps/machine/src/services/access/authorize.ts
new file mode 100644
index 0000000..86aac4d
--- /dev/null
+++ b/apps/machine/src/services/access/authorize.ts
@@ -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(, 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 {
+ 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 => sha256Hex(`id:${salt}:${id}`)
+export const hashPin = (pin: string, salt: string): Promise =>
+ 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 {
+ 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 }
+}
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
new file mode 100644
index 0000000..a3ed586
--- /dev/null
+++ b/apps/machine/src/services/access/index.ts
@@ -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 {
+ const readers = allAccessReaders()
+ const flags = await Promise.all(readers.map((r) => r.isAvailable()))
+ return readers.filter((_, i) => flags[i])
+}
diff --git a/apps/machine/src/services/access/mock-reader.ts b/apps/machine/src/services/access/mock-reader.ts
new file mode 100644
index 0000000..c918b18
--- /dev/null
+++ b/apps/machine/src/services/access/mock-reader.ts
@@ -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 {
+ return true
+ }
+
+ async start(opts: AccessReaderStartOptions): Promise {
+ 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
+ }
+ }
+}
diff --git a/apps/machine/src/services/access/qr-npub-reader.ts b/apps/machine/src/services/access/qr-npub-reader.ts
new file mode 100644
index 0000000..e95c507
--- /dev/null
+++ b/apps/machine/src/services/access/qr-npub-reader.ts
@@ -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 {
+ return (
+ typeof navigator !== 'undefined' &&
+ !!navigator.mediaDevices &&
+ typeof navigator.mediaDevices.getUserMedia === 'function'
+ )
+ }
+
+ async start(opts: AccessReaderStartOptions): Promise {
+ const { onScan, onError, video } = opts
+ if (!video) throw new Error('QrNpubAccessReader requires a
+
+
+
+ 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/IdleView.vue b/apps/machine/src/views/IdleView.vue
index 3465cc1..cad3758 100644
--- a/apps/machine/src/views/IdleView.vue
+++ b/apps/machine/src/views/IdleView.vue
@@ -173,15 +173,37 @@ function handleCashOut() {
-
-
+
+
+
+
+
+