From d4314adc528ebf869decef06fd598443120bbe0d Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 7 Aug 2026 01:44:10 +0200 Subject: [PATCH] feat(access): auto re-lock the idle menu after inactivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/__tests__/access-control.test.ts | 31 ++++++++++++++++++- packages/state-machine/src/machine.ts | 16 ++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/state-machine/src/__tests__/access-control.test.ts b/packages/state-machine/src/__tests__/access-control.test.ts index d9dff5c..1302f81 100644 --- a/packages/state-machine/src/__tests__/access-control.test.ts +++ b/packages/state-machine/src/__tests__/access-control.test.ts @@ -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( diff --git a/packages/state-machine/src/machine.ts b/packages/state-machine/src/machine.ts index c1a97b3..9943fc6 100644 --- a/packages/state-machine/src/machine.ts +++ b/packages/state-machine/src/machine.ts @@ -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',