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:
parent
2ea3df01d1
commit
a7b409b109
20 changed files with 1534 additions and 28 deletions
83
packages/state-machine/src/__tests__/access-control.test.ts
Normal file
83
packages/state-machine/src/__tests__/access-control.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -52,6 +52,8 @@ export {
|
|||
type PaymentMethod,
|
||||
type DispenseCashResult,
|
||||
type CassetteBillResult,
|
||||
type AccessRole,
|
||||
type AccessSession,
|
||||
initialContext,
|
||||
} from './types.js'
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue