+/**
+ * Light/dark toggle — the single reusable control for switching color mode.
+ *
+ * Kiosk-sized by default (large touch target for a public display). colorMode
+ * is global + persisted (toggles `.dark` on , and dark mode pulls
+ * branding.json's dark palette + logo-dark.png), so this stays in sync
+ * wherever it's used. Position it via a fallthrough `class` on the consumer,
+ * e.g. `
`.
+ */
+import { useTheme } from '@/composables/useTheme'
+import { Button } from '@/components/ui/button'
+import { Sun, Moon } from 'lucide-vue-next'
+
+const { colorMode } = useTheme()
+
+function toggle() {
+ colorMode.value = colorMode.value === 'dark' ? 'light' : 'dark'
+}
+
+
+
+
+
+
+ {{ colorMode === 'dark' ? 'Light' : 'Dark' }}
+
+
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/main.ts b/apps/machine/src/main.ts
index 5f39e0a..8679c87 100644
--- a/apps/machine/src/main.ts
+++ b/apps/machine/src/main.ts
@@ -24,6 +24,13 @@ const router = createRouter({
],
})
+// Kiosk chrome (hidden cursor) is the default — every real machine is a
+// touchscreen. The public web demo (VITE_DEMO_TAG) runs in a normal browser,
+// where an invisible pointer just reads as broken.
+if (!import.meta.env.VITE_DEMO_TAG) {
+ document.documentElement.classList.add('kiosk')
+}
+
// Create Pinia store
const pinia = createPinia()
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 element')
+
+ const camera = await frontalCamera(video)
+
+ // Match the pairing source's deliberate capture resolution (see
+ // pairing/qr-source.ts) — 1280x960 balances px/module against decode speed
+ // on this fixed-focus panel. Soft `ideal` so a camera that can't honor it
+ // degrades instead of throwing.
+ try {
+ const stream = video.srcObject
+ if (stream instanceof MediaStream) {
+ await stream.getVideoTracks()[0]?.applyConstraints({
+ width: { ideal: 1280 },
+ height: { ideal: 960 },
+ })
+ }
+ } catch (e) {
+ onError?.(e)
+ }
+
+ const canvas = new QRCanvas() // decode-only
+
+ let stopped = false
+ let cancel: (() => void) | null = null
+ const stop: StopCapture = () => {
+ if (stopped) return
+ stopped = true
+ cancel?.()
+ camera.stop()
+ }
+
+ cancel = frameLoop(() => {
+ if (stopped) return
+ try {
+ const result = camera.readFrame(canvas, true)
+ if (result) {
+ // Stop on first decode so one badge isn't ingested repeatedly; the
+ // store restarts the reader if authorization fails.
+ stop()
+ onScan({ kind: 'npub', npub: result.trim() })
+ }
+ } catch (e) {
+ onError?.(e)
+ }
+ })
+
+ return stop
+ }
+}
diff --git a/apps/machine/src/services/access/types.ts b/apps/machine/src/services/access/types.ts
new file mode 100644
index 0000000..d6ecec3
--- /dev/null
+++ b/apps/machine/src/services/access/types.ts
@@ -0,0 +1,66 @@
+/**
+ * Access-control reader abstraction (ADR-003).
+ *
+ * Mirrors the `services/pairing` `PairingSource` seam: an `AccessReader`
+ * captures a credential from whatever hardware the machine has and hands an
+ * `AccessScan` to the store, which authorizes it and grants/denies terminal
+ * access. Implementations live next to this file:
+ * - `qr-npub-reader.ts` — camera scans an npub QR "badge" (PROTOTYPE, works
+ * on batm3 today — same camera the pairing wizard uses)
+ * - `mock-reader.ts` — dev, no hardware (keyboard / console trigger)
+ * - `web-nfc-reader.ts` — Web NFC / NDEFReader, laptop/phone dev (PR3)
+ * - `serial-reader.ts` — /dev/ttyNFC via main-process HAL + IPC (PR4)
+ *
+ * The reader emits a RAW credential; hashing/authorization (and the optional
+ * PIN second factor) is the store's job (see `authorize.ts`), so raw ids never
+ * leave this layer (KYC-free).
+ */
+
+import type { AccessRole } from '@bitSpire/state-machine'
+
+export type { AccessRole }
+
+export type AccessReaderKind = 'qr-npub' | 'mock' | 'nfc-web' | 'nfc-serial'
+
+/**
+ * A raw credential captured by a reader. Discriminated union so new factors
+ * are additive:
+ * - `npub` — prototype QR badge (this PR)
+ * - `uid` — NFC card UID (PR4)
+ * - `challenge` — card-signed nonce, challenge-response (PR5)
+ */
+export type AccessScan =
+ | { kind: 'npub'; npub: string }
+ | { kind: 'uid'; uid: string }
+ // A tapped Bolt Card: `externalId` is the identity (from the lnurlw path);
+ // `lnurlw` is the full voucher (p/c intact) the session reuses at Complete to
+ // move sats. Only `externalId` is ever hashed/authorized — the p/c never enter
+ // the authorize layer (KYC-free; they're single-use secrets held transiently
+ // by the store for the one transaction).
+ | { kind: 'boltcard'; externalId: string; lnurlw: string }
+ | { kind: 'challenge'; pubkey: string; nonce: string; sig: string }
+
+export interface AccessReaderStartOptions {
+ /** Called with each captured credential. */
+ onScan: (scan: AccessScan) => void
+ /** Non-fatal capture error (e.g. a frame decode glitch). */
+ onError?: (error: unknown) => void
+ /**
+ * The element a camera reader renders into. Required by camera
+ * readers (qr-npub); ignored by readers with no viewfinder (mock, NFC).
+ */
+ video?: HTMLVideoElement
+}
+
+/** Releases the reader (camera stream, event listeners, serial handle). Idempotent. */
+export type StopCapture = () => void
+
+export interface AccessReader {
+ readonly kind: AccessReaderKind
+ /** Short human label for status/debug UI. */
+ readonly label: string
+ /** Whether this reader can run in the current environment. */
+ isAvailable(): Promise
+ /** Begin capturing; resolves once the reader is live. */
+ start(opts: AccessReaderStartOptions): Promise
+}
diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts
index eb2e1c7..aa6b52c 100644
--- a/apps/machine/src/services/lightning.ts
+++ b/apps/machine/src/services/lightning.ts
@@ -505,6 +505,31 @@ export async function initializeLightningServices(options?: {
}
console.log('[Lightning] LNbits wallet:', lnbitsWalletId)
+ // ── Public web demo: stamp the throwaway account so it can be swept ──────
+ // The browser demo (atm.demo.aiolabs.dev) runs with an EPHEMERAL identity —
+ // a fresh keypair per page load — so LNbits mints a new account + a fresh
+ // auto-credited wallet for every visitor. That isolation is the point (a
+ // single baked-in key would be credited exactly once and then drain), but it
+ // leaves throwaway accounts behind, and nothing in an auto-created row says
+ // "demo": pubkey-set/prvkey-NULL also describes a real ATM.
+ //
+ // A nostr pubkey can't carry a marker (you'd have to grind a vanity prefix,
+ // far too slow to do on page load), and the account/wallet the server
+ // auto-creates isn't nameable by the client. So we mint one extra,
+ // never-used wallet whose NAME is the tag: sweeping is then an exact string
+ // match on wallet name rather than a heuristic about what looks disposable.
+ //
+ // Unset on every real machine, so this is inert outside the demo build. The
+ // call is fire-and-forget: losing the marker degrades cleanup, not the demo.
+ const demoTag = (import.meta.env.VITE_DEMO_TAG as string | undefined)?.trim()
+ if (demoTag) {
+ void lnbits
+ .createWallet(demoTag)
+ // Never log the reply — create_wallet returns adminkey/inkey.
+ .then(() => console.log('[Lightning] Demo marker wallet created:', demoTag))
+ .catch((e) => console.warn('[Lightning] Demo marker wallet failed:', e))
+ }
+
// #70 P1: pull operator pubkey + fee config from LNbits over the authenticated
// transport (spirekeeper#41 `get_machine_config`). A seed-only machine has no
// VITE_OPERATOR_PUBKEYS, so without this it can't trust its fee config and sits
diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts
index db1a124..6416828 100644
--- a/apps/machine/src/stores/atm.ts
+++ b/apps/machine/src/stores/atm.ts
@@ -8,7 +8,9 @@ import {
type ActorRefFrom,
type SnapshotFrom,
type ATMMachine,
+ type AccessRole,
} from '@bitSpire/state-machine'
+import type { AccessControlConfig } from '@/types/electron'
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
import { classifyInitError } from '@/services/init-error'
import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config'
@@ -25,6 +27,7 @@ import {
} from '@bitSpire/clink'
import type { TransactionRecord } from '@/types/state'
import { useAvailabilityBroadcast } from '@/composables/useAvailabilityBroadcast'
+import { authorize, parseBoltcardLnurlw, type AccessScan } from '@/services/access'
// Check if we're running in Electron
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
@@ -291,6 +294,23 @@ export const useAtmStore = defineStore('atm', () => {
// is in flight (settlement still arrives via the normal invoice watcher).
const nfcStatus = ref<{ state: string; message?: string } | null>(null)
const boltCardProcessing = ref(false)
+ // Access-control gate config (ADR-003). Defaults disabled → the machine's
+ // `locked` state bypasses straight to `idle` (behaviour identical to no gate).
+ // Populated from RuntimeConfig.accessControl in initializeForProduction.
+ const accessControl = ref({
+ enabled: false,
+ devUnlock: false,
+ openEnrollment: false,
+ salt: 'bitspire-access-v1',
+ allowList: [],
+ })
+ // Build/dev bypass — opens the gate even when enabled (browser dev / CI).
+ const accessBypassFlag = import.meta.env.VITE_SKIP_ACCESS_GATE === 'true'
+ // Tap-to-enter (ADR-003): the Bolt Card tapped at the locked screen is held
+ // here for the whole session so buy/sell just need "Complete" — no second tap.
+ // Carries the full lnurlw (single-use SUN p/c intact; spent only at payment).
+ // Cleared when the session ends (machine re-locks). Never logged.
+ const loadedBoltCard = ref<{ externalId: string; lnurlw: string } | null>(null)
const fiatCode = ref('USD')
// Defaults are 0 — the operator's fee config (received via Nostr
// kind-30078 `bitspire-fees:` envelope from satmachineadmin)
@@ -421,6 +441,11 @@ export const useAtmStore = defineStore('atm', () => {
const isIdle = computed(() => currentState.value === 'idle')
+ // ADR-003: the machine is sitting at the access gate. When the gate is
+ // disabled this is never true (the `locked` state bypasses to `idle` on
+ // start), so App.vue's LockedView branch never renders on a non-access machine.
+ const isLocked = computed(() => currentState.value === 'locked')
+
const isCashIn = computed(() => {
const state = snapshot.value?.value
return typeof state === 'object' && 'cashIn' in state
@@ -448,6 +473,8 @@ export const useAtmStore = defineStore('atm', () => {
currency: fiatCode.value,
cashInFeeFraction: cashInFeeFraction.value,
cashOutFeeFraction: cashOutFeeFraction.value,
+ accessControlEnabled: accessControl.value.enabled,
+ accessBypassFlag,
})
actor.value = createActor(machine)
@@ -473,6 +500,12 @@ export const useAtmStore = defineStore('atm', () => {
lnurlCleanupFn()
}
+ // Drop the tapped-at-entry Bolt Card when the session ends (machine
+ // re-locks) so the next customer starts fresh — never carry a card over.
+ if (state === 'locked' && loadedBoltCard.value) {
+ loadedBoltCard.value = null
+ }
+
// Detect network from first invoice we see
if (newSnapshot.context.invoice) {
detectNetworkFromInvoice(newSnapshot.context.invoice)
@@ -650,21 +683,76 @@ export const useAtmStore = defineStore('atm', () => {
}
}
+ /**
+ * A tapped Bolt Card at the locked screen (ADR-003 tap-to-enter). Soft entry:
+ * parse the external_id LOCALLY (no server call, so the single-use SUN p/c stay
+ * valid), authorize (open-enrollment or allow-list), then hold the full lnurlw
+ * for the session. The cryptographic check happens later, at Complete, when the
+ * stored lnurlw actually moves sats.
+ */
+ async function handleBoltCardEntry(lnurlw: string) {
+ if (!isLocked.value) return
+ if (boltCardProcessing.value) return
+ const parsed = parseBoltcardLnurlw(lnurlw)
+ if (!parsed) {
+ nfcStatus.value = { state: 'declined', message: 'Not a Bolt Card' }
+ denyAccess('not a Bolt Card')
+ return
+ }
+ boltCardProcessing.value = true
+ nfcStatus.value = { state: 'processing', message: 'Reading card…' }
+ try {
+ const scan: AccessScan = { kind: 'boltcard', externalId: parsed.externalId, lnurlw }
+ const outcome = await authorize(scan, accessControl.value.allowList, {
+ salt: accessControl.value.salt,
+ openEnrollment: accessControl.value.openEnrollment,
+ })
+ if (outcome.status === 'granted') {
+ loadedBoltCard.value = { externalId: parsed.externalId, lnurlw }
+ nfcStatus.value = { state: 'accepted', message: 'Card accepted' }
+ grantAccess(outcome.role, outcome.credentialIdHash)
+ } else {
+ // pin-required can't occur for card-only open-enrollment; treat as denied.
+ const reason = outcome.status === 'denied' ? outcome.reason : 'card not authorized'
+ nfcStatus.value = { state: 'declined', message: reason }
+ denyAccess(reason, outcome.credentialIdHash)
+ }
+ } finally {
+ boltCardProcessing.value = false
+ }
+ }
+
+ /**
+ * Complete a buy/sell using the Bolt Card loaded at entry — no second tap.
+ * Cash-out pulls via the stored lnurlw; cash-in resolves it to the card
+ * wallet's lnurlp and pays. Reuses the tap handlers verbatim.
+ */
+ function completeWithCard() {
+ const card = loadedBoltCard.value
+ if (!card) return
+ if (isCashOut.value) void handleBoltCardTap(card.lnurlw)
+ else if (isCashIn.value) void handleBoltCardReceive(card.lnurlw)
+ }
+
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
function setupNfcListener() {
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
window.electronAPI.onNfcCardTapped((lnurlw) => {
- // Route the same physical tap by flow: cash-out pulls, cash-in receives.
- if (isCashOut.value && nestedState.value === 'displayingInvoice') {
+ // Route the tap by state: locked → enter + load the card; then cash-out
+ // pulls, cash-in receives (fallback if no card was loaded at entry).
+ if (isLocked.value) {
+ void handleBoltCardEntry(lnurlw)
+ } else if (isCashOut.value && nestedState.value === 'displayingInvoice') {
void handleBoltCardTap(lnurlw)
} else if (isCashIn.value && nestedState.value === 'displayingQR') {
void handleBoltCardReceive(lnurlw)
}
})
window.electronAPI.onNfcStatus?.((status) => {
- // Only surface reader status on a tap screen, and don't clobber an
- // in-flight tap's message.
+ // Surface reader status on a tap screen (locked / invoice / QR); don't
+ // clobber an in-flight tap's message.
const onTapScreen =
+ isLocked.value ||
(isCashOut.value && nestedState.value === 'displayingInvoice') ||
(isCashIn.value && nestedState.value === 'displayingQR')
if (onTapScreen && !boltCardProcessing.value) {
@@ -683,6 +771,11 @@ export const useAtmStore = defineStore('atm', () => {
void handleBoltCardReceive(lnurlw)
}
+ /** Dev/mock: simulate tapping a card at the locked screen (tap-to-enter). */
+ function simulateBoltCardEntry(lnurlw: string) {
+ void handleBoltCardEntry(lnurlw)
+ }
+
/**
* Group an array of inserted bill denominations into { denomination, count } pairs.
*/
@@ -1183,6 +1276,19 @@ export const useAtmStore = defineStore('atm', () => {
const runtimeFiatCode = runtimeConfig.fiatCode || 'USD'
fiatCode.value = runtimeFiatCode
+ // Access-control gate (ADR-003). Must be set BEFORE the machine is built
+ // (initialize() reads accessControl.value to seed the `locked` state).
+ if (runtimeConfig.accessControl) {
+ accessControl.value = runtimeConfig.accessControl
+ if (runtimeConfig.accessControl.enabled) {
+ console.log(
+ `[ATM] Access control ENABLED (openEnrollment=${runtimeConfig.accessControl.openEnrollment}, ` +
+ `allowList=${runtimeConfig.accessControl.allowList.length} entries, ` +
+ `devUnlock=${runtimeConfig.accessControl.devUnlock})`
+ )
+ }
+ }
+
// Load persisted operator fee config (aiolabs/lamassu-next#57). If no
// config has ever been applied (fresh ATM, pre-operator-publish),
// enter the 'awaiting-fees' maintenance state — UI shows the operator
@@ -1491,6 +1597,61 @@ export const useAtmStore = defineStore('atm', () => {
actor.value.send(event)
}
+ // === Access control (ADR-003) ===
+
+ /**
+ * Audit stub — records an access decision. PR1 logs only (hashed id, never a
+ * raw credential); a fast-follow persists to state.db and optionally a Nostr
+ * event (see ADR-003).
+ */
+ function recordAccessAudit(outcome: {
+ result: 'granted' | 'denied'
+ role?: AccessRole
+ credentialIdHash: string
+ reason?: string
+ }) {
+ console.info('[Access] audit', {
+ result: outcome.result,
+ role: outcome.role ?? null,
+ // Truncate the hash in logs — it's already non-reversible, but no need to
+ // splash the full value across the journal.
+ credentialIdHash: outcome.credentialIdHash.slice(0, 12),
+ reason: outcome.reason ?? null,
+ at: Date.now(),
+ })
+ }
+
+ /** Grant terminal access after a credential (and any PIN) is authorized. */
+ function grantAccess(role: AccessRole, credentialIdHash: string) {
+ recordAccessAudit({ result: 'granted', role, credentialIdHash })
+ send({ type: 'ACCESS_GRANTED', role, credentialIdHash })
+ }
+
+ /** Reject an access attempt; the machine stays locked and shows the reason. */
+ function denyAccess(reason: string, credentialIdHash = '') {
+ recordAccessAudit({ result: 'denied', credentialIdHash, reason })
+ send({ type: 'ACCESS_DENIED', reason })
+ }
+
+ /** Runtime dev/operator unlock (gated by the machine's devUnlockAllowed guard). */
+ function devUnlock() {
+ recordAccessAudit({ result: 'granted', role: 'operator', credentialIdHash: 'dev-unlock' })
+ send({ type: 'DEV_UNLOCK' })
+ }
+
+ /**
+ * End the current tap-in session and re-lock immediately (drops the loaded
+ * Bolt Card via `locked`'s entry). Routed through the machine's root-level
+ * END_SESSION so it locks from any unlocked state. Callers: the "End session"
+ * button, the idle-inactivity timer, and the absolute session cap. `reason`
+ * is recorded for the access audit trail — never a raw credential. No-op
+ * unless the gate is active (guarded in the machine).
+ */
+ function endSession(reason: 'user' | 'inactivity' | 'session-cap' = 'user') {
+ console.info(`[ATM] Ending session — reason=${reason}`)
+ send({ type: 'END_SESSION' })
+ }
+
// Convenience methods for common events
function selectCashIn() {
send({ type: 'SELECT_CASH_IN' })
@@ -1640,6 +1801,18 @@ export const useAtmStore = defineStore('atm', () => {
boltCardProcessing,
simulateBoltCardTap,
simulateBoltCardReceive,
+ // Tap-to-enter: card loaded at the locked screen, reused at Complete
+ loadedBoltCard,
+ completeWithCard,
+ simulateBoltCardEntry,
+
+ // Access control (ADR-003)
+ accessControl,
+ isLocked,
+ grantAccess,
+ denyAccess,
+ devUnlock,
+ endSession,
// Actions
initialize,
diff --git a/apps/machine/src/style.css b/apps/machine/src/style.css
index 621cf82..288f0dc 100644
--- a/apps/machine/src/style.css
+++ b/apps/machine/src/style.css
@@ -1,10 +1,13 @@
@import 'tailwindcss';
@import 'tw-animate-css';
-/* Hide cursor completely on touchscreen kiosk */
-*,
-*::before,
-*::after {
+/* Hide cursor completely on touchscreen kiosk.
+ Scoped to .kiosk (set on by main.ts) so the public web demo, which
+ runs in an ordinary browser with a mouse, keeps a visible pointer. */
+.kiosk,
+.kiosk *,
+.kiosk *::before,
+.kiosk *::after {
cursor: none !important;
}
diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts
index 0e5846d..95425f6 100644
--- a/apps/machine/src/types/electron.d.ts
+++ b/apps/machine/src/types/electron.d.ts
@@ -2,6 +2,26 @@
* Type declarations for Electron API exposed via preload
*/
+import type { AllowListEntry } from '../services/access/authorize'
+
+/**
+ * Access-control config (ADR-003). Loaded by the main process from env +
+ * an optional /var/lib/bitspire/access.json. `enabled` defaults false, so a
+ * machine with no access config behaves exactly as before.
+ */
+export interface AccessControlConfig {
+ /** Master switch for the badge-to-enter gate. */
+ enabled: boolean
+ /** Allow the runtime dev/operator unlock gesture on the locked screen. */
+ devUnlock: boolean
+ /** Prototype: admit any valid npub when the allow-list has no match. */
+ openEnrollment: boolean
+ /** Per-machine salt for hashing credentials/PINs. */
+ salt: string
+ /** Authorized identities (hashed). Empty in open-enrollment prototype mode. */
+ allowList: AllowListEntry[]
+}
+
export interface RuntimeConfig {
relayUrl: string
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
@@ -17,6 +37,8 @@ export interface RuntimeConfig {
maintenanceMode: boolean
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
branding: BrandingConfig | null
+ /** Access-control gate config (ADR-003). Always present; `enabled` defaults false. */
+ accessControl: AccessControlConfig
}
/** Operator branding config. Wire payload from Electron IPC; renderer applies via useBranding(). */
diff --git a/apps/machine/src/views/CashInView.vue b/apps/machine/src/views/CashInView.vue
index 915c113..b75a2a4 100644
--- a/apps/machine/src/views/CashInView.vue
+++ b/apps/machine/src/views/CashInView.vue
@@ -363,6 +363,24 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
+
+
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 {
+
+
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() {
-
-