ACCESS_DEV_UNLOCK was opt-out (anything but 'false' enabled it) and
access.example.json shipped it on, so a gated production machine would
render a visible gate-bypass button on the lock screen by default. Flip
to opt-in (=== 'true'), update the example file and the provisioning
schema comment to match.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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>
The idle re-lock was an XState `after` on `idle`, which is anchored to
state ENTRY and never reset on screen touches — so it fired a fixed 60s
countdown regardless of interaction (reported: touching the screen
didn't extend the session). The machine can't observe raw pointer
events, so inactivity can't be measured there.
Move session timeouts to the DOM layer (useSessionSecurity, mounted in
the always-on App shell), enforcing two fail-closed limits that both
re-lock via a new root-level END_SESSION transition:
- SOFT idle (60s): re-lock after no *trusted* pointer/touch/key input
while on the idle menu; resets on every genuine interaction. Scoped to
idle so it never interrupts an in-flight cash-in/out.
- HARD cap (10min): absolute ceiling from unlock time, never reset — a
forgotten/relayed card can't hold a session open. Lives at the machine
root so it can lock mid-transaction, not just from idle.
Security posture: only event.isTrusted resets the soft timer (synthetic
events can't keep a session alive); wall-clock deadline checks re-lock
immediately after a suspend/resume rather than silently extending;
one-shot disarm-on-fire prevents spin; END_SESSION is guarded to the
active gate so it's inert when the gate is off.
Machine no longer owns the idle timer; tests updated (END_SESSION
re-locks from idle and from an in-flight cash-out; no-op when disabled).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
Per on-device review: order the top-left group ✕ then ?, and use the
`destructive` button variant so the exit swatch is a solid, theme-aware
red (--destructive is scoped per colorscheme) rather than a subtle
outline that didn't read as an exit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
The top-right "End Session" button overlapped the centered balance /
commission chips, which wrap into the top-right corner on narrower
screens (sintra). Relocate it to a minimal red ✕ icon button grouped
next to the "?" help button in the top-left, clear of the chips. Same
endSession() behavior; shown only while the gate is active.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
A Bolt Card tap loads the holder's card for the whole session, so an
unattended idle menu is transactable by the next person until the 60s
IDLE_LOCK_TIMEOUT fires. Give the holder an explicit re-lock:
- END_SESSION event on `idle`, guarded to the active gate, targets
`locked` (whose entry already clears the access session + loaded card).
No-op on a gate-disabled machine that rests at idle.
- endSession() store action; IdleView shows a destructive-styled
"End Session" button top-right only while accessControl.enabled.
- Tests: END_SESSION re-locks when the gate is active; no-op when off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
The access gate (ADR-003, #86) only wired services.pcscd + the pcsc
polkit rule into batm3.nix, so the tap-to-enter reader was invisible on
upboard machines. Port the same device-agnostic wiring to upboard.nix
(HID Global OMNIKEY 5022, 076b:5022) so the gate works on the sintra dev
unit — and on tejo — when #86 lands on dev and the nightly upgrade pulls
it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
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.
After a transaction with the gate enabled, the machine re-locks (cashOut/
cashIn → locked, not idle), so the view's isIdle watch never fires and the
router stays on /cash-out|/cash-in. When the next tap reopens the gate to
idle, the stale transaction view showed (e.g. a completed sell's collect
screen, stuck). Reset the route to / while locked (router-view hidden under
LockedView) so idle renders IdleView.
Builds on the access gate: a single Bolt Card tap at the locked screen both
unlocks the terminal AND pre-loads the card, so buy/sell just need "Complete"
— no second tap. Reuses the #83/#84 payment paths verbatim.
Soft entry, verify-at-payment: the tap is parsed LOCALLY (external_id only) so
the single-use SUN p/c stay valid; the cryptographic check happens at Complete
when the stored lnurlw actually moves sats (withdraw for sell, lnurlp-pay for
buy). Open-enrollment, card-only (no npub-QR, no PIN) per product decision.
- services/access: `boltcard` credential (externalId + lnurlw) in the AccessScan
union; canonicalId + open-enrollment/allow-list authorize; parseBoltcardLnurlw
(local, no server). Only external_id is hashed — p/c never enter authorize.
- store: loadedBoltCard (session-scoped, cleared on re-lock); handleBoltCardEntry
(tap while locked → authorize → grant + load); completeWithCard (routes to the
existing tap handlers); NFC listener routes locked→enter.
- LockedView: card-only "Tap your Bolt Card" screen (dropped camera/npub-QR/PIN).
- CashIn/CashOutView: "Complete Purchase/Sale" button + card chip when loaded.
- tests: boltcard authorize + parseBoltcardLnurlw (17 access tests total).
Enabling the gate is a provisioning step (access.json enabled+openEnrollment);
other machines default off → unchanged.
Squashed skeleton (was 11 commits on feat/access-control-skeleton) for a
clean rebase onto dev. Adds a `locked` gate the terminal boots into until a
credential is presented; opt-in and non-breaking (defaults off → boots
straight to idle as before).
- state-machine: `locked` state + ACCESS_GRANTED/ACCESS_DENIED/DEV_UNLOCK
events + accessBypass/devUnlockAllowed guards (packages/state-machine).
- services/access: reader abstraction, npub+PIN authorize() (nostr-tools
nip19; accepts nostr:/nprofile), camera npub-QR reader, mock reader.
- LockedView.vue + ColorModeToggle: branded viewfinder, PIN pad, denied
reason, dev-unlock; camera off-by-default + idle return.
- store/main/electron.d.ts: seed gate config, grant/deny/devUnlock wiring,
access.json provisioning (no rebuild), get-config surface.
- deploy: access.example.json + provision-access.sh; ADR-003.
Credential union is npub today; UID (NFC tap) is the next step.