feat(access): Bolt Card tap-to-enter access gate (ADR-003) #86
3 changed files with 34 additions and 19 deletions
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>
commit
5114619fce
|
|
@ -17,7 +17,11 @@ import { useAtmStore } from '@/stores/atm'
|
||||||
* (those carry their own, longer machine timeouts).
|
* (those carry their own, longer machine timeouts).
|
||||||
* - HARD cap (HARD_CAP_MS): re-lock this long after the session began,
|
* - HARD cap (HARD_CAP_MS): re-lock this long after the session began,
|
||||||
* regardless of activity. Anchored to unlock time and never reset — an
|
* 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:
|
* Security properties:
|
||||||
* - Only `event.isTrusted` input resets the soft timer, so synthetic/scripted
|
* - 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.
|
// can only ever make it fire late-then-immediately, never early.
|
||||||
useIntervalFn(() => {
|
useIntervalFn(() => {
|
||||||
if (sessionStartedAt === null || atm.isLocked || !atm.accessControl.enabled) return
|
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()
|
const now = Date.now()
|
||||||
|
|
||||||
// Hard cap first — absolute, activity-independent.
|
// Hard cap first — absolute, activity-independent.
|
||||||
|
|
@ -86,8 +94,8 @@ export function useSessionSecurity() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soft inactivity — idle menu only, resets on trusted input.
|
// Soft inactivity — resets on trusted input.
|
||||||
if (atm.currentState === 'idle' && now - lastActivityAt >= SOFT_IDLE_MS) {
|
if (now - lastActivityAt >= SOFT_IDLE_MS) {
|
||||||
disarm()
|
disarm()
|
||||||
atm.endSession('inactivity')
|
atm.endSession('inactivity')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,8 @@ describe('ATM access control (ADR-003)', () => {
|
||||||
// entry-anchored timer can't measure inactivity (it never resets on screen
|
// entry-anchored timer can't measure inactivity (it never resets on screen
|
||||||
// touches). It's enforced at the DOM layer (useSessionSecurity), which sends
|
// 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
|
// 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)', () => {
|
describe('session end (button / inactivity / hard cap all route here)', () => {
|
||||||
it('END_SESSION re-locks immediately from idle when the gate is active', () => {
|
it('END_SESSION re-locks immediately from idle when the gate is active', () => {
|
||||||
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
|
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
|
||||||
|
|
@ -76,9 +77,10 @@ describe('ATM access control (ADR-003)', () => {
|
||||||
expect(actor.getSnapshot().value).toBe('locked')
|
expect(actor.getSnapshot().value).toBe('locked')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('END_SESSION re-locks from an in-flight cash-out (hard-cap path)', () => {
|
it('END_SESSION is ignored mid-transaction (never strands funds in flight)', () => {
|
||||||
// The absolute session cap must be able to lock mid-transaction, so the
|
// A root-level END_SESSION would bypass confirmAbandon / an in-flight
|
||||||
// transition lives at the machine root, not only on `idle`.
|
// dispense. The transition lives on `idle` only; a transaction's own
|
||||||
|
// terminal states return to `locked` when it ends.
|
||||||
const rateServices = {
|
const rateServices = {
|
||||||
getExchangeRate: () => Promise.resolve(2500),
|
getExchangeRate: () => Promise.resolve(2500),
|
||||||
getAvailableBalance: () => Promise.resolve(1_000_000),
|
getAvailableBalance: () => Promise.resolve(1_000_000),
|
||||||
|
|
@ -87,9 +89,11 @@ describe('ATM access control (ADR-003)', () => {
|
||||||
actor.start()
|
actor.start()
|
||||||
actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc' })
|
actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc' })
|
||||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
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' })
|
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)', () => {
|
it('END_SESSION is a no-op when the gate is disabled (stays at idle)', () => {
|
||||||
|
|
|
||||||
|
|
@ -513,14 +513,6 @@ export function createATMMachine(
|
||||||
cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction,
|
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: {
|
states: {
|
||||||
// === ACCESS GATE (ADR-003) ===
|
// === ACCESS GATE (ADR-003) ===
|
||||||
|
|
@ -546,9 +538,20 @@ export function createATMMachine(
|
||||||
// touches (the machine can't see raw pointer events), so it would fire
|
// touches (the machine can't see raw pointer events), so it would fire
|
||||||
// a fixed countdown regardless of activity. Inactivity is measured at
|
// a fixed countdown regardless of activity. Inactivity is measured at
|
||||||
// the DOM layer (useSessionSecurity) and drives END_SESSION on true
|
// the DOM layer (useSessionSecurity) and drives END_SESSION on true
|
||||||
// idleness. The hard session cap is handled the same way. Both re-lock
|
// idleness. The hard session cap is handled the same way.
|
||||||
// via the root-level END_SESSION transition above.
|
|
||||||
on: {
|
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: {
|
SELECT_CASH_IN: {
|
||||||
target: 'cashIn',
|
target: 'cashIn',
|
||||||
actions: ['setStartTime', 'setCashInFee'],
|
actions: ['setStartTime', 'setCashInFee'],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue