From 5114619fced94a31b5118dd1167cb09fcb881309 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 20 Sep 2026 14:11:38 +0200 Subject: [PATCH] fix(access): only accept END_SESSION from idle so the session cap can't strand funds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/composables/useSessionSecurity.ts | 14 ++++++++--- .../src/__tests__/access-control.test.ts | 16 ++++++++----- packages/state-machine/src/machine.ts | 23 +++++++++++-------- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/apps/machine/src/composables/useSessionSecurity.ts b/apps/machine/src/composables/useSessionSecurity.ts index 6317474..8420005 100644 --- a/apps/machine/src/composables/useSessionSecurity.ts +++ b/apps/machine/src/composables/useSessionSecurity.ts @@ -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') } diff --git a/packages/state-machine/src/__tests__/access-control.test.ts b/packages/state-machine/src/__tests__/access-control.test.ts index e3a6477..3c059c7 100644 --- a/packages/state-machine/src/__tests__/access-control.test.ts +++ b/packages/state-machine/src/__tests__/access-control.test.ts @@ -65,7 +65,8 @@ describe('ATM access control (ADR-003)', () => { // entry-anchored timer can't measure inactivity (it never resets on screen // touches). It's enforced at the DOM layer (useSessionSecurity), which sends // END_SESSION on true idleness / at the hard cap. The machine's contract is - // just: END_SESSION re-locks from any unlocked state when the gate is active. + // just: END_SESSION re-locks from idle when the gate is active, and is + // ignored mid-transaction (a transaction re-locks on its own when it ends). describe('session end (button / inactivity / hard cap all route here)', () => { it('END_SESSION re-locks immediately from idle when the gate is active', () => { const actor = createActor(createATMMachine({}, { accessControlEnabled: true })) @@ -76,9 +77,10 @@ describe('ATM access control (ADR-003)', () => { expect(actor.getSnapshot().value).toBe('locked') }) - it('END_SESSION re-locks from an in-flight cash-out (hard-cap path)', () => { - // The absolute session cap must be able to lock mid-transaction, so the - // transition lives at the machine root, not only on `idle`. + it('END_SESSION is ignored mid-transaction (never strands funds in flight)', () => { + // A root-level END_SESSION would bypass confirmAbandon / an in-flight + // dispense. The transition lives on `idle` only; a transaction's own + // terminal states return to `locked` when it ends. const rateServices = { getExchangeRate: () => Promise.resolve(2500), getAvailableBalance: () => Promise.resolve(1_000_000), @@ -87,9 +89,11 @@ describe('ATM access control (ADR-003)', () => { actor.start() actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc' }) actor.send({ type: 'SELECT_CASH_OUT' }) - expect(actor.getSnapshot().value).not.toBe('locked') // now inside cashOut + const before = actor.getSnapshot().value + expect(before).not.toBe('locked') // now inside cashOut actor.send({ type: 'END_SESSION' }) - expect(actor.getSnapshot().value).toBe('locked') + expect(actor.getSnapshot().value).toEqual(before) + expect(actor.getSnapshot().context.accessSession).not.toBeNull() }) it('END_SESSION is a no-op when the gate is disabled (stays at idle)', () => { diff --git a/packages/state-machine/src/machine.ts b/packages/state-machine/src/machine.ts index 2a11569..c5d4c1a 100644 --- a/packages/state-machine/src/machine.ts +++ b/packages/state-machine/src/machine.ts @@ -513,14 +513,6 @@ export function createATMMachine( cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction, }), }, - // Root-level session kill switch (ADR-003). Any unlocked state re-locks - // on END_SESSION — the "End session" button, the idle-inactivity timer, - // and the absolute session cap all route here (see useSessionSecurity). - // Placed at the root so the hard cap can lock mid cash-in/out, not just - // from idle. Guarded to the active gate so a gate-disabled machine (which - // rests at idle) can't be knocked out of it. `locked`'s entry clears the - // access session + loaded card. Fail-closed: locking is always allowed. - END_SESSION: { guard: 'accessGateActive', target: '#atm.locked' }, }, states: { // === ACCESS GATE (ADR-003) === @@ -546,9 +538,20 @@ export function createATMMachine( // touches (the machine can't see raw pointer events), so it would fire // a fixed countdown regardless of activity. Inactivity is measured at // the DOM layer (useSessionSecurity) and drives END_SESSION on true - // idleness. The hard session cap is handled the same way. Both re-lock - // via the root-level END_SESSION transition above. + // idleness. The hard session cap is handled the same way. on: { + // Session kill switch (ADR-003): the "End session" button, the + // idle-inactivity timer, and the absolute session cap all route here. + // Deliberately handled ONLY from `idle`, not at the machine root: a + // root-level END_SESSION would bypass the money-path protections + // (`confirmAbandon` with bills stacked, an in-flight dispense, an + // outbound payment) and strand the customer's funds. Every + // transaction terminal state already returns to `locked` on its own, + // so a cap that fires mid-transaction has nothing to gain — the + // DOM-layer timers defer until the machine is back at idle. Guarded to + // the active gate so a gate-disabled machine (which rests at idle) + // can't be knocked out of it. `locked`'s entry clears the session. + END_SESSION: { guard: 'accessGateActive', target: '#atm.locked' }, SELECT_CASH_IN: { target: 'cashIn', actions: ['setStartTime', 'setCashInFee'],