feat(access): Bolt Card tap-to-enter access gate (ADR-003) #86
2 changed files with 46 additions and 1 deletions
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.
commit
6676c26761
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue