fix(access): only accept END_SESSION from idle so the session cap can't strand funds

The root-level END_SESSION let the 10-minute hard cap (and the End Session
button) jump to `locked` from any state, bypassing the money-path guards
the machine already has: confirmAbandon with bills stacked, an in-flight
dispense, an outbound cash-in payment. Cap fires at minute 10 while a
customer's bills sit in the stacker → locked → next unlock resetContext
wipes them unpaid; during dispensingCash the done-event is dropped and
no transaction record is written.

Nothing is lost by scoping it: every transaction terminal state already
targets #atm.locked on this branch, so the machine re-locks on its own
when the transaction ends. END_SESSION now lives on idle.on only, and
useSessionSecurity defers both deadlines until currentState is idle —
an expired session re-locks on the first tick back at the menu.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-20 14:11:38 +02:00
commit 5114619fce
3 changed files with 34 additions and 19 deletions

View file

@ -17,7 +17,11 @@ import { useAtmStore } from '@/stores/atm'
* (those carry their own, longer machine timeouts).
* - HARD cap (HARD_CAP_MS): re-lock this long after the session began,
* regardless of activity. Anchored to unlock time and never reset — an
* absolute ceiling a forgotten or relayed card can't hold open.
* absolute ceiling a forgotten or relayed card can't hold open. Like the
* soft limit it only fires while on the idle menu: locking mid-transaction
* would strand stacked bills or an in-flight dispense, and every
* transaction already returns to `locked` on its own, so the cap simply
* takes effect the moment the machine is back at idle.
*
* Security properties:
* - Only `event.isTrusted` input resets the soft timer, so synthetic/scripted
@ -77,6 +81,10 @@ export function useSessionSecurity() {
// can only ever make it fire late-then-immediately, never early.
useIntervalFn(() => {
if (sessionStartedAt === null || atm.isLocked || !atm.accessControl.enabled) return
// Never lock out from under a transaction (the machine only accepts
// END_SESSION from idle anyway); the deadlines keep counting meanwhile, so
// an expired session re-locks on the first tick back at the menu.
if (atm.currentState !== 'idle') return
const now = Date.now()
// Hard cap first — absolute, activity-independent.
@ -86,8 +94,8 @@ export function useSessionSecurity() {
return
}
// Soft inactivity — idle menu only, resets on trusted input.
if (atm.currentState === 'idle' && now - lastActivityAt >= SOFT_IDLE_MS) {
// Soft inactivity — resets on trusted input.
if (now - lastActivityAt >= SOFT_IDLE_MS) {
disarm()
atm.endSession('inactivity')
}