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.
This commit is contained in:
Patrick Mulligan 2026-08-06 22:16:56 +02:00 • committed by Padreug
commit a7b409b109
20 changed files with 1534 additions and 28 deletions

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