feat(access): Bolt Card tap-to-enter access gate (ADR-003) #86

Merged
padreug merged 11 commits from feat/access-control-skeleton into dev 2026-09-20 13:16:22 +00:00
20 changed files with 1534 additions and 28 deletions
Showing only changes of commit a7b409b109 - Show all commits

feat(access): access-control gate — npub-QR badge + PIN + dev bypass (ADR-003)

Squashed skeleton (was 11 commits on feat/access-control-skeleton) for a
clean rebase onto dev. Adds a `locked` gate the terminal boots into until a
credential is presented; opt-in and non-breaking (defaults off → boots
straight to idle as before).

- state-machine: `locked` state + ACCESS_GRANTED/ACCESS_DENIED/DEV_UNLOCK
  events + accessBypass/devUnlockAllowed guards (packages/state-machine).
- services/access: reader abstraction, npub+PIN authorize() (nostr-tools
  nip19; accepts nostr:/nprofile), camera npub-QR reader, mock reader.
- LockedView.vue + ColorModeToggle: branded viewfinder, PIN pad, denied
  reason, dev-unlock; camera off-by-default + idle return.
- store/main/electron.d.ts: seed gate config, grant/deny/devUnlock wiring,
  access.json provisioning (no rebuild), get-config surface.
- deploy: access.example.json + provision-access.sh; ADR-003.

Credential union is npub today; UID (NFC tap) is the next step.
Patrick Mulligan 2026-08-06 22:16:56 +02:00 • committed by Padreug

View file

@ -93,3 +93,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

View file

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

View file

@ -7,8 +7,9 @@ import { setBranding } from '@/composables/useBranding'
import { classifyInitError } from '@/services/init-error'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Sun, Moon } from 'lucide-vue-next'
import PairingWizard from '@/components/PairingWizard.vue'
import LockedView from '@/views/LockedView.vue'
import ColorModeToggle from '@/components/ColorModeToggle.vue'
const atmStore = useAtmStore()
const route = useRoute()
@ -289,6 +290,11 @@ function toggleLiveServices() {
</Button>
</div>
<!-- Access gate (ADR-003): shown when the machine is healthy but locked,
below the init/maintenance gates above. Never renders when access
control is disabled (the machine never dwells in `locked`). -->
<LockedView v-else-if="atmStore.isLocked" />
<template v-else>
<router-view />
@ -340,16 +346,10 @@ function toggleLiveServices() {
</div>
<!-- Light/dark toggle (production only — debug panel has this in dev) -->
<Button
<ColorModeToggle
v-if="!atmStore.allowMockFallback"
variant="outline"
class="fixed bottom-3 right-3 lg:bottom-6 lg:right-6 z-50 h-10 px-3 py-1 text-sm rounded-lg lg:h-[7vh] lg:min-h-[70px] lg:px-8 lg:py-3 lg:text-2xl lg:rounded-xl gap-2 lg:gap-3"
@click="colorMode = colorMode === 'dark' ? 'light' : 'dark'"
>
<Sun v-if="colorMode === 'dark'" class="w-5 h-5 lg:w-7 lg:h-7" />
<Moon v-else class="w-5 h-5 lg:w-7 lg:h-7" />
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
</Button>
class="fixed bottom-3 right-3 z-50 lg:bottom-6 lg:right-6"
/>
<!-- Debug overlay (dev only) -->
<div

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
/**
* 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 <html>, 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. `<ColorModeToggle class="fixed bottom-6 right-6" />`.
*/
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'
}
</script>
<template>
<Button
variant="outline"
class="h-10 gap-2 rounded-lg px-3 py-1 text-sm lg:h-[7vh] lg:min-h-[70px] lg:gap-3 lg:rounded-xl lg:px-8 lg:py-3 lg:text-2xl"
:aria-label="colorMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggle"
>
<Sun v-if="colorMode === 'dark'" class="h-5 w-5 lg:h-7 lg:w-7" />
<Moon v-else class="h-5 w-5 lg:h-7 lg:w-7" />
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
</Button>
</template>

View file

@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest'
import { npubEncode, nprofileEncode } from 'nostr-tools/nip19'
import { authorize, hashId, hashPin, type AllowListEntry } from '../authorize'
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<AllowListEntry> => ({
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')
})
})
})

View file

@ -0,0 +1,141 @@
/**
* 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(<canonical id>, 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<string> {
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<string> => sha256Hex(`id:${salt}:${id}`)
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.
* 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 === '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<AuthorizeOutcome> {
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' : '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 }
}

View file

@ -0,0 +1,42 @@
/**
* 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'
/** 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<AccessReader[]> {
const readers = allAccessReaders()
const flags = await Promise.all(readers.map((r) => r.isAvailable()))
return readers.filter((_, i) => flags[i])
}

View file

@ -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<boolean> {
return true
}
async start(opts: AccessReaderStartOptions): Promise<StopCapture> {
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
}
}
}

View file

@ -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<boolean> {
return (
typeof navigator !== 'undefined' &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getUserMedia === 'function'
)
}
async start(opts: AccessReaderStartOptions): Promise<StopCapture> {
const { onScan, onError, video } = opts
if (!video) throw new Error('QrNpubAccessReader requires a <video> 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
}
}

View file

@ -0,0 +1,60 @@
/**
* 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 }
| { 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 <video> 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<boolean>
/** Begin capturing; resolves once the reader is live. */
start(opts: AccessReaderStartOptions): Promise<StopCapture>
}

View file

@ -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'
@ -291,6 +293,18 @@ 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<AccessControlConfig>({
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'
const fiatCode = ref('USD')
// Defaults are 0 — the operator's fee config (received via Nostr
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
@ -421,6 +435,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 +467,8 @@ export const useAtmStore = defineStore('atm', () => {
currency: fiatCode.value,
cashInFeeFraction: cashInFeeFraction.value,
cashOutFeeFraction: cashOutFeeFraction.value,
accessControlEnabled: accessControl.value.enabled,
accessBypassFlag,
})
actor.value = createActor(machine)
@ -1183,6 +1204,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 +1525,48 @@ 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' })
}
// Convenience methods for common events
function selectCashIn() {
send({ type: 'SELECT_CASH_IN' })
@ -1641,6 +1717,13 @@ export const useAtmStore = defineStore('atm', () => {
simulateBoltCardTap,
simulateBoltCardReceive,
// Access control (ADR-003)
accessControl,
isLocked,
grantAccess,
denyAccess,
devUnlock,
// Actions
initialize,
initializeWithLightning,

View file

@ -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(). */

View file

@ -0,0 +1,316 @@
<script setup lang="ts">
/**
* Access gate — "present your badge" screen (ADR-003).
*
* 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
* shows a QR encoding their npub. On decode we authorize() it (optionally
* behind a PIN) and grant/deny access on the state machine. A mock reader
* (F9 / console) is the keyboard fallback when no camera is present.
*
* Reader lifecycle lives here (this view owns the <video>), mirroring
* PairingWizard; the store owns the machine events (grant/deny/devUnlock).
*/
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
import { useAtmStore } from '@/stores/atm'
import { useBranding } from '@/composables/useBranding'
import { Button } from '@/components/ui/button'
import ColorModeToggle from '@/components/ColorModeToggle.vue'
import { ScanLine } from 'lucide-vue-next'
import {
availableAccessReaders,
authorize,
type AccessReader,
type AccessScan,
type StopCapture,
} from '@/services/access'
const atmStore = useAtmStore()
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 showDevUnlock = computed(() => atmStore.accessControl.devUnlock)
// The camera stays ON (always ready to scan), but the live stream is HIDDEN
// behind a branded overlay by default — showing a moving camera feed to every
// 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>
<template>
<div
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 -->
<ColorModeToggle class="absolute right-4 top-4 z-10" />
<!-- Brand: logo + title only, colours from the active theme (branding.json) -->
<div class="flex flex-col items-center gap-4">
<img
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>
</div>
<!-- PIN entry (second factor) -->
<div v-if="pinPending" 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
class="relative overflow-hidden rounded-3xl border-4 border-primary bg-black shadow-xl"
style="width: min(72vw, 26rem); aspect-ratio: 1 / 1"
>
<!-- Rotation is adjustable live (press "r"); persisted per machine. -->
<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>
<template v-if="showStream">
<p class="text-2xl font-semibold text-foreground lg:text-3xl">Scan to enter</p>
<p class="max-w-md text-center text-base text-muted-foreground lg:text-xl">
Present your access QR to the camera
</p>
<p v-if="denyReason" class="text-lg font-medium text-destructive lg:text-xl">
{{ denyReason }}
</p>
<p v-else-if="statusMessage" class="text-base text-muted-foreground">
{{ statusMessage }}
</p>
</template>
</div>
<div v-if="showDevUnlock" class="mt-2 flex flex-col items-center gap-1">
<Button
variant="ghost"
size="sm"
class="text-muted-foreground opacity-40 transition-opacity hover:opacity-100"
@click="atmStore.devUnlock()"
>
Dev unlock
</Button>
<button
class="text-xs text-muted-foreground opacity-40 transition-opacity hover:opacity-100"
@click="cyclePreviewRotation"
>
press “r” to rotate camera · {{ previewRotation }}°
</button>
</div>
</template>
</div>
</template>

View file

@ -0,0 +1,5 @@
{
"enabled": true,
"openEnrollment": true,
"devUnlock": true
}

View file

@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Provision the access-control gate (ADR-003) to a deployed bitSpire ATM.
# Pushes an access.json to /var/lib/bitspire/ and restarts the service, so the
# gate can be toggled on a machine without an image rebuild (mirrors
# provision-branding.sh). Env defaults are overridden by whatever this file sets.
#
# Usage:
# bash provision-access.sh <access.json> # SSH to localhost:2222 (QEMU)
# bash provision-access.sh <access.json> 192.168.1.50 # a real ATM on the LAN
# bash provision-access.sh <access.json> 192.168.1.50 22 # custom SSH port
#
# access.json schema (all keys optional; omitted keys fall back to env/defaults):
# {
# "enabled": true, // master switch for the gate
# "openEnrollment": true, // prototype: admit any valid npub
# "devUnlock": true, // allow the on-screen dev/operator unlock
# "salt": "per-machine", // hashing salt (provision a real one for prod)
# "allowList": [ // authorized identities (hashed); empty in open mode
# { "idHash": "<hashId(hexpubkey,salt)>", "role": "user", "pinHash": "<hashPin(pin,salt)>" }
# ]
# }
#
# To DISABLE the gate again: push a file with {"enabled": false} (or delete
# /var/lib/bitspire/access.json on the machine) and restart.
set -euo pipefail
ACCESS_FILE="${1:-}"
ATM_HOST="${2:-localhost}"
ATM_SSH_PORT="${3:-2222}"
ATM_USER="bitspire"
REMOTE_FILE="/var/lib/bitspire/access.json"
if [ -z "$ACCESS_FILE" ]; then
echo "Usage: $0 <access.json> [host] [port]" >&2
echo " $0 ./access.json (QEMU on localhost:2222)" >&2
echo " $0 ./access.json 192.168.1.50 (real ATM)" >&2
exit 1
fi
if [ ! -f "$ACCESS_FILE" ]; then
echo "ERROR: access file not found: $ACCESS_FILE" >&2
exit 1
fi
# Fail fast on malformed JSON before touching the machine.
if command -v jq >/dev/null 2>&1; then
jq empty "$ACCESS_FILE" || { echo "ERROR: $ACCESS_FILE is not valid JSON" >&2; exit 1; }
fi
echo "=== Provisioning access gate to $ATM_HOST:$ATM_SSH_PORT ==="
echo "Local file : $ACCESS_FILE"
echo "Remote file: $REMOTE_FILE"
cat "$ACCESS_FILE"
echo ""
# Copy over SSH. --rsync-path=sudo because /var/lib/bitspire is owned by the
# bitspire service user, not the SSH user.
rsync -avz \
--rsync-path="sudo rsync" \
-e "ssh -o StrictHostKeyChecking=no -p $ATM_SSH_PORT" \
"$ACCESS_FILE" \
"$ATM_USER@$ATM_HOST:$REMOTE_FILE"
# Restart so loadAccessControl() re-reads the file.
ssh -o StrictHostKeyChecking=no -p "$ATM_SSH_PORT" "$ATM_USER@$ATM_HOST" \
"sudo systemctl restart bitspire"
echo ""
echo "=== Access gate provisioned. Service restarted. ==="

View file

@ -0,0 +1,201 @@
# ADR-003: NFC Access-Control Layer (badge-to-enter) + Developer Bypass
**Status:** Accepted
**Date:** 2026-07-29
**Context:** batm3 gaining a physical access layer — an NFC card must be presented to unlock the machine before anyone can transact. Reader hardware is not yet on hand; this ADR defines the direction and a non-breaking skeleton that is fully testable without it.
## Decision
1. **Add a top-level `locked` state to the ATM state machine, and make it the initial state.** It sits *below* the existing initialization gates (`unpaired` / `awaiting-fees` / `maintenance` / `signer-unreachable`, which live in `App.vue`). A healthy, paired machine boots into `locked` and only reveals `idle` (Buy/Sell) after an access grant.
2. **Access control is opt-in via runtime config (`accessControl.enabled`, default `false`).** When disabled, the machine behaves exactly as today (boots straight to `idle`). This makes the whole feature non-breaking for the current test unit and for production ATMs, and lets a half-built access layer never brick a working box. This is the single most important constraint on the design.
3. **The reader lives behind an `AccessReader` abstraction that mirrors the existing `PairingSource` seam.** First implementation is a `MockAccessReader` (dev button / hotkey), so the locked→idle→transaction path is exercisable today with zero hardware. Web NFC and serial-NFC implementations follow.
4. **Credential model is a discriminated union with an explicit upgrade path.** v1 = card UID matched against a hashed allow-list. v2 = challenge-response (card-held key signs a machine nonce), verified against an operator-authorized set. Ship v1; design the types so v2 is additive.
5. **Three-tier developer bypass**, following existing conventions: a build flag (`VITE_SKIP_ACCESS_GATE`), the config disable (`accessControl.enabled=false`), and a runtime operator/dev unlock gesture that dispatches a synthetic grant.
6. **Authorization is owned by the machine operator, not the SaaS operator** — consistent with [ADR-002](./002-remote-access-and-fleet-management.md). The card allow-list is authorized by the operator key (the [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) allow-list mechanism), local first, operator-synced later. When access control is *enabled* and the reader is absent/broken, the machine **fails closed** (with the operator/dev unlock as the escape hatch); when *disabled*, reader state is irrelevant.
7. **Every grant/deny is audited** to `state.db` (hashed credential + timestamp + role + outcome), with optional later publication as a Nostr event. No PII, consistent with the KYC-free principle.
## Context
### What this is (and what it is not)
This is the **end-user physical access plane**: a person must badge in to use the machine. It is distinct from the three planes in [ADR-002](./002-remote-access-and-fleet-management.md) — it is *not* the operator's SSH/NetBird recovery plane, and *not* the SaaS payment plane. It shares one idea with ADR-002: **the machine operator owns who is authorized**, expressed through the operator key / #42 allow-list.
### Why the codebase is well-shaped for this
Three seams already exist; we extend them rather than invent:
- **The idle→transaction transition is unguarded.** `packages/state-machine/src/machine.ts` starts at `initial: 'idle'` (~L431) and `idle` moves to `cashIn`/`cashOut` via plain `SELECT_CASH_IN` / `SELECT_CASH_OUT` transitions with no guards (~L457-464). Inserting a `locked` predecessor state is a localized change.
- **`PairingSource` is a reader abstraction designed to grow.** Its doc (`apps/machine/src/services/pairing/types.ts`) explicitly anticipates *"an NFC reader or a HAL barcode scanner… a HAL-scanner source can be added the same way without touching the wizard."* `AccessReader` mirrors it: `qr-source.ts` / `nfc-source.ts` → `mock-reader.ts` / `web-nfc-reader.ts` / `serial-reader.ts`.
- **Dev-flag and config conventions are established.** `import.meta.env.VITE_* === 'true'` (e.g. `VITE_MAINTENANCE_MODE`, `VITE_FORCE_MOCK`), plus Electron `get-config` fields that the renderer reads (`electron/main.ts` L280–312: `maintenanceMode`, `branding`). A new `accessControl` config field and a `VITE_SKIP_ACCESS_GATE` flag follow the same shape.
### Hardware reality check (important)
The batm3 already exposes an NFC device, but it is **serial**: `deploy/nixos/hardware/batm3.nix` L154 maps udev serial `A9ZF8ELY` → `/dev/ttyNFC`. The *existing* `pairing/nfc-source.ts` uses **Web NFC** (`NDEFReader`), which drives a phone/laptop NFC radio, **not** a serial reader. So the real batm3 access reader needs a **main-process serial driver** (HAL-style, per [ADR-001](./001-hal-architecture.md)) exposing card events to the renderer over IPC — the Web NFC path is only useful for laptop/phone dev. This ADR keeps that driver as a clearly-scoped later PR so the skeleton doesn't pretend the scaffold "just works" on the panel.
## Architecture
### Boot / render layering
```
Electron get-config ─┐
▼
App.vue init gates (unchanged):
unpaired? → PairingWizard
initError (maintenance / awaiting-fees / signer-unreachable)? → maintenance screen
else ▼
State machine (paired + healthy):
┌───────────────────────────────────────────────┐
│ locked ──ACCESS_GRANTED──▶ idle │ ← NEW initial state
│ ▲ │ SELECT_CASH_* │
│ │ re-lock (session end / ▼ │
│ │ inactivity / complete) cashIn / cashOut │
│ └──────────────────────────┘ │
└───────────────────────────────────────────────┘
(when accessControl.enabled === false,
`locked` immediately `always`-bypasses to `idle`)
```
The access gate is strictly below App.vue's init gates: a machine that is unpaired or in maintenance never reaches `locked`.
### 1. State machine (`packages/state-machine`)
- New top-level state `locked`, `initial: 'locked'`.
- New events on the machine's event union: `ACCESS_GRANTED` (carries an authorized `CardCredential` + resolved role), `ACCESS_DENIED` (carries a reason), `DEV_UNLOCK`.
- `locked` transitions:
- `always: [{ guard: 'accessBypass', target: 'idle' }]` — instant pass-through when disabled/bypassed (no UI flicker; the view is gated on the same predicate).
- `on: { ACCESS_GRANTED: { target: 'idle', actions: ['startSession', 'recordAccessGrant'] }, ACCESS_DENIED: { actions: 'recordAccessDeny' }, DEV_UNLOCK: { guard: 'devUnlockAllowed', target: 'idle', actions: 'startDevSession' } }`.
- Re-lock: the existing `complete` auto-return (currently 60s → `idle`) and the inactivity timeouts (`INACTIVITY_TIMEOUT`/`TIMEOUT_MS`, ~L422-429) target `locked` instead of `idle`. Because `locked` `always`-bypasses when disabled, this is one code path for both modes.
- Purity: the state-machine package must not read Vite env. `accessControl.enabled` and the bypass boolean are passed in as **actor input → context** (`context.accessControlEnabled`, `context.accessBypass`); guard `accessBypass` reads context only. New context fields: `accessControlEnabled`, `accessBypass`, `session` (`{ role, grantedAt, credentialIdHash } | null`).
- Guards: `accessBypass`, `devUnlockAllowed`. Actions: `startSession`, `startDevSession`, `recordAccessGrant`, `recordAccessDeny`, and `resetContext` extended to clear `session`.
### 2. Config plumbing
- `apps/machine/src/types/electron.d.ts` — extend `RuntimeConfig` (L5) with:
```ts
accessControl: {
enabled: boolean // default false
devUnlock: boolean // allow the runtime operator/dev unlock gesture
// v2: allowListSource, challengeRequired, …
}
```
- `apps/machine/electron/main.ts` — the `get-config` handler (L280) returns `accessControl`, sourced from env for now (`ACCESS_CONTROL_ENABLED === 'true'`, `VITE_SKIP_ACCESS_GATE` → forces `enabled:false`), later from a provisioned file under `/var/lib/bitspire/` alongside branding.
- `apps/machine/.env.example` — document `VITE_SKIP_ACCESS_GATE=true` (browser/dev straight to idle) and `ACCESS_CONTROL_ENABLED`.
### 3. Reader abstraction (`apps/machine/src/services/access/`)
Mirrors `services/pairing/`:
```
services/access/
types.ts # AccessReader, CardCredential (union), AccessRole, StopCapture
mock-reader.ts # PR1 — fires a card event on demand (dev button / hotkey)
web-nfc-reader.ts # PR3 — NDEFReader, dev on laptop/phone
serial-reader.ts # PR4 — /dev/ttyNFC via main-process HAL + IPC
authorize.ts # allow-list check + role resolution (hashed UID v1)
index.ts # availableAccessReaders(): AccessReader[]
__tests__/
```
```ts
export type AccessRole = 'user' | 'operator'
export type CardCredential =
| { kind: 'uid'; uidHash: string } // v1
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string } // v2 (seam)
export interface AccessReader {
readonly kind: 'mock' | 'nfc-web' | 'nfc-serial'
readonly label: string
isAvailable(): Promise<boolean>
start(opts: {
onCard: (cred: CardCredential) => void
onError?: (e: unknown) => void
}): Promise<StopCapture>
}
```
### 4. Renderer wiring
- `apps/machine/src/views/LockedView.vue` (new) — the badge-in screen. Shows brand/logo + "Present your card", a live reader status, and (when `accessControl.devUnlock`) a discreet operator/dev unlock affordance (hidden long-press corner, or a button on the existing debug bar).
- `apps/machine/src/App.vue` — add a `locked` render branch mirroring the `PairingWizard` branch (L179) and the `initError` branch (L183): `<LockedView v-else-if="atmStore.isLocked" />`, then the existing `<router-view>` only when unlocked. Keeps rendering state-driven and matches the current shape.
- `apps/machine/src/stores/atm.ts` —
- `createActor(machine, { input: { accessControlEnabled, accessBypass } })` at the existing `createActor(machine)` site (L452), seeded from `RuntimeConfig`.
- `isLocked` computed off the snapshot (peer of `isIdle`, ~L422).
- `grantAccess(cred, role)` / `denyAccess(reason)` / `devUnlock()` that `send({ type: 'ACCESS_GRANTED' | 'ACCESS_DENIED' | 'DEV_UNLOCK', … })` (peers of `selectCashIn` at L1382, using the existing `send` at L1378).
- On init, when `accessControl.enabled`, subscribe to `availableAccessReaders()[0]`; on `onCard`, run `authorize()` → `grantAccess`/`denyAccess`. When disabled, do nothing (machine `always`-bypasses).
### 5. Audit
- Add `recordAccessEvent({ credentialIdHash, role, outcome, at })` alongside the existing state.db handlers (`state:record-transaction` etc. in `electron/main.ts`, exposed via `preload.ts`). v1 writes locally; a later PR can mirror to a replaceable Nostr event.
### Session semantics (decided)
**One badge = one transaction-scoped session.** A grant unlocks `idle`, the user runs a single transaction (Buy or Sell), and the machine re-locks on `complete`, on inactivity, or on an explicit "Done". The `session` context field is deliberately shaped as a general access session (`{ role, grantedAt, credentialIdHash }`), not a transaction handle, because this terminal may later handle **non-transaction functions** — so "unlock the terminal" and "authorize a transaction" stay separate concepts.
**Step-up authorization (future seam, not in PR1).** The badge tap grants *terminal access*; a specific sensitive action can independently *request re-authorization* — e.g. "tap your phone" or "enter a PIN" — without conflating the two. This is why the credential model is a union and the machine carries a `session` rather than a boolean "unlocked": a later `REQUIRE_STEPUP` event can gate an individual action against a fresh credential/PIN while the terminal session stays open. PR1 ships only the entry gate; step-up is a documented extension.
## Alternatives considered
- **Gate between `idle` and the transaction** (idle visible, tap requires a card). Rejected: the requirement is "gain access to the exchange" — the whole machine should be locked, not just the transact button. A `locked` predecessor matches the mental model and gives a clean re-lock boundary.
- **Web NFC only** (reuse `nfc-source.ts` as-is). Rejected: the batm3 reader is serial (`ttyNFC`); Web NFC can't drive it. Web NFC stays a dev-only convenience.
- **Fail-open by default** (no card → allow). Rejected for an access-control feature; but note the *disabled* default sidesteps this — access is simply off until an operator turns it on, at which point it fails **closed**.
- **OS/kiosk-level lock** (lock the desktop, not the app). Rejected: too coarse, no per-transaction audit, no role model, and it fights the existing state-driven UI.
- **UID allow-list as the permanent model.** Rejected as an endpoint (UIDs clone trivially) but accepted as v1 behind a union type, so challenge-response is additive.
## Security considerations
- **UID cloning** — card UIDs are not secret and are cloneable; v1 is "better than nothing" and is explicitly labeled upgradeable. The v2 challenge-response path (card signs a machine nonce) is the real security boundary; design the credential union and the `authorize()` seam for it now.
- **Data at rest** — store only a salted hash of the credential id; never raw UIDs or any PII (KYC-free). Never log a card secret or nsec (repo security priority #1).
- **Fail-closed when enabled** — reader absent/broken + `enabled` ⇒ locked, escape hatch = operator/dev unlock. Reader problems on a *disabled* machine are inert.
- **Operator ownership** — authorization derives from the operator key / #42 allow-list, not the SaaS operator (ADR-002 boundary). Local allow-list first; operator-published (NIP-51-style) sync later.
- **Dev bypass blast radius** — `VITE_SKIP_ACCESS_GATE` is build-time and never set in a production image; `devUnlock` is gated by `accessControl.devUnlock` (off in a locked-down deployment) and every dev unlock is audited with role `operator`/`dev`.
## Resolved decisions (2026-07-29)
1. **Session model** — ✅ one badge = one **transaction-scoped session**, with the `session` context modeled generally (terminal access, not a transaction handle) to allow non-transaction functions and per-action **step-up auth** (tap phone / PIN) later. See *Session semantics* above.
2. **v1 credential** — ✅ **UID allow-list first** (hashed), behind a `CardCredential` union so challenge-response is additive (PR5).
3. **Allow-list home** — ✅ **local first** (`state.db` / provisioned `access.json`, peer of `branding/`); operator-Nostr sync is a later PR.
Still open (cosmetic, decide during PR2):
4. **Dev unlock affordance** — hidden long-press corner vs a labeled button on the existing debug bar.
---
## Implementation plan (phased PRs)
Each PR is independently mergeable. **PR1 changes nothing observable while `accessControl.enabled=false` (the default).**
### PR1 — Non-breaking skeleton (state + config + mock reader + dev bypass + audit stub)
**Goal:** the locked→idle→transaction path is exercisable on the batm3 today, and the flag-off machine is byte-for-byte behavior-identical.
- `packages/state-machine`: add `locked` state (`initial`), `ACCESS_GRANTED`/`ACCESS_DENIED`/`DEV_UNLOCK` events, `accessBypass`/`devUnlockAllowed` guards, `startSession`/`recordAccess*` actions, context fields + actor `input`. Re-point `complete`/inactivity re-lock targets to `locked`.
- `apps/machine/src/types/electron.d.ts`: `RuntimeConfig.accessControl`.
- `apps/machine/electron/main.ts`: `get-config` returns `accessControl` (env-sourced); `.env.example` documents `VITE_SKIP_ACCESS_GATE` + `ACCESS_CONTROL_ENABLED`.
- `apps/machine/src/services/access/`: `types.ts`, `mock-reader.ts`, `authorize.ts` (UID allow-list, hashed), `index.ts`.
- `apps/machine/src/stores/atm.ts`: actor `input`, `isLocked`, `grantAccess`/`denyAccess`/`devUnlock`, reader subscription (only when enabled).
- `apps/machine/src/views/LockedView.vue` + `App.vue` `locked` render branch.
- Audit stub: `recordAccessEvent` handler + preload exposure (local write only).
- **Tests:** state-machine `locked → idle` on grant; `always`-bypass when disabled; re-lock from `complete`; `authorize()` allow/deny; `devUnlock` gated by config.
- **Flag state on merge:** `enabled=false`. CI green = no behavior change.
### PR2 — Locked UX polish
- Reader status/animation, brand-aware LockedView, denied-flash + reason, inactivity copy, operator/dev unlock affordance per open-question #4. Pure renderer.
### PR3 — Web NFC reader (dev)
- `web-nfc-reader.ts` (`NDEFReader`), registered in `availableAccessReaders()` behind availability check. Lets a laptop/phone drive the gate for demos/dev. No hardware dependency.
### PR4 — Serial NFC HAL driver (real batm3 hardware)
- Main-process serial driver for `/dev/ttyNFC` (HAL-style per ADR-001), IPC channel `access:watch-card` + `preload.ts` exposure; `serial-reader.ts` renderer client. Requires the physical reader to validate. Document the reader's protocol/baud in `docs/device-configuration.md`.
### PR5 — Challenge-response credential + operator allow-list sync
- Extend `CardCredential` with the `challenge` variant; `authorize.ts` verifies a signature over a machine nonce against the operator-authorized set; allow-list synced from an operator-published event (#42 mechanism). This is the real security upgrade; v1 UID path stays as a fallback/dev mode.
### Cross-cutting
- **Docs:** update `docs/machine-installation.md` (enabling access control, enrolling cards) and `deploy/nixos/README.md` (the `accessControl` config + `/dev/ttyNFC`) as PR4/PR5 land.
- **Provisioning:** a later change can add an `access.json` under `/var/lib/bitspire/` (peer of `branding/`) with the allow-list + `enabled`, plus a `provision-access.sh` mirroring `provision-branding.sh`.

View file

@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest'
import { createActor } from 'xstate'
import { createATMMachine } from '../machine.js'
// ADR-003 access gate. Key invariant: with the gate DISABLED (the default),
// the machine is behaviourally identical to the pre-access machine — it
// settles into `idle` on start via the `locked` state's `always` bypass.
describe('ATM access control (ADR-003)', () => {
describe('gate disabled (default)', () => {
it('settles into idle on start (non-breaking)', () => {
const actor = createActor(createATMMachine())
actor.start()
expect(actor.getSnapshot().value).toBe('idle')
})
it('settles into idle even with accessControlEnabled:false explicit', () => {
const actor = createActor(createATMMachine({}, { accessControlEnabled: false }))
actor.start()
expect(actor.getSnapshot().value).toBe('idle')
})
})
describe('gate enabled', () => {
it('stays locked on start', () => {
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
actor.start()
expect(actor.getSnapshot().value).toBe('locked')
expect(actor.getSnapshot().context.accessSession).toBeNull()
})
it('ACCESS_GRANTED unlocks to idle and records the session', () => {
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
actor.start()
actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc123' })
const snap = actor.getSnapshot()
expect(snap.value).toBe('idle')
expect(snap.context.accessSession).toMatchObject({ role: 'user', credentialIdHash: 'abc123' })
expect(typeof snap.context.accessSession?.grantedAt).toBe('number')
})
it('ACCESS_DENIED stays locked and surfaces the reason', () => {
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
actor.start()
actor.send({ type: 'ACCESS_DENIED', reason: 'card not authorized' })
const snap = actor.getSnapshot()
expect(snap.value).toBe('locked')
expect(snap.context.accessDenyReason).toBe('card not authorized')
})
it('DEV_UNLOCK unlocks to idle (operator session)', () => {
const actor = createATMMachine({}, { accessControlEnabled: true })
const running = createActor(actor)
running.start()
running.send({ type: 'DEV_UNLOCK' })
const snap = running.getSnapshot()
expect(snap.value).toBe('idle')
expect(snap.context.accessSession?.role).toBe('operator')
})
})
describe('build/dev bypass', () => {
it('accessBypassFlag opens the gate even when enabled', () => {
const actor = createActor(
createATMMachine({}, { accessControlEnabled: true, accessBypassFlag: true })
)
actor.start()
expect(actor.getSnapshot().value).toBe('idle')
})
it('DEV_UNLOCK is a no-op while bypassing (already idle)', () => {
const actor = createActor(
createATMMachine({}, { accessControlEnabled: true, accessBypassFlag: true })
)
actor.start()
// already idle; DEV_UNLOCK guard is false, so no throw / no change
actor.send({ type: 'DEV_UNLOCK' })
expect(actor.getSnapshot().value).toBe('idle')
})
})
})

View file

@ -52,6 +52,8 @@ export {
type PaymentMethod,
type DispenseCashResult,
type CassetteBillResult,
type AccessRole,
type AccessSession,
initialContext,
} from './types.js'

View file

@ -22,6 +22,14 @@ export interface ATMMachineOptions {
currency?: string
cashInFeeFraction?: number
cashOutFeeFraction?: number
/**
* ADR-003 access gate. When true, the machine boots into `locked` and
* waits for an ACCESS_GRANTED (or DEV_UNLOCK) before reaching `idle`.
* Defaults false → `locked` immediately bypasses to `idle` (no gate).
*/
accessControlEnabled?: boolean
/** Build/dev bypass (VITE_SKIP_ACCESS_GATE) — opens the gate even when enabled. */
accessBypassFlag?: boolean
}
export function createATMMachine(
@ -167,9 +175,41 @@ export function createATMMachine(
inventory: context.inventory,
cashInFeeFraction: context.cashInFeeFraction,
cashOutFeeFraction: context.cashOutFeeFraction,
// Preserve the access-gate config across resets — it comes from
// machine options, not the transaction, and must survive re-lock.
accessControlEnabled: context.accessControlEnabled,
accessBypassFlag: context.accessBypassFlag,
// Preserve the active access session: `idle`'s entry runs resetContext
// AFTER the locked→idle transition action that set the session, so
// without this the just-granted session would be wiped. The session is
// cleared instead on re-lock (locked's entry), i.e. when access ends.
accessSession: context.accessSession,
cashInSessionId: null,
dispenseResult: null,
})),
// ADR-003 access-control actions
startAccessSession: assign({
accessSession: ({ event }) => {
if (event.type !== 'ACCESS_GRANTED') return null
return { role: event.role, grantedAt: Date.now(), credentialIdHash: event.credentialIdHash }
},
accessDenyReason: null,
}),
startDevAccessSession: assign({
accessSession: () => ({
role: 'operator' as const,
grantedAt: Date.now(),
credentialIdHash: 'dev-unlock',
}),
accessDenyReason: null,
}),
setAccessDenyReason: assign({
accessDenyReason: ({ event }) => (event.type === 'ACCESS_DENIED' ? event.reason : null),
}),
clearAccessDenyReason: assign({ accessDenyReason: null }),
// On (re-)entering `locked`, access has ended: drop any prior session so a
// stale grant can't leak across the gate.
clearAccessSession: assign({ accessSession: null }),
setStartTime: assign({
startedAt: () => Date.now(),
txid: () => generateTxId(),
@ -376,6 +416,12 @@ export function createATMMachine(
}),
},
guards: {
// ADR-003: gate is open when access control is off, or the build/dev
// bypass is set. Used by `locked`'s eventless `always` transition so a
// machine with the gate disabled settles straight into `idle`.
accessBypass: ({ context }) => !context.accessControlEnabled || context.accessBypassFlag,
// The dev unlock is only meaningful when the gate is actually engaged.
devUnlockAllowed: ({ context }) => context.accessControlEnabled && !context.accessBypassFlag,
hasInsertedBills: ({ context }) => context.billsInserted.length > 0,
// Legacy brain.js parity: "send coins" is a no-op while a bill is
// between the stack command and the validator's stacked-confirmation.
@ -429,7 +475,10 @@ export function createATMMachine(
},
}).createMachine({
id: 'atm',
initial: 'idle',
// ADR-003: `locked` is the resting state. With the gate disabled (the
// default), its `always` transition bypasses straight to `idle` on start,
// so behavior is identical to the pre-access machine.
initial: 'locked',
context: {
...initialContext,
...(options?.currency ? { currency: options.currency } : {}),
@ -437,6 +486,12 @@ export function createATMMachine(
...(options?.cashOutFeeFraction !== undefined
? { cashOutFeeFraction: options.cashOutFeeFraction }
: {}),
...(options?.accessControlEnabled !== undefined
? { accessControlEnabled: options.accessControlEnabled }
: {}),
...(options?.accessBypassFlag !== undefined
? { accessBypassFlag: options.accessBypassFlag }
: {}),
},
// Root-level handler: lets the operator-fees subscriber update the
// active fee fractions reactively. Next cashIn/cashOut entry will
@ -451,6 +506,22 @@ export function createATMMachine(
},
},
states: {
// === ACCESS GATE (ADR-003) ===
// Resting/locked state. A paired, healthy machine sits here until a
// valid credential is presented. When the gate is disabled (default)
// the eventless `always` transition immediately hands off to `idle`,
// so a non-access machine never dwells here.
locked: {
entry: ['clearAccessDenyReason', 'clearAccessSession'],
always: [{ guard: 'accessBypass', target: 'idle' }],
on: {
ACCESS_GRANTED: { target: 'idle', actions: 'startAccessSession' },
DEV_UNLOCK: { guard: 'devUnlockAllowed', target: 'idle', actions: 'startDevAccessSession' },
// A denied tap keeps us locked; record the reason for the screen.
ACCESS_DENIED: { actions: 'setAccessDenyReason' },
},
},
idle: {
entry: 'resetContext',
on: {
@ -491,7 +562,7 @@ export function createATMMachine(
INACTIVITY_TIMEOUT: [
{
guard: ({ context }) => context.billsInserted.length === 0,
target: '#atm.idle',
target: '#atm.locked',
},
{
target: 'confirmAbandon',
@ -524,7 +595,7 @@ export function createATMMachine(
{
// No bills inserted yet: safe to cancel
guard: ({ context }) => context.billsInserted.length === 0,
target: '#atm.idle',
target: '#atm.locked',
},
{
// Bills already stacked: warn user before abandoning
@ -534,7 +605,7 @@ export function createATMMachine(
TIMEOUT: [
{
guard: ({ context }) => context.billsInserted.length === 0,
target: '#atm.idle',
target: '#atm.locked',
},
{
target: 'confirmAbandon',
@ -586,10 +657,10 @@ export function createATMMachine(
// like the rest — clear the marker so nothing blocks on it.
entry: 'clearBillPending',
after: {
60000: '#atm.idle',
60000: '#atm.locked',
},
on: {
CANCEL: '#atm.idle', // User confirms they want to leave
CANCEL: '#atm.locked', // User confirms they want to leave
RETRY: 'generatingNdebit', // Go back and try again
},
},
@ -614,10 +685,10 @@ export function createATMMachine(
},
complete: {
after: {
COMPLETE_DELAY: '#atm.idle',
COMPLETE_DELAY: '#atm.locked',
},
on: {
CANCEL: '#atm.idle',
CANCEL: '#atm.locked',
},
},
error: {
@ -646,7 +717,7 @@ export function createATMMachine(
{
// No bills inserted: safe to cancel
guard: ({ context }) => context.billsInserted.length === 0,
target: '#atm.idle',
target: '#atm.locked',
},
{
// Bills inserted: show abandon warning first
@ -689,7 +760,7 @@ export function createATMMachine(
// User selects denomination buttons to build up the cash amount
// UI shows: available denominations, running total, sats equivalent
after: {
INACTIVITY_TIMEOUT: '#atm.idle',
INACTIVITY_TIMEOUT: '#atm.locked',
},
entry: 'clearCashOutSelection',
on: {
@ -709,8 +780,8 @@ export function createATMMachine(
target: 'generatingInvoice',
actions: 'calculateDispenseFromSelection',
},
CANCEL: '#atm.idle',
TIMEOUT: '#atm.idle',
CANCEL: '#atm.locked',
TIMEOUT: '#atm.locked',
},
},
generatingInvoice: {
@ -758,7 +829,7 @@ export function createATMMachine(
TIMEOUT: {
target: 'selectingAmount',
},
CANCEL: '#atm.idle',
CANCEL: '#atm.locked',
},
},
dispensingCash: {
@ -826,20 +897,20 @@ export function createATMMachine(
},
complete: {
after: {
COMPLETE_DELAY: '#atm.idle',
COMPLETE_DELAY: '#atm.locked',
},
on: {
CANCEL: '#atm.idle',
CANCEL: '#atm.locked',
},
},
dispenseError: {
// Payment received but cash not (fully) dispensed.
// Show error + txid for 30s, then auto-idle (matches brain.js _timedState).
after: {
DISPENSE_ERROR_TIMEOUT: '#atm.idle',
DISPENSE_ERROR_TIMEOUT: '#atm.locked',
},
on: {
CANCEL: '#atm.idle',
CANCEL: '#atm.locked',
},
},
error: {
@ -849,7 +920,7 @@ export function createATMMachine(
target: 'fetchingRate',
actions: 'incrementRetry',
},
CANCEL: '#atm.idle',
CANCEL: '#atm.locked',
},
},
},

View file

@ -48,8 +48,47 @@ export interface OfferRequestEvent {
description?: string
}
/**
* Access-control role resolved from a presented credential.
* `user` may transact; `operator` may additionally reach operator
* functions (config/maintenance/enrollment) — reserved for later PRs.
* See ADR-003.
*/
export type AccessRole = 'user' | 'operator'
/**
* An active access session, created when a valid credential is presented
* (or via the dev unlock). Modeled as general *terminal access*, not a
* transaction handle, so the terminal can later gate non-transaction
* functions and per-action step-up auth (ADR-003).
*/
export interface AccessSession {
role: AccessRole
/** ms epoch when access was granted */
grantedAt: number
/** salted hash of the presented credential id — never the raw UID (KYC-free) */
credentialIdHash: string
}
/** ATM machine context */
export interface ATMContext {
// Access control (ADR-003)
/**
* Whether the badge-to-enter access gate is active. When false (the
* default), the machine's `locked` initial state immediately bypasses
* to `idle` — behavior is identical to a machine with no access layer.
*/
accessControlEnabled: boolean
/**
* Build/dev bypass (VITE_SKIP_ACCESS_GATE). Forces the gate open even
* when accessControlEnabled is true — for browser dev / CI.
*/
accessBypassFlag: boolean
/** Active access session, or null while locked. */
accessSession: AccessSession | null
/** Reason for the last denied access attempt (for the locked screen). */
accessDenyReason: string | null
// Transaction details
/** Fiat amount in cents */
fiatCents: number
@ -136,6 +175,10 @@ export type ATMEvent =
| { type: 'SELECT_CASH_IN' }
| { type: 'SELECT_CASH_OUT' }
| { type: 'CANCEL' }
// Access control (ADR-003)
| { type: 'ACCESS_GRANTED'; role: AccessRole; credentialIdHash: string }
| { type: 'ACCESS_DENIED'; reason: string }
| { type: 'DEV_UNLOCK' }
| { type: 'SELECT_AMOUNT'; amount: number }
| { type: 'FINISH_INSERTING' }
| { type: 'USER_SCANNED_NPUB'; npub: string }
@ -173,6 +216,12 @@ export type ATMEvent =
/** Initial context values */
export const initialContext: ATMContext = {
// Access control defaults OFF — a machine built without the access
// options behaves exactly as before (locked → bypass → idle). See ADR-003.
accessControlEnabled: false,
accessBypassFlag: false,
accessSession: null,
accessDenyReason: null,
fiatCents: 0,
satsAmount: 0,
currency: 'USD',