feat(access): auto re-lock the idle menu after inactivity

An unlocked session left unattended (card tapped in, no transaction) stayed
at idle indefinitely, so anyone could then transact on the loaded card. Add
an IDLE_LOCK_TIMEOUT (60s) after-transition on idle → locked, guarded by
accessGateActive so a gate-disabled machine (which rests at idle) never
re-locks. Selecting cash-in/out leaves idle and cancels the timer; the store
clears the loaded Bolt Card on re-lock. Transaction flows already re-lock on
their own inactivity timeouts.
This commit is contained in:
Patrick Mulligan 2026-08-07 01:44:10 +02:00
commit d4314adc52
2 changed files with 46 additions and 1 deletions

View file

@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { createActor } from 'xstate'
import { createATMMachine } from '../machine.js'
@ -61,6 +61,35 @@ describe('ATM access control (ADR-003)', () => {
})
})
describe('idle inactivity re-lock', () => {
it('re-locks the idle menu after IDLE_LOCK_TIMEOUT when the gate is active', () => {
vi.useFakeTimers()
try {
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
actor.start()
actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc' })
expect(actor.getSnapshot().value).toBe('idle')
vi.advanceTimersByTime(60000)
expect(actor.getSnapshot().value).toBe('locked')
} finally {
vi.useRealTimers()
}
})
it('does not re-lock idle when the gate is disabled', () => {
vi.useFakeTimers()
try {
const actor = createActor(createATMMachine()) // gate off → rests at idle
actor.start()
expect(actor.getSnapshot().value).toBe('idle')
vi.advanceTimersByTime(120000)
expect(actor.getSnapshot().value).toBe('idle')
} finally {
vi.useRealTimers()
}
})
})
describe('build/dev bypass', () => {
it('accessBypassFlag opens the gate even when enabled', () => {
const actor = createActor(

View file

@ -422,6 +422,12 @@ export function createATMMachine(
accessBypass: ({ context }) => !context.accessControlEnabled || context.accessBypassFlag,
// The dev unlock is only meaningful when the gate is actually engaged.
devUnlockAllowed: ({ context }) => context.accessControlEnabled && !context.accessBypassFlag,
// Gate is actively engaged (enabled + not bypassed) — used to auto re-lock
// the `idle` menu on inactivity so an unattended unlocked session (a tapped
// card left behind) can't be used by the next person. Same condition as
// devUnlockAllowed; named for the lock-timeout intent.
accessGateActive: ({ 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.
@ -472,6 +478,8 @@ export function createATMMachine(
COMPLETE_DELAY: 60000,
DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond
DISPENSE_ERROR_TIMEOUT: 30000, // 30s like brain.js _timedState
// Auto re-lock the idle menu after this long unattended (gate active only).
IDLE_LOCK_TIMEOUT: 60000, // 60s
},
}).createMachine({
id: 'atm',
@ -524,6 +532,14 @@ export function createATMMachine(
idle: {
entry: 'resetContext',
// When the gate is engaged, an unlocked-but-idle terminal auto re-locks
// after IDLE_LOCK_TIMEOUT so a session left unattended (card loaded, no
// transaction) returns to the lock screen. Guarded so a gate-disabled
// machine (which rests at idle) never re-locks. Selecting cash-in/out
// leaves idle and cancels this timer.
after: {
IDLE_LOCK_TIMEOUT: { guard: 'accessGateActive', target: 'locked' },
},
on: {
SELECT_CASH_IN: {
target: 'cashIn',