Compare commits
18 commits
984ae9d71e
...
82fbf12950
| Author | SHA1 | Date | |
|---|---|---|---|
| 82fbf12950 | |||
| 4ce68c1301 | |||
| fbcaa3121f | |||
| d35faf1c93 | |||
| c83b40fe5e | |||
|
|
6676c26761 | ||
|
|
44a5ebbd12 | ||
|
|
c7e312a63f | ||
|
|
a7b409b109 | ||
| 2ea3df01d1 | |||
| cb236703d6 | |||
| 46e52f6598 | |||
| 59e8a9de02 | |||
| 8264dd7472 | |||
| ac40ea9bb6 | |||
| 1b671bf407 | |||
| 83300784ec | |||
|
|
ffbacafe39 |
52 changed files with 1981 additions and 70 deletions
|
|
@ -73,6 +73,18 @@ VITE_SPIRE_SEED=
|
||||||
# Show "Under Service" screen and block all transactions
|
# Show "Under Service" screen and block all transactions
|
||||||
# VITE_MAINTENANCE_MODE=true
|
# VITE_MAINTENANCE_MODE=true
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Public Web Demo
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Set ONLY for the browser demo build (atm.demo.aiolabs.dev). Leave blank on
|
||||||
|
# every real machine. When set it:
|
||||||
|
# - keeps the mouse cursor visible (kiosk builds hide it)
|
||||||
|
# - mints one extra, never-used LNbits wallet named with this exact string,
|
||||||
|
# so the throwaway accounts the demo creates (one per page load, each with
|
||||||
|
# its own ephemeral identity) can be swept by name instead of guessed at.
|
||||||
|
# VITE_DEMO_TAG=bitspire-web-demo
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Mock Fallback (Production Safety)
|
# Mock Fallback (Production Safety)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -81,3 +93,29 @@ VITE_SPIRE_SEED=
|
||||||
# Set to 'true' for development/demo environments only
|
# Set to 'true' for development/demo environments only
|
||||||
# When false (production default), initialization failures show a maintenance screen
|
# When false (production default), initialization failures show a maintenance screen
|
||||||
# VITE_ALLOW_MOCK_FALLBACK=true
|
# VITE_ALLOW_MOCK_FALLBACK=true
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Access Control (ADR-003)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Badge-to-enter gate. When disabled (default), the machine boots straight to
|
||||||
|
# idle exactly as before. When enabled, it boots into a locked screen and
|
||||||
|
# requires a credential (prototype: an npub QR scanned by the camera, with an
|
||||||
|
# optional PIN) before transactions are reachable.
|
||||||
|
# ACCESS_CONTROL_ENABLED=true
|
||||||
|
|
||||||
|
# Prototype posture: admit ANY valid npub when the allow-list has no match.
|
||||||
|
# Turn OFF once a real allow-list (/var/lib/bitspire/access.json) is provisioned.
|
||||||
|
# ACCESS_OPEN_ENROLLMENT=true
|
||||||
|
|
||||||
|
# Allow the on-screen runtime dev/operator unlock button (default: allowed when
|
||||||
|
# the gate is on). Set to 'false' to hide it on a locked-down deployment.
|
||||||
|
# ACCESS_DEV_UNLOCK=false
|
||||||
|
|
||||||
|
# Per-machine salt for hashing credentials/PINs. Provision a real value in
|
||||||
|
# production (or in access.json); a fixed default is used if unset.
|
||||||
|
# ACCESS_SALT=change-me-per-machine
|
||||||
|
|
||||||
|
# Build/dev bypass — forces the gate OPEN even when enabled (browser dev / CI).
|
||||||
|
# Renderer-side (Vite) flag, never set in a production image.
|
||||||
|
# VITE_SKIP_ACCESS_GATE=true
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,61 @@ function loadBranding(): BrandingConfig | null {
|
||||||
return { title, theme, customColors, customColorsDark, logoDataUrl, logoDarkDataUrl }
|
return { title, theme, customColors, customColorsDark, logoDataUrl, logoDarkDataUrl }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Access-control config loader (ADR-003). Env toggles the gate; an optional
|
||||||
|
// /var/lib/bitspire/access.json carries the salt + allow-list. Defaults OFF —
|
||||||
|
// a machine with neither env nor file behaves as if there is no access layer.
|
||||||
|
// The allow-list shape mirrors the renderer's AllowListEntry (authorize.ts);
|
||||||
|
// duplicated here to avoid a cross-project (electron↔renderer) import.
|
||||||
|
interface AccessAllowListEntry {
|
||||||
|
idHash: string
|
||||||
|
role: 'user' | 'operator'
|
||||||
|
pinHash?: string
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
function loadAccessControl() {
|
||||||
|
// Env provides defaults; access.json (writable, operator-provisioned — same
|
||||||
|
// spirit as branding/) overrides them, so the gate can be toggled on a
|
||||||
|
// deployed machine by dropping a file + restarting the service, with no image
|
||||||
|
// rebuild. Defaults OFF.
|
||||||
|
let enabled = process.env.ACCESS_CONTROL_ENABLED === 'true'
|
||||||
|
// Dev unlock allowed by default when the gate is on; opt out explicitly.
|
||||||
|
let devUnlock = process.env.ACCESS_DEV_UNLOCK !== 'false'
|
||||||
|
let openEnrollment = process.env.ACCESS_OPEN_ENROLLMENT === 'true'
|
||||||
|
let salt = process.env.ACCESS_SALT || ''
|
||||||
|
let allowList: AccessAllowListEntry[] = []
|
||||||
|
|
||||||
|
const jsonPath = path.join(
|
||||||
|
fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(),
|
||||||
|
'access.json'
|
||||||
|
)
|
||||||
|
if (fs.existsSync(jsonPath)) {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
|
||||||
|
if (typeof raw.enabled === 'boolean') enabled = raw.enabled
|
||||||
|
if (typeof raw.devUnlock === 'boolean') devUnlock = raw.devUnlock
|
||||||
|
if (typeof raw.openEnrollment === 'boolean') openEnrollment = raw.openEnrollment
|
||||||
|
if (typeof raw.salt === 'string' && raw.salt) salt = raw.salt
|
||||||
|
if (Array.isArray(raw.allowList)) {
|
||||||
|
allowList = (raw.allowList as unknown[]).filter(
|
||||||
|
(e): e is AccessAllowListEntry =>
|
||||||
|
!!e &&
|
||||||
|
typeof (e as AccessAllowListEntry).idHash === 'string' &&
|
||||||
|
((e as AccessAllowListEntry).role === 'user' ||
|
||||||
|
(e as AccessAllowListEntry).role === 'operator')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Electron] Failed to parse access.json:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A gated machine needs a stable salt for deterministic hashing. Fall back to
|
||||||
|
// a fixed default (prototype); production should provision a real salt.
|
||||||
|
if (!salt) salt = 'bitspire-access-v1'
|
||||||
|
|
||||||
|
return { enabled, devUnlock, openEnrollment, salt, allowList }
|
||||||
|
}
|
||||||
|
|
||||||
// Determine if we're in development
|
// Determine if we're in development
|
||||||
const isDev =
|
const isDev =
|
||||||
process.env.ELECTRON_FORCE_PROD !== '1' &&
|
process.env.ELECTRON_FORCE_PROD !== '1' &&
|
||||||
|
|
@ -311,6 +366,9 @@ ipcMain.handle('get-config', () => {
|
||||||
|
|
||||||
// Operator branding (logo/title/theme) — null when no override
|
// Operator branding (logo/title/theme) — null when no override
|
||||||
branding: loadBranding(),
|
branding: loadBranding(),
|
||||||
|
|
||||||
|
// Access-control gate (ADR-003) — `enabled` defaults false (no gate).
|
||||||
|
accessControl: loadAccessControl(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@
|
||||||
* QR path keeps working — cash-out never depends on this.
|
* QR path keeps working — cash-out never depends on this.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
|
||||||
export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable'
|
export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable'
|
||||||
export interface NfcStatus {
|
export interface NfcStatus {
|
||||||
state: NfcState
|
state: NfcState
|
||||||
|
|
@ -83,7 +85,11 @@ export async function readNdefLnurlw(
|
||||||
const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256)
|
const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256)
|
||||||
|
|
||||||
// Select the NDEF Tag Application (AID D2760000850101).
|
// Select the NDEF Tag Application (AID D2760000850101).
|
||||||
if (!swOk(await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]))) {
|
if (
|
||||||
|
!swOk(
|
||||||
|
await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00])
|
||||||
|
)
|
||||||
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,6 +113,29 @@ export async function readNdefLnurlw(
|
||||||
|
|
||||||
let stopFn: (() => void) | null = null
|
let stopFn: (() => void) | null = null
|
||||||
|
|
||||||
|
// ── Wedge auto-recovery ───────────────────────────────────────────────────
|
||||||
|
// Cheap CCID readers (the Feitian R502-CL especially) occasionally wedge: they
|
||||||
|
// keep detecting a card but every APDU returns "card absent or mute", and ONLY
|
||||||
|
// a USB power-cycle clears it — pcscd/app restarts do NOT. When we see a run of
|
||||||
|
// consecutive read failures we trigger nfc-reader-reset.service (a root oneshot
|
||||||
|
// that re-binds the reader's USB device = a software replug); nfc-pcsc then
|
||||||
|
// re-detects the reader on hotplug with no app restart. The trigger is gated by
|
||||||
|
// a cooldown so a still-wedged reader can't reset-loop. A quality reader (e.g.
|
||||||
|
// ACR1252U) wedges far less; this is belt-and-suspenders for any reader.
|
||||||
|
const WEDGE_FAILURE_THRESHOLD = 3
|
||||||
|
const RESET_COOLDOWN_MS = 30_000
|
||||||
|
// Persist across reader re-enumerations (a reset spawns a fresh reader closure).
|
||||||
|
let lastReaderResetAt = 0
|
||||||
|
|
||||||
|
/** Trigger the privileged USB power-cycle of the reader. Best-effort. */
|
||||||
|
function resetWedgedReader(): void {
|
||||||
|
// NixOS: the app runs unprivileged as `bitspire`; a polkit rule authorises it
|
||||||
|
// to start this one unit. systemctl lives at a stable path on the device.
|
||||||
|
execFile('/run/current-system/sw/bin/systemctl', ['start', 'nfc-reader-reset.service'], () => {
|
||||||
|
/* best-effort — if it fails the reader stays wedged until a manual reset */
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start listening for Bolt Card taps. Idempotent. Returns a stop function.
|
* Start listening for Bolt Card taps. Idempotent. Returns a stop function.
|
||||||
* Never throws — failures surface via onStatus.
|
* Never throws — failures surface via onStatus.
|
||||||
|
|
@ -159,6 +188,10 @@ export async function startNfcReader(
|
||||||
// a present↔empty storm when hammered, so ignore re-detections for a beat
|
// a present↔empty storm when hammered, so ignore re-detections for a beat
|
||||||
// after a failure. Successful reads don't cool down.
|
// after a failure. Successful reads don't cool down.
|
||||||
let cooldownUntil = 0
|
let cooldownUntil = 0
|
||||||
|
// Consecutive failed reads → wedge detection (see resetWedgedReader above).
|
||||||
|
// A completed read (Bolt Card or not) proves the reader is healthy and
|
||||||
|
// clears the count; only a run of thrown transmits trips the reset.
|
||||||
|
let consecutiveFailures = 0
|
||||||
r.on('card', async () => {
|
r.on('card', async () => {
|
||||||
if (Date.now() < cooldownUntil) return
|
if (Date.now() < cooldownUntil) return
|
||||||
onStatus({ state: 'reading', reader: name })
|
onStatus({ state: 'reading', reader: name })
|
||||||
|
|
@ -167,13 +200,30 @@ export async function startNfcReader(
|
||||||
// user simply re-taps.
|
// user simply re-taps.
|
||||||
try {
|
try {
|
||||||
const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen))
|
const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen))
|
||||||
|
consecutiveFailures = 0
|
||||||
if (lnurlw) {
|
if (lnurlw) {
|
||||||
onCard(lnurlw)
|
onCard(lnurlw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' })
|
onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onStatus({ state: 'error', reader: name, message: 'card read failed — hold steady & retap' })
|
consecutiveFailures++
|
||||||
|
if (
|
||||||
|
consecutiveFailures >= WEDGE_FAILURE_THRESHOLD &&
|
||||||
|
Date.now() - lastReaderResetAt > RESET_COOLDOWN_MS
|
||||||
|
) {
|
||||||
|
// Reader looks wedged — auto power-cycle it (only fix that works).
|
||||||
|
lastReaderResetAt = Date.now()
|
||||||
|
consecutiveFailures = 0
|
||||||
|
onStatus({ state: 'error', reader: name, message: 'reader stuck — auto-resetting…' })
|
||||||
|
resetWedgedReader()
|
||||||
|
} else {
|
||||||
|
onStatus({
|
||||||
|
state: 'error',
|
||||||
|
reader: name,
|
||||||
|
message: 'card read failed — hold steady & retap',
|
||||||
|
})
|
||||||
|
}
|
||||||
void e
|
void e
|
||||||
}
|
}
|
||||||
cooldownUntil = Date.now() + 1500
|
cooldownUntil = Date.now() + 1500
|
||||||
|
|
@ -182,7 +232,9 @@ export async function startNfcReader(
|
||||||
r.on('error', (err: unknown) =>
|
r.on('error', (err: unknown) =>
|
||||||
onStatus({ state: 'error', reader: name, message: errMsg(err) })
|
onStatus({ state: 'error', reader: name, message: errMsg(err) })
|
||||||
)
|
)
|
||||||
r.on('end', () => onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' }))
|
r.on('end', () =>
|
||||||
|
onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' })
|
||||||
|
)
|
||||||
})
|
})
|
||||||
nfc.on('error', (err: unknown) => onStatus({ state: 'error', message: errMsg(err) }))
|
nfc.on('error', (err: unknown) => onStatus({ state: 'error', message: errMsg(err) }))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<html lang="en" class="dark">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/logo.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||||
<!--
|
<!--
|
||||||
Content Security Policy:
|
Content Security Policy:
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss: http: https:; img-src 'self' data: blob:; font-src 'self'; frame-src 'none'; object-src 'none'"
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss: http: https:; img-src 'self' data: blob:; font-src 'self'; frame-src 'none'; object-src 'none'"
|
||||||
/>
|
/>
|
||||||
<title>Lamassu ATM</title>
|
<title>bitSpire ATM</title>
|
||||||
<style>
|
<style>
|
||||||
/* Prevent text selection and context menu on kiosk */
|
/* Prevent text selection and context menu on kiosk */
|
||||||
* {
|
* {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"dev:vite": "vite",
|
"dev:vite": "vite",
|
||||||
"electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js",
|
"electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js",
|
||||||
"build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --external:better-sqlite3 --outfile=dist-electron/fund-atm.bundle.cjs",
|
"build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --external:better-sqlite3 --outfile=dist-electron/fund-atm.bundle.cjs",
|
||||||
|
"build:web": "vite build",
|
||||||
"build:electron": "pnpm build && electron-builder",
|
"build:electron": "pnpm build && electron-builder",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,39 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
|
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAtmStore } from '@/stores/atm'
|
import { useAtmStore } from '@/stores/atm'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
import { useSessionSecurity } from '@/composables/useSessionSecurity'
|
||||||
import { setBranding } from '@/composables/useBranding'
|
import { setBranding } from '@/composables/useBranding'
|
||||||
import { classifyInitError } from '@/services/init-error'
|
import { classifyInitError } from '@/services/init-error'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Sun, Moon } from 'lucide-vue-next'
|
|
||||||
import PairingWizard from '@/components/PairingWizard.vue'
|
import PairingWizard from '@/components/PairingWizard.vue'
|
||||||
|
import LockedView from '@/views/LockedView.vue'
|
||||||
|
import ColorModeToggle from '@/components/ColorModeToggle.vue'
|
||||||
|
|
||||||
const atmStore = useAtmStore()
|
const atmStore = useAtmStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const { current: currentTheme, themes, colorMode } = useTheme()
|
const { current: currentTheme, themes, colorMode } = useTheme()
|
||||||
|
|
||||||
|
// ADR-003 session security: idle-inactivity re-lock (resets on touch) + an
|
||||||
|
// absolute hard session cap. Enforced here at the always-mounted shell so it
|
||||||
|
// spans the whole unlocked session, not just the idle view.
|
||||||
|
useSessionSecurity()
|
||||||
|
|
||||||
|
// When the machine re-locks after a transaction (access gate enabled), the
|
||||||
|
// router is still on /cash-in or /cash-out under the LockedView overlay. Reset
|
||||||
|
// it to home so that when the gate reopens to `idle`, IdleView shows — not the
|
||||||
|
// stale transaction view (e.g. a completed cash-out's collect screen). Runs
|
||||||
|
// while locked, so the router-view is hidden; no flicker. The disabled-gate path
|
||||||
|
// (never dwells in `locked`) still returns home via each view's isIdle watch.
|
||||||
|
watch(
|
||||||
|
() => atmStore.isLocked,
|
||||||
|
(locked) => {
|
||||||
|
if (locked && route.path !== '/') void router.push('/')
|
||||||
|
}
|
||||||
|
)
|
||||||
const debugExpanded = ref(false)
|
const debugExpanded = ref(false)
|
||||||
const isSupport = computed(() => route.path === '/support')
|
const isSupport = computed(() => route.path === '/support')
|
||||||
// Network detected dynamically from Lightning invoice prefix
|
// Network detected dynamically from Lightning invoice prefix
|
||||||
|
|
@ -110,9 +131,7 @@ onMounted(async () => {
|
||||||
// Same env → pairing-seed precedence as lightning.ts: on a blank-.env
|
// Same env → pairing-seed precedence as lightning.ts: on a blank-.env
|
||||||
// seed-driven machine the relay comes from the pairing transport, not env.
|
// seed-driven machine the relay comes from the pairing transport, not env.
|
||||||
const relayUrl =
|
const relayUrl =
|
||||||
config?.relayUrl ||
|
config?.relayUrl || import.meta.env.VITE_RELAY_URL || resolved?.transport?.relays?.[0]
|
||||||
import.meta.env.VITE_RELAY_URL ||
|
|
||||||
resolved?.transport?.relays?.[0]
|
|
||||||
if (signer && relayUrl) {
|
if (signer && relayUrl) {
|
||||||
const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
|
const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
|
||||||
await client.connect()
|
await client.connect()
|
||||||
|
|
@ -289,6 +308,11 @@ function toggleLiveServices() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Access gate (ADR-003): shown when the machine is healthy but locked,
|
||||||
|
below the init/maintenance gates above. Never renders when access
|
||||||
|
control is disabled (the machine never dwells in `locked`). -->
|
||||||
|
<LockedView v-else-if="atmStore.isLocked" />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<router-view />
|
<router-view />
|
||||||
|
|
||||||
|
|
@ -340,16 +364,10 @@ function toggleLiveServices() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Light/dark toggle (production only — debug panel has this in dev) -->
|
<!-- Light/dark toggle (production only — debug panel has this in dev) -->
|
||||||
<Button
|
<ColorModeToggle
|
||||||
v-if="!atmStore.allowMockFallback"
|
v-if="!atmStore.allowMockFallback"
|
||||||
variant="outline"
|
class="fixed bottom-3 right-3 z-50 lg:bottom-6 lg:right-6"
|
||||||
class="fixed bottom-3 right-3 lg:bottom-6 lg:right-6 z-50 h-10 px-3 py-1 text-sm rounded-lg lg:h-[7vh] lg:min-h-[70px] lg:px-8 lg:py-3 lg:text-2xl lg:rounded-xl gap-2 lg:gap-3"
|
/>
|
||||||
@click="colorMode = colorMode === 'dark' ? 'light' : 'dark'"
|
|
||||||
>
|
|
||||||
<Sun v-if="colorMode === 'dark'" class="w-5 h-5 lg:w-7 lg:h-7" />
|
|
||||||
<Moon v-else class="w-5 h-5 lg:w-7 lg:h-7" />
|
|
||||||
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!-- Debug overlay (dev only) -->
|
<!-- Debug overlay (dev only) -->
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
33
apps/machine/src/components/ColorModeToggle.vue
Normal file
33
apps/machine/src/components/ColorModeToggle.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Light/dark toggle — the single reusable control for switching color mode.
|
||||||
|
*
|
||||||
|
* Kiosk-sized by default (large touch target for a public display). colorMode
|
||||||
|
* is global + persisted (toggles `.dark` on <html>, and dark mode pulls
|
||||||
|
* branding.json's dark palette + logo-dark.png), so this stays in sync
|
||||||
|
* wherever it's used. Position it via a fallthrough `class` on the consumer,
|
||||||
|
* e.g. `<ColorModeToggle class="fixed bottom-6 right-6" />`.
|
||||||
|
*/
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Sun, Moon } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const { colorMode } = useTheme()
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
colorMode.value = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="h-10 gap-2 rounded-lg px-3 py-1 text-sm lg:h-[7vh] lg:min-h-[70px] lg:gap-3 lg:rounded-xl lg:px-8 lg:py-3 lg:text-2xl"
|
||||||
|
:aria-label="colorMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<Sun v-if="colorMode === 'dark'" class="h-5 w-5 lg:h-7 lg:w-7" />
|
||||||
|
<Moon v-else class="h-5 w-5 lg:h-7 lg:w-7" />
|
||||||
|
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
95
apps/machine/src/composables/useSessionSecurity.ts
Normal file
95
apps/machine/src/composables/useSessionSecurity.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { watch } from 'vue'
|
||||||
|
import { useEventListener, useIntervalFn } from '@vueuse/core'
|
||||||
|
import { useAtmStore } from '@/stores/atm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session security timeouts for the ADR-003 access gate.
|
||||||
|
*
|
||||||
|
* Enforced at the DOM layer on purpose: the state machine can't observe raw
|
||||||
|
* pointer events, so an XState `after` delay can only count from state entry —
|
||||||
|
* it never resets on a screen touch and therefore can't measure *inactivity*.
|
||||||
|
* Two independent, fail-closed limits, both of which re-lock via the machine's
|
||||||
|
* root-level END_SESSION transition:
|
||||||
|
*
|
||||||
|
* - SOFT idle (SOFT_IDLE_MS): re-lock after this long with no trusted user
|
||||||
|
* input while on the idle menu. Resets on every genuine pointer/touch/key
|
||||||
|
* event. Scoped to `idle` so it never interrupts an in-flight cash-in/out
|
||||||
|
* (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.
|
||||||
|
*
|
||||||
|
* Security properties:
|
||||||
|
* - Only `event.isTrusted` input resets the soft timer, so synthetic/scripted
|
||||||
|
* events in the renderer can't keep a session alive.
|
||||||
|
* - Limits are wall-clock deadline comparisons, not chained setTimeouts: a
|
||||||
|
* suspended/resumed renderer re-locks on the very next tick instead of
|
||||||
|
* silently extending the session past its deadline.
|
||||||
|
* - Both limits only ever *lock*. The machine's accessGateActive guard makes
|
||||||
|
* END_SESSION a no-op when the gate is off, so this is inert on a
|
||||||
|
* gate-disabled machine.
|
||||||
|
* - One-shot per session: after firing, it disarms until the next unlock, so
|
||||||
|
* a lock that (under dev bypass) doesn't take can't spin.
|
||||||
|
*
|
||||||
|
* Call once from the always-mounted App shell.
|
||||||
|
*/
|
||||||
|
const SOFT_IDLE_MS = 60_000 // 60s of no interaction on the idle menu
|
||||||
|
const HARD_CAP_MS = 600_000 // 10min absolute session ceiling
|
||||||
|
|
||||||
|
export function useSessionSecurity() {
|
||||||
|
const atm = useAtmStore()
|
||||||
|
|
||||||
|
// Wall-clock anchors. `null` sessionStartedAt == disarmed (no live session).
|
||||||
|
let sessionStartedAt: number | null = null
|
||||||
|
let lastActivityAt = 0
|
||||||
|
|
||||||
|
function arm() {
|
||||||
|
const now = Date.now()
|
||||||
|
sessionStartedAt = now
|
||||||
|
lastActivityAt = now
|
||||||
|
}
|
||||||
|
function disarm() {
|
||||||
|
sessionStartedAt = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arm on each locked → unlocked edge; disarm on lock. Anchored to the
|
||||||
|
// isLocked transition so the hard cap starts at unlock and does NOT restart
|
||||||
|
// when moving idle → cashIn → idle within a single session.
|
||||||
|
watch(
|
||||||
|
() => atm.isLocked,
|
||||||
|
(locked, wasLocked) => {
|
||||||
|
if (wasLocked && !locked && atm.accessControl.enabled) arm()
|
||||||
|
else if (locked) disarm()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only genuine hardware input counts as activity. Passive + capture so it
|
||||||
|
// observes every touch without interfering with handling. useEventListener
|
||||||
|
// auto-detaches on unmount.
|
||||||
|
const onActivity = (e: Event) => {
|
||||||
|
if (e.isTrusted) lastActivityAt = Date.now()
|
||||||
|
}
|
||||||
|
for (const type of ['pointerdown', 'touchstart', 'keydown', 'wheel'] as const) {
|
||||||
|
useEventListener(document, type, onActivity, { passive: true, capture: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single 1s evaluator — cheap, and coarse enough that timer drift/suspend
|
||||||
|
// can only ever make it fire late-then-immediately, never early.
|
||||||
|
useIntervalFn(() => {
|
||||||
|
if (sessionStartedAt === null || atm.isLocked || !atm.accessControl.enabled) return
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// Hard cap first — absolute, activity-independent.
|
||||||
|
if (now - sessionStartedAt >= HARD_CAP_MS) {
|
||||||
|
disarm() // one-shot; re-arms on next unlock
|
||||||
|
atm.endSession('session-cap')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft inactivity — idle menu only, resets on trusted input.
|
||||||
|
if (atm.currentState === 'idle' && now - lastActivityAt >= SOFT_IDLE_MS) {
|
||||||
|
disarm()
|
||||||
|
atm.endSession('inactivity')
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,13 @@ const router = createRouter({
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Kiosk chrome (hidden cursor) is the default — every real machine is a
|
||||||
|
// touchscreen. The public web demo (VITE_DEMO_TAG) runs in a normal browser,
|
||||||
|
// where an invisible pointer just reads as broken.
|
||||||
|
if (!import.meta.env.VITE_DEMO_TAG) {
|
||||||
|
document.documentElement.classList.add('kiosk')
|
||||||
|
}
|
||||||
|
|
||||||
// Create Pinia store
|
// Create Pinia store
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
|
|
||||||
166
apps/machine/src/services/access/__tests__/authorize.test.ts
Normal file
166
apps/machine/src/services/access/__tests__/authorize.test.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { npubEncode, nprofileEncode } from 'nostr-tools/nip19'
|
||||||
|
import { authorize, hashId, hashPin, type AllowListEntry } from '../authorize'
|
||||||
|
import { parseBoltcardLnurlw } from '../boltcard'
|
||||||
|
|
||||||
|
const SALT = 'test-salt'
|
||||||
|
const HEX_A = 'aa'.repeat(32)
|
||||||
|
const HEX_B = 'bb'.repeat(32)
|
||||||
|
const NPUB_A = npubEncode(HEX_A)
|
||||||
|
const NPUB_B = npubEncode(HEX_B)
|
||||||
|
|
||||||
|
describe('access authorize (ADR-003)', () => {
|
||||||
|
describe('open enrollment (prototype)', () => {
|
||||||
|
it('grants any valid npub as user', async () => {
|
||||||
|
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], {
|
||||||
|
salt: SALT,
|
||||||
|
openEnrollment: true,
|
||||||
|
})
|
||||||
|
expect(out.status).toBe('granted')
|
||||||
|
expect(out).toMatchObject({ status: 'granted', role: 'user' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a stray / non-npub QR', async () => {
|
||||||
|
const out = await authorize({ kind: 'npub', npub: 'https://example.com/not-an-npub' }, [], {
|
||||||
|
salt: SALT,
|
||||||
|
openEnrollment: true,
|
||||||
|
})
|
||||||
|
expect(out.status).toBe('denied')
|
||||||
|
expect(out).toMatchObject({ reason: 'not a valid npub' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a `nostr:` URI prefix (with surrounding whitespace)', async () => {
|
||||||
|
const out = await authorize({ kind: 'npub', npub: ` nostr:${NPUB_A}\n` }, [], {
|
||||||
|
salt: SALT,
|
||||||
|
openEnrollment: true,
|
||||||
|
})
|
||||||
|
expect(out.status).toBe('granted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts an nprofile and resolves to the same identity as its npub', async () => {
|
||||||
|
const nprofile = nprofileEncode({ pubkey: HEX_A, relays: ['wss://relay.example'] })
|
||||||
|
const viaNprofile = await authorize({ kind: 'npub', npub: nprofile }, [], {
|
||||||
|
salt: SALT,
|
||||||
|
openEnrollment: true,
|
||||||
|
})
|
||||||
|
const viaNpub = await authorize({ kind: 'npub', npub: NPUB_A }, [], {
|
||||||
|
salt: SALT,
|
||||||
|
openEnrollment: true,
|
||||||
|
})
|
||||||
|
expect(viaNprofile.status).toBe('granted')
|
||||||
|
// Same underlying pubkey → same credential hash.
|
||||||
|
expect(viaNprofile.credentialIdHash).toBe(viaNpub.credentialIdHash)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('allow-list (closed)', () => {
|
||||||
|
it('denies an unlisted npub when not open-enrollment', async () => {
|
||||||
|
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], { salt: SALT })
|
||||||
|
expect(out).toMatchObject({ status: 'denied', reason: 'not authorized' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('grants a listed npub with its role, no PIN', async () => {
|
||||||
|
const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'operator' }
|
||||||
|
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [entry], { salt: SALT })
|
||||||
|
expect(out).toMatchObject({ status: 'granted', role: 'operator' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not match npub B against npub A entry', async () => {
|
||||||
|
const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'user' }
|
||||||
|
const out = await authorize({ kind: 'npub', npub: NPUB_B }, [entry], { salt: SALT })
|
||||||
|
expect(out.status).toBe('denied')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PIN second factor', () => {
|
||||||
|
const makeEntry = async (): Promise<AllowListEntry> => ({
|
||||||
|
idHash: await hashId(HEX_A, SALT),
|
||||||
|
role: 'user',
|
||||||
|
pinHash: await hashPin('1234', SALT),
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks for a PIN when one is configured and none supplied', async () => {
|
||||||
|
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
|
||||||
|
salt: SALT,
|
||||||
|
})
|
||||||
|
expect(out.status).toBe('pin-required')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('grants on correct PIN', async () => {
|
||||||
|
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
|
||||||
|
salt: SALT,
|
||||||
|
pin: '1234',
|
||||||
|
})
|
||||||
|
expect(out).toMatchObject({ status: 'granted', role: 'user' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('denies on wrong PIN', async () => {
|
||||||
|
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
|
||||||
|
salt: SALT,
|
||||||
|
pin: '9999',
|
||||||
|
})
|
||||||
|
expect(out).toMatchObject({ status: 'denied', reason: 'incorrect PIN' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('challenge credential (v2 seam)', () => {
|
||||||
|
it('is not yet authorized', async () => {
|
||||||
|
const out = await authorize({ kind: 'challenge', pubkey: HEX_A, nonce: 'n', sig: 's' }, [], {
|
||||||
|
salt: SALT,
|
||||||
|
openEnrollment: true,
|
||||||
|
})
|
||||||
|
expect(out.status).toBe('denied')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('boltcard credential (tap-to-enter)', () => {
|
||||||
|
it('open-enrollment grants any card as user', async () => {
|
||||||
|
const out = await authorize(
|
||||||
|
{ kind: 'boltcard', externalId: 'abc123', lnurlw: 'lnurlw://h/scan/abc123?p=1&c=2' },
|
||||||
|
[],
|
||||||
|
{ salt: SALT, openEnrollment: true }
|
||||||
|
)
|
||||||
|
expect(out).toMatchObject({ status: 'granted', role: 'user' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a card with no external_id', async () => {
|
||||||
|
const out = await authorize({ kind: 'boltcard', externalId: '', lnurlw: '' }, [], {
|
||||||
|
salt: SALT,
|
||||||
|
openEnrollment: true,
|
||||||
|
})
|
||||||
|
expect(out).toMatchObject({ status: 'denied', reason: 'not a valid card' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allow-list matches by external_id hash', async () => {
|
||||||
|
const idHash = await hashId('abc123', SALT)
|
||||||
|
const list: AllowListEntry[] = [{ idHash, role: 'operator' }]
|
||||||
|
const out = await authorize(
|
||||||
|
{ kind: 'boltcard', externalId: 'abc123', lnurlw: 'lnurlw://h/scan/abc123?p=1&c=2' },
|
||||||
|
list,
|
||||||
|
{ salt: SALT, openEnrollment: false }
|
||||||
|
)
|
||||||
|
expect(out).toMatchObject({ status: 'granted', role: 'operator' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseBoltcardLnurlw', () => {
|
||||||
|
it('extracts external_id from a tapped lnurlw', () => {
|
||||||
|
expect(
|
||||||
|
parseBoltcardLnurlw('lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEAD&c=BEEF')
|
||||||
|
).toEqual({ externalId: 'abc123' })
|
||||||
|
})
|
||||||
|
it('strips a lightning: prefix and accepts https', () => {
|
||||||
|
expect(parseBoltcardLnurlw('lightning:lnurlw://h/boltcards/api/v1/scan/xyz?p=1')).toEqual({
|
||||||
|
externalId: 'xyz',
|
||||||
|
})
|
||||||
|
expect(parseBoltcardLnurlw('https://h/boltcards/api/v1/scan/xyz?p=1')).toEqual({
|
||||||
|
externalId: 'xyz',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('returns null for non-card / malformed input', () => {
|
||||||
|
expect(parseBoltcardLnurlw('https://h/something/else')).toBeNull()
|
||||||
|
expect(parseBoltcardLnurlw('not a url')).toBeNull()
|
||||||
|
expect(parseBoltcardLnurlw('')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
148
apps/machine/src/services/access/authorize.ts
Normal file
148
apps/machine/src/services/access/authorize.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
/**
|
||||||
|
* Credential authorization (ADR-003).
|
||||||
|
*
|
||||||
|
* PROTOTYPE (this PR): a QR "badge" carrying an npub grants terminal access,
|
||||||
|
* with an OPTIONAL PIN as a second factor. Matching is against a local
|
||||||
|
* allow-list of hashed identities; `openEnrollment` admits any valid npub
|
||||||
|
* (no allow-list) for early prototyping. Only salted hashes are compared or
|
||||||
|
* stored — never the raw npub/UID (KYC-free).
|
||||||
|
*
|
||||||
|
* Identity id per scan kind:
|
||||||
|
* - npub → hex pubkey (decoded, canonical), then hashed
|
||||||
|
* - uid → raw UID hashed (NFC, PR4)
|
||||||
|
* - challenge → v2 seam (PR5), not yet authorized
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { decode as nip19Decode } from 'nostr-tools/nip19'
|
||||||
|
import type { AccessRole } from '@bitSpire/state-machine'
|
||||||
|
import type { AccessScan } from './types'
|
||||||
|
|
||||||
|
/** One authorized identity. `idHash` = hashId(<canonical id>, salt). */
|
||||||
|
export interface AllowListEntry {
|
||||||
|
idHash: string
|
||||||
|
role: AccessRole
|
||||||
|
/** When set, access requires this PIN (hashPin(pin, salt)) as a 2nd factor. */
|
||||||
|
pinHash?: string
|
||||||
|
/** Optional operator-facing label (never a person's real identity). */
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthorizeOptions {
|
||||||
|
/** Per-machine salt for all hashing. */
|
||||||
|
salt: string
|
||||||
|
/** Admit any valid credential when the allow-list has no match (prototype). */
|
||||||
|
openEnrollment?: boolean
|
||||||
|
/** PIN supplied on the follow-up call after a `pin-required` outcome. */
|
||||||
|
pin?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three outcomes, so the caller can drive a two-step flow:
|
||||||
|
* - `granted` → send ACCESS_GRANTED
|
||||||
|
* - `pin-required` → prompt for a PIN, then call authorize() again with `pin`
|
||||||
|
* - `denied` → send ACCESS_DENIED(reason)
|
||||||
|
*/
|
||||||
|
export type AuthorizeOutcome =
|
||||||
|
| { status: 'granted'; role: AccessRole; credentialIdHash: string }
|
||||||
|
| { status: 'pin-required'; credentialIdHash: string }
|
||||||
|
| { status: 'denied'; credentialIdHash: string; reason: string }
|
||||||
|
|
||||||
|
/** Salted SHA-256, hex-encoded. */
|
||||||
|
async function sha256Hex(input: string): Promise<string> {
|
||||||
|
const data = new TextEncoder().encode(input)
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||||
|
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hashId = (id: string, salt: string): Promise<string> => sha256Hex(`id:${salt}:${id}`)
|
||||||
|
export const hashPin = (pin: string, salt: string): Promise<string> =>
|
||||||
|
sha256Hex(`pin:${salt}:${pin}`)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a scan to a canonical identity string, or `null` if malformed.
|
||||||
|
* npub is decoded to its hex pubkey so npub/hex forms compare equal and a
|
||||||
|
* stray (non-npub) QR is rejected.
|
||||||
|
*/
|
||||||
|
function canonicalId(scan: AccessScan): string | null {
|
||||||
|
if (scan.kind === 'uid') return scan.uid || null
|
||||||
|
if (scan.kind === 'boltcard') return scan.externalId || null
|
||||||
|
if (scan.kind === 'challenge') return null // v2 — handled separately
|
||||||
|
// Tolerate real-world nostr QR shapes: a bare `npub1…`, a `nostr:` URI
|
||||||
|
// prefix, and `nprofile1…` (npub + relay hints, what many clients export).
|
||||||
|
const raw = scan.npub.trim().replace(/^nostr:/i, '')
|
||||||
|
try {
|
||||||
|
const decoded = nip19Decode(raw)
|
||||||
|
if (decoded.type === 'npub' && typeof decoded.data === 'string') {
|
||||||
|
return decoded.data
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
decoded.type === 'nprofile' &&
|
||||||
|
decoded.data &&
|
||||||
|
typeof (decoded.data as { pubkey?: unknown }).pubkey === 'string'
|
||||||
|
) {
|
||||||
|
return (decoded.data as { pubkey: string }).pubkey
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a scanned credential is authorized.
|
||||||
|
* Always resolves (never throws) so the caller can uniformly react.
|
||||||
|
*/
|
||||||
|
export async function authorize(
|
||||||
|
scan: AccessScan,
|
||||||
|
allowList: AllowListEntry[],
|
||||||
|
opts: AuthorizeOptions
|
||||||
|
): Promise<AuthorizeOutcome> {
|
||||||
|
if (scan.kind === 'challenge') {
|
||||||
|
return {
|
||||||
|
status: 'denied',
|
||||||
|
credentialIdHash: '',
|
||||||
|
reason: 'challenge-response credentials not yet supported',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = canonicalId(scan)
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
status: 'denied',
|
||||||
|
credentialIdHash: '',
|
||||||
|
reason:
|
||||||
|
scan.kind === 'npub'
|
||||||
|
? 'not a valid npub'
|
||||||
|
: scan.kind === 'boltcard'
|
||||||
|
? 'not a valid card'
|
||||||
|
: 'invalid credential',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialIdHash = await hashId(id, opts.salt)
|
||||||
|
const entry = allowList.find((e) => e.idHash === credentialIdHash)
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
if (opts.openEnrollment) {
|
||||||
|
return { status: 'granted', role: 'user', credentialIdHash }
|
||||||
|
}
|
||||||
|
return { status: 'denied', credentialIdHash, reason: 'not authorized' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// No PIN configured → single-factor grant.
|
||||||
|
if (!entry.pinHash) {
|
||||||
|
return { status: 'granted', role: entry.role, credentialIdHash }
|
||||||
|
}
|
||||||
|
|
||||||
|
// PIN configured but not yet supplied → ask for it.
|
||||||
|
if (opts.pin === undefined) {
|
||||||
|
return { status: 'pin-required', credentialIdHash }
|
||||||
|
}
|
||||||
|
|
||||||
|
// PIN supplied → verify.
|
||||||
|
const pinHash = await hashPin(opts.pin, opts.salt)
|
||||||
|
if (pinHash !== entry.pinHash) {
|
||||||
|
return { status: 'denied', credentialIdHash, reason: 'incorrect PIN' }
|
||||||
|
}
|
||||||
|
return { status: 'granted', role: entry.role, credentialIdHash }
|
||||||
|
}
|
||||||
27
apps/machine/src/services/access/boltcard.ts
Normal file
27
apps/machine/src/services/access/boltcard.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* Bolt Card lnurlw parsing for the access gate (ADR-003).
|
||||||
|
*
|
||||||
|
* The tap-to-enter flow reads a Bolt Card's `lnurlw://…/scan/<external_id>?p=&c=`
|
||||||
|
* voucher and needs the `external_id` for the session identity — WITHOUT hitting
|
||||||
|
* the server (that would burn the single-use SUN p/c we want to reuse at
|
||||||
|
* Complete). So this is a purely local parse: extract the id from the URL path;
|
||||||
|
* the p/c ride along in the stored lnurlw and are only spent at payment time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Extract a Bolt Card's `external_id` from its tapped lnurlw. Null if not one. */
|
||||||
|
export function parseBoltcardLnurlw(lnurlw: string): { externalId: string } | null {
|
||||||
|
let s = lnurlw.trim()
|
||||||
|
if (!s) return null
|
||||||
|
if (s.toLowerCase().startsWith('lightning:')) s = s.slice('lightning:'.length)
|
||||||
|
const https = s.replace(/^lnurlw:\/\//i, 'https://').replace(/^lnurl:\/\//i, 'https://')
|
||||||
|
if (!/^https:\/\//i.test(https)) return null
|
||||||
|
try {
|
||||||
|
const u = new URL(https)
|
||||||
|
// …/boltcards/api/v1/scan/<external_id>
|
||||||
|
const m = u.pathname.match(/\/scan\/([^/?#]+)/)
|
||||||
|
if (!m || !m[1]) return null
|
||||||
|
return { externalId: decodeURIComponent(m[1]) }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/machine/src/services/access/index.ts
Normal file
43
apps/machine/src/services/access/index.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* Access-control module surface (ADR-003).
|
||||||
|
*
|
||||||
|
* `availableAccessReaders()` returns the readers this device can run, in
|
||||||
|
* preference order: the camera npub-QR badge first (prototype, works on the
|
||||||
|
* batm3 today), the mock reader as a keyboard/console fallback. PR3 adds a
|
||||||
|
* Web-NFC reader and PR4 the serial `/dev/ttyNFC` reader ahead of these.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { QrNpubAccessReader } from './qr-npub-reader'
|
||||||
|
import { MockAccessReader } from './mock-reader'
|
||||||
|
import type { AccessReader } from './types'
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AccessReader,
|
||||||
|
AccessReaderKind,
|
||||||
|
AccessReaderStartOptions,
|
||||||
|
AccessScan,
|
||||||
|
AccessRole,
|
||||||
|
StopCapture,
|
||||||
|
} from './types'
|
||||||
|
export { QrNpubAccessReader } from './qr-npub-reader'
|
||||||
|
export { MockAccessReader, MOCK_NPUB } from './mock-reader'
|
||||||
|
export { authorize, hashId, hashPin } from './authorize'
|
||||||
|
export type { AllowListEntry, AuthorizeOptions, AuthorizeOutcome } from './authorize'
|
||||||
|
export { parseBoltcardLnurlw } from './boltcard'
|
||||||
|
|
||||||
|
/** All readers in preference order, regardless of availability. */
|
||||||
|
export function allAccessReaders(): AccessReader[] {
|
||||||
|
// PR3: WebNfcAccessReader, PR4: SerialNfcAccessReader — inserted ahead of the
|
||||||
|
// camera once real NFC hardware is present.
|
||||||
|
return [new QrNpubAccessReader(), new MockAccessReader()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only the readers this device can run, in preference order. The mock reader
|
||||||
|
* is always available, so it lands last as a guaranteed fallback.
|
||||||
|
*/
|
||||||
|
export async function availableAccessReaders(): Promise<AccessReader[]> {
|
||||||
|
const readers = allAccessReaders()
|
||||||
|
const flags = await Promise.all(readers.map((r) => r.isAvailable()))
|
||||||
|
return readers.filter((_, i) => flags[i])
|
||||||
|
}
|
||||||
53
apps/machine/src/services/access/mock-reader.ts
Normal file
53
apps/machine/src/services/access/mock-reader.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
/**
|
||||||
|
* Mock access reader (ADR-003) — SCAFFOLD, no hardware.
|
||||||
|
*
|
||||||
|
* A keyboard/console fallback for when no camera or NFC reader is present
|
||||||
|
* (headless dev, CI, a batm3 with a dead camera). Emits an npub scan on
|
||||||
|
* demand via two triggers:
|
||||||
|
* - `window.__bitspireMockCard(npub?)` — from the LockedView dev button or
|
||||||
|
* the devtools console.
|
||||||
|
* - the `F9` key — a quick tap on the physical machine.
|
||||||
|
*
|
||||||
|
* Registered only when it is the sole available reader (see `index.ts`), so it
|
||||||
|
* never shadows the real camera/NFC path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { npubEncode } from 'nostr-tools/nip19'
|
||||||
|
import type { AccessReader, AccessReaderStartOptions, StopCapture } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default npub the mock emits when none is supplied — derived from a fixed
|
||||||
|
* (all-ones) hex pubkey so it carries a valid bech32 checksum and survives
|
||||||
|
* `authorize()`'s nip19 decode. Not a real key; dev-only.
|
||||||
|
*/
|
||||||
|
export const MOCK_NPUB = npubEncode('11'.repeat(32))
|
||||||
|
|
||||||
|
interface MockCardGlobal {
|
||||||
|
__bitspireMockCard?: (npub?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockAccessReader implements AccessReader {
|
||||||
|
readonly kind = 'mock' as const
|
||||||
|
readonly label = 'Mock reader (dev)'
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(opts: AccessReaderStartOptions): Promise<StopCapture> {
|
||||||
|
const emit = (npub: string = MOCK_NPUB) => opts.onScan({ kind: 'npub', npub })
|
||||||
|
|
||||||
|
const g = globalThis as unknown as MockCardGlobal
|
||||||
|
g.__bitspireMockCard = emit
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'F9') emit()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
if (g.__bitspireMockCard === emit) delete g.__bitspireMockCard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
apps/machine/src/services/access/qr-npub-reader.ts
Normal file
79
apps/machine/src/services/access/qr-npub-reader.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
/**
|
||||||
|
* QR-npub access reader (ADR-003, PROTOTYPE).
|
||||||
|
*
|
||||||
|
* Until the NFC reader hardware exists, the batm3's camera — the same one the
|
||||||
|
* pairing wizard uses — reads a QR "badge" that encodes the user's npub. The
|
||||||
|
* decoded npub is handed to `authorize()`, which admits it (optionally behind
|
||||||
|
* a PIN). This is a thin adapter onto the same `qr/dom.js` decode loop as
|
||||||
|
* `pairing/qr-source.ts`; see that file for the capture-resolution rationale.
|
||||||
|
*
|
||||||
|
* It emits the raw decoded string as an `npub` scan and lets `authorize()`
|
||||||
|
* validate it — a stray, non-npub QR is rejected there, not here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { QRCanvas, frontalCamera, frameLoop } from 'qr/dom.js'
|
||||||
|
import type { AccessReader, AccessReaderStartOptions, StopCapture } from './types'
|
||||||
|
|
||||||
|
export class QrNpubAccessReader implements AccessReader {
|
||||||
|
readonly kind = 'qr-npub' as const
|
||||||
|
readonly label = 'Camera (npub QR)'
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
return (
|
||||||
|
typeof navigator !== 'undefined' &&
|
||||||
|
!!navigator.mediaDevices &&
|
||||||
|
typeof navigator.mediaDevices.getUserMedia === 'function'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(opts: AccessReaderStartOptions): Promise<StopCapture> {
|
||||||
|
const { onScan, onError, video } = opts
|
||||||
|
if (!video) throw new Error('QrNpubAccessReader requires a <video> element')
|
||||||
|
|
||||||
|
const camera = await frontalCamera(video)
|
||||||
|
|
||||||
|
// Match the pairing source's deliberate capture resolution (see
|
||||||
|
// pairing/qr-source.ts) — 1280x960 balances px/module against decode speed
|
||||||
|
// on this fixed-focus panel. Soft `ideal` so a camera that can't honor it
|
||||||
|
// degrades instead of throwing.
|
||||||
|
try {
|
||||||
|
const stream = video.srcObject
|
||||||
|
if (stream instanceof MediaStream) {
|
||||||
|
await stream.getVideoTracks()[0]?.applyConstraints({
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 960 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
onError?.(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = new QRCanvas() // decode-only
|
||||||
|
|
||||||
|
let stopped = false
|
||||||
|
let cancel: (() => void) | null = null
|
||||||
|
const stop: StopCapture = () => {
|
||||||
|
if (stopped) return
|
||||||
|
stopped = true
|
||||||
|
cancel?.()
|
||||||
|
camera.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel = frameLoop(() => {
|
||||||
|
if (stopped) return
|
||||||
|
try {
|
||||||
|
const result = camera.readFrame(canvas, true)
|
||||||
|
if (result) {
|
||||||
|
// Stop on first decode so one badge isn't ingested repeatedly; the
|
||||||
|
// store restarts the reader if authorization fails.
|
||||||
|
stop()
|
||||||
|
onScan({ kind: 'npub', npub: result.trim() })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
onError?.(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return stop
|
||||||
|
}
|
||||||
|
}
|
||||||
66
apps/machine/src/services/access/types.ts
Normal file
66
apps/machine/src/services/access/types.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
/**
|
||||||
|
* Access-control reader abstraction (ADR-003).
|
||||||
|
*
|
||||||
|
* Mirrors the `services/pairing` `PairingSource` seam: an `AccessReader`
|
||||||
|
* captures a credential from whatever hardware the machine has and hands an
|
||||||
|
* `AccessScan` to the store, which authorizes it and grants/denies terminal
|
||||||
|
* access. Implementations live next to this file:
|
||||||
|
* - `qr-npub-reader.ts` — camera scans an npub QR "badge" (PROTOTYPE, works
|
||||||
|
* on batm3 today — same camera the pairing wizard uses)
|
||||||
|
* - `mock-reader.ts` — dev, no hardware (keyboard / console trigger)
|
||||||
|
* - `web-nfc-reader.ts` — Web NFC / NDEFReader, laptop/phone dev (PR3)
|
||||||
|
* - `serial-reader.ts` — /dev/ttyNFC via main-process HAL + IPC (PR4)
|
||||||
|
*
|
||||||
|
* The reader emits a RAW credential; hashing/authorization (and the optional
|
||||||
|
* PIN second factor) is the store's job (see `authorize.ts`), so raw ids never
|
||||||
|
* leave this layer (KYC-free).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AccessRole } from '@bitSpire/state-machine'
|
||||||
|
|
||||||
|
export type { AccessRole }
|
||||||
|
|
||||||
|
export type AccessReaderKind = 'qr-npub' | 'mock' | 'nfc-web' | 'nfc-serial'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A raw credential captured by a reader. Discriminated union so new factors
|
||||||
|
* are additive:
|
||||||
|
* - `npub` — prototype QR badge (this PR)
|
||||||
|
* - `uid` — NFC card UID (PR4)
|
||||||
|
* - `challenge` — card-signed nonce, challenge-response (PR5)
|
||||||
|
*/
|
||||||
|
export type AccessScan =
|
||||||
|
| { kind: 'npub'; npub: string }
|
||||||
|
| { kind: 'uid'; uid: string }
|
||||||
|
// A tapped Bolt Card: `externalId` is the identity (from the lnurlw path);
|
||||||
|
// `lnurlw` is the full voucher (p/c intact) the session reuses at Complete to
|
||||||
|
// move sats. Only `externalId` is ever hashed/authorized — the p/c never enter
|
||||||
|
// the authorize layer (KYC-free; they're single-use secrets held transiently
|
||||||
|
// by the store for the one transaction).
|
||||||
|
| { kind: 'boltcard'; externalId: string; lnurlw: string }
|
||||||
|
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string }
|
||||||
|
|
||||||
|
export interface AccessReaderStartOptions {
|
||||||
|
/** Called with each captured credential. */
|
||||||
|
onScan: (scan: AccessScan) => void
|
||||||
|
/** Non-fatal capture error (e.g. a frame decode glitch). */
|
||||||
|
onError?: (error: unknown) => void
|
||||||
|
/**
|
||||||
|
* The <video> element a camera reader renders into. Required by camera
|
||||||
|
* readers (qr-npub); ignored by readers with no viewfinder (mock, NFC).
|
||||||
|
*/
|
||||||
|
video?: HTMLVideoElement
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases the reader (camera stream, event listeners, serial handle). Idempotent. */
|
||||||
|
export type StopCapture = () => void
|
||||||
|
|
||||||
|
export interface AccessReader {
|
||||||
|
readonly kind: AccessReaderKind
|
||||||
|
/** Short human label for status/debug UI. */
|
||||||
|
readonly label: string
|
||||||
|
/** Whether this reader can run in the current environment. */
|
||||||
|
isAvailable(): Promise<boolean>
|
||||||
|
/** Begin capturing; resolves once the reader is live. */
|
||||||
|
start(opts: AccessReaderStartOptions): Promise<StopCapture>
|
||||||
|
}
|
||||||
|
|
@ -505,6 +505,31 @@ export async function initializeLightningServices(options?: {
|
||||||
}
|
}
|
||||||
console.log('[Lightning] LNbits wallet:', lnbitsWalletId)
|
console.log('[Lightning] LNbits wallet:', lnbitsWalletId)
|
||||||
|
|
||||||
|
// ── Public web demo: stamp the throwaway account so it can be swept ──────
|
||||||
|
// The browser demo (atm.demo.aiolabs.dev) runs with an EPHEMERAL identity —
|
||||||
|
// a fresh keypair per page load — so LNbits mints a new account + a fresh
|
||||||
|
// auto-credited wallet for every visitor. That isolation is the point (a
|
||||||
|
// single baked-in key would be credited exactly once and then drain), but it
|
||||||
|
// leaves throwaway accounts behind, and nothing in an auto-created row says
|
||||||
|
// "demo": pubkey-set/prvkey-NULL also describes a real ATM.
|
||||||
|
//
|
||||||
|
// A nostr pubkey can't carry a marker (you'd have to grind a vanity prefix,
|
||||||
|
// far too slow to do on page load), and the account/wallet the server
|
||||||
|
// auto-creates isn't nameable by the client. So we mint one extra,
|
||||||
|
// never-used wallet whose NAME is the tag: sweeping is then an exact string
|
||||||
|
// match on wallet name rather than a heuristic about what looks disposable.
|
||||||
|
//
|
||||||
|
// Unset on every real machine, so this is inert outside the demo build. The
|
||||||
|
// call is fire-and-forget: losing the marker degrades cleanup, not the demo.
|
||||||
|
const demoTag = (import.meta.env.VITE_DEMO_TAG as string | undefined)?.trim()
|
||||||
|
if (demoTag) {
|
||||||
|
void lnbits
|
||||||
|
.createWallet(demoTag)
|
||||||
|
// Never log the reply — create_wallet returns adminkey/inkey.
|
||||||
|
.then(() => console.log('[Lightning] Demo marker wallet created:', demoTag))
|
||||||
|
.catch((e) => console.warn('[Lightning] Demo marker wallet failed:', e))
|
||||||
|
}
|
||||||
|
|
||||||
// #70 P1: pull operator pubkey + fee config from LNbits over the authenticated
|
// #70 P1: pull operator pubkey + fee config from LNbits over the authenticated
|
||||||
// transport (spirekeeper#41 `get_machine_config`). A seed-only machine has no
|
// transport (spirekeeper#41 `get_machine_config`). A seed-only machine has no
|
||||||
// VITE_OPERATOR_PUBKEYS, so without this it can't trust its fee config and sits
|
// VITE_OPERATOR_PUBKEYS, so without this it can't trust its fee config and sits
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ import {
|
||||||
type ActorRefFrom,
|
type ActorRefFrom,
|
||||||
type SnapshotFrom,
|
type SnapshotFrom,
|
||||||
type ATMMachine,
|
type ATMMachine,
|
||||||
|
type AccessRole,
|
||||||
} from '@bitSpire/state-machine'
|
} from '@bitSpire/state-machine'
|
||||||
|
import type { AccessControlConfig } from '@/types/electron'
|
||||||
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
||||||
import { classifyInitError } from '@/services/init-error'
|
import { classifyInitError } from '@/services/init-error'
|
||||||
import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config'
|
import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config'
|
||||||
|
|
@ -25,6 +27,7 @@ import {
|
||||||
} from '@bitSpire/clink'
|
} from '@bitSpire/clink'
|
||||||
import type { TransactionRecord } from '@/types/state'
|
import type { TransactionRecord } from '@/types/state'
|
||||||
import { useAvailabilityBroadcast } from '@/composables/useAvailabilityBroadcast'
|
import { useAvailabilityBroadcast } from '@/composables/useAvailabilityBroadcast'
|
||||||
|
import { authorize, parseBoltcardLnurlw, type AccessScan } from '@/services/access'
|
||||||
|
|
||||||
// Check if we're running in Electron
|
// Check if we're running in Electron
|
||||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
||||||
|
|
@ -291,6 +294,23 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
// is in flight (settlement still arrives via the normal invoice watcher).
|
// is in flight (settlement still arrives via the normal invoice watcher).
|
||||||
const nfcStatus = ref<{ state: string; message?: string } | null>(null)
|
const nfcStatus = ref<{ state: string; message?: string } | null>(null)
|
||||||
const boltCardProcessing = ref(false)
|
const boltCardProcessing = ref(false)
|
||||||
|
// Access-control gate config (ADR-003). Defaults disabled → the machine's
|
||||||
|
// `locked` state bypasses straight to `idle` (behaviour identical to no gate).
|
||||||
|
// Populated from RuntimeConfig.accessControl in initializeForProduction.
|
||||||
|
const accessControl = ref<AccessControlConfig>({
|
||||||
|
enabled: false,
|
||||||
|
devUnlock: false,
|
||||||
|
openEnrollment: false,
|
||||||
|
salt: 'bitspire-access-v1',
|
||||||
|
allowList: [],
|
||||||
|
})
|
||||||
|
// Build/dev bypass — opens the gate even when enabled (browser dev / CI).
|
||||||
|
const accessBypassFlag = import.meta.env.VITE_SKIP_ACCESS_GATE === 'true'
|
||||||
|
// Tap-to-enter (ADR-003): the Bolt Card tapped at the locked screen is held
|
||||||
|
// here for the whole session so buy/sell just need "Complete" — no second tap.
|
||||||
|
// Carries the full lnurlw (single-use SUN p/c intact; spent only at payment).
|
||||||
|
// Cleared when the session ends (machine re-locks). Never logged.
|
||||||
|
const loadedBoltCard = ref<{ externalId: string; lnurlw: string } | null>(null)
|
||||||
const fiatCode = ref('USD')
|
const fiatCode = ref('USD')
|
||||||
// Defaults are 0 — the operator's fee config (received via Nostr
|
// Defaults are 0 — the operator's fee config (received via Nostr
|
||||||
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
||||||
|
|
@ -421,6 +441,11 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
|
|
||||||
const isIdle = computed(() => currentState.value === 'idle')
|
const isIdle = computed(() => currentState.value === 'idle')
|
||||||
|
|
||||||
|
// ADR-003: the machine is sitting at the access gate. When the gate is
|
||||||
|
// disabled this is never true (the `locked` state bypasses to `idle` on
|
||||||
|
// start), so App.vue's LockedView branch never renders on a non-access machine.
|
||||||
|
const isLocked = computed(() => currentState.value === 'locked')
|
||||||
|
|
||||||
const isCashIn = computed(() => {
|
const isCashIn = computed(() => {
|
||||||
const state = snapshot.value?.value
|
const state = snapshot.value?.value
|
||||||
return typeof state === 'object' && 'cashIn' in state
|
return typeof state === 'object' && 'cashIn' in state
|
||||||
|
|
@ -448,6 +473,8 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
currency: fiatCode.value,
|
currency: fiatCode.value,
|
||||||
cashInFeeFraction: cashInFeeFraction.value,
|
cashInFeeFraction: cashInFeeFraction.value,
|
||||||
cashOutFeeFraction: cashOutFeeFraction.value,
|
cashOutFeeFraction: cashOutFeeFraction.value,
|
||||||
|
accessControlEnabled: accessControl.value.enabled,
|
||||||
|
accessBypassFlag,
|
||||||
})
|
})
|
||||||
actor.value = createActor(machine)
|
actor.value = createActor(machine)
|
||||||
|
|
||||||
|
|
@ -473,6 +500,12 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
lnurlCleanupFn()
|
lnurlCleanupFn()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop the tapped-at-entry Bolt Card when the session ends (machine
|
||||||
|
// re-locks) so the next customer starts fresh — never carry a card over.
|
||||||
|
if (state === 'locked' && loadedBoltCard.value) {
|
||||||
|
loadedBoltCard.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// Detect network from first invoice we see
|
// Detect network from first invoice we see
|
||||||
if (newSnapshot.context.invoice) {
|
if (newSnapshot.context.invoice) {
|
||||||
detectNetworkFromInvoice(newSnapshot.context.invoice)
|
detectNetworkFromInvoice(newSnapshot.context.invoice)
|
||||||
|
|
@ -650,21 +683,76 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tapped Bolt Card at the locked screen (ADR-003 tap-to-enter). Soft entry:
|
||||||
|
* parse the external_id LOCALLY (no server call, so the single-use SUN p/c stay
|
||||||
|
* valid), authorize (open-enrollment or allow-list), then hold the full lnurlw
|
||||||
|
* for the session. The cryptographic check happens later, at Complete, when the
|
||||||
|
* stored lnurlw actually moves sats.
|
||||||
|
*/
|
||||||
|
async function handleBoltCardEntry(lnurlw: string) {
|
||||||
|
if (!isLocked.value) return
|
||||||
|
if (boltCardProcessing.value) return
|
||||||
|
const parsed = parseBoltcardLnurlw(lnurlw)
|
||||||
|
if (!parsed) {
|
||||||
|
nfcStatus.value = { state: 'declined', message: 'Not a Bolt Card' }
|
||||||
|
denyAccess('not a Bolt Card')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
boltCardProcessing.value = true
|
||||||
|
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
|
||||||
|
try {
|
||||||
|
const scan: AccessScan = { kind: 'boltcard', externalId: parsed.externalId, lnurlw }
|
||||||
|
const outcome = await authorize(scan, accessControl.value.allowList, {
|
||||||
|
salt: accessControl.value.salt,
|
||||||
|
openEnrollment: accessControl.value.openEnrollment,
|
||||||
|
})
|
||||||
|
if (outcome.status === 'granted') {
|
||||||
|
loadedBoltCard.value = { externalId: parsed.externalId, lnurlw }
|
||||||
|
nfcStatus.value = { state: 'accepted', message: 'Card accepted' }
|
||||||
|
grantAccess(outcome.role, outcome.credentialIdHash)
|
||||||
|
} else {
|
||||||
|
// pin-required can't occur for card-only open-enrollment; treat as denied.
|
||||||
|
const reason = outcome.status === 'denied' ? outcome.reason : 'card not authorized'
|
||||||
|
nfcStatus.value = { state: 'declined', message: reason }
|
||||||
|
denyAccess(reason, outcome.credentialIdHash)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
boltCardProcessing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a buy/sell using the Bolt Card loaded at entry — no second tap.
|
||||||
|
* Cash-out pulls via the stored lnurlw; cash-in resolves it to the card
|
||||||
|
* wallet's lnurlp and pays. Reuses the tap handlers verbatim.
|
||||||
|
*/
|
||||||
|
function completeWithCard() {
|
||||||
|
const card = loadedBoltCard.value
|
||||||
|
if (!card) return
|
||||||
|
if (isCashOut.value) void handleBoltCardTap(card.lnurlw)
|
||||||
|
else if (isCashIn.value) void handleBoltCardReceive(card.lnurlw)
|
||||||
|
}
|
||||||
|
|
||||||
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
|
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
|
||||||
function setupNfcListener() {
|
function setupNfcListener() {
|
||||||
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
|
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
|
||||||
window.electronAPI.onNfcCardTapped((lnurlw) => {
|
window.electronAPI.onNfcCardTapped((lnurlw) => {
|
||||||
// Route the same physical tap by flow: cash-out pulls, cash-in receives.
|
// Route the tap by state: locked → enter + load the card; then cash-out
|
||||||
if (isCashOut.value && nestedState.value === 'displayingInvoice') {
|
// pulls, cash-in receives (fallback if no card was loaded at entry).
|
||||||
|
if (isLocked.value) {
|
||||||
|
void handleBoltCardEntry(lnurlw)
|
||||||
|
} else if (isCashOut.value && nestedState.value === 'displayingInvoice') {
|
||||||
void handleBoltCardTap(lnurlw)
|
void handleBoltCardTap(lnurlw)
|
||||||
} else if (isCashIn.value && nestedState.value === 'displayingQR') {
|
} else if (isCashIn.value && nestedState.value === 'displayingQR') {
|
||||||
void handleBoltCardReceive(lnurlw)
|
void handleBoltCardReceive(lnurlw)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
window.electronAPI.onNfcStatus?.((status) => {
|
window.electronAPI.onNfcStatus?.((status) => {
|
||||||
// Only surface reader status on a tap screen, and don't clobber an
|
// Surface reader status on a tap screen (locked / invoice / QR); don't
|
||||||
// in-flight tap's message.
|
// clobber an in-flight tap's message.
|
||||||
const onTapScreen =
|
const onTapScreen =
|
||||||
|
isLocked.value ||
|
||||||
(isCashOut.value && nestedState.value === 'displayingInvoice') ||
|
(isCashOut.value && nestedState.value === 'displayingInvoice') ||
|
||||||
(isCashIn.value && nestedState.value === 'displayingQR')
|
(isCashIn.value && nestedState.value === 'displayingQR')
|
||||||
if (onTapScreen && !boltCardProcessing.value) {
|
if (onTapScreen && !boltCardProcessing.value) {
|
||||||
|
|
@ -683,6 +771,11 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
void handleBoltCardReceive(lnurlw)
|
void handleBoltCardReceive(lnurlw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dev/mock: simulate tapping a card at the locked screen (tap-to-enter). */
|
||||||
|
function simulateBoltCardEntry(lnurlw: string) {
|
||||||
|
void handleBoltCardEntry(lnurlw)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group an array of inserted bill denominations into { denomination, count } pairs.
|
* Group an array of inserted bill denominations into { denomination, count } pairs.
|
||||||
*/
|
*/
|
||||||
|
|
@ -1183,6 +1276,19 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
const runtimeFiatCode = runtimeConfig.fiatCode || 'USD'
|
const runtimeFiatCode = runtimeConfig.fiatCode || 'USD'
|
||||||
fiatCode.value = runtimeFiatCode
|
fiatCode.value = runtimeFiatCode
|
||||||
|
|
||||||
|
// Access-control gate (ADR-003). Must be set BEFORE the machine is built
|
||||||
|
// (initialize() reads accessControl.value to seed the `locked` state).
|
||||||
|
if (runtimeConfig.accessControl) {
|
||||||
|
accessControl.value = runtimeConfig.accessControl
|
||||||
|
if (runtimeConfig.accessControl.enabled) {
|
||||||
|
console.log(
|
||||||
|
`[ATM] Access control ENABLED (openEnrollment=${runtimeConfig.accessControl.openEnrollment}, ` +
|
||||||
|
`allowList=${runtimeConfig.accessControl.allowList.length} entries, ` +
|
||||||
|
`devUnlock=${runtimeConfig.accessControl.devUnlock})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load persisted operator fee config (aiolabs/lamassu-next#57). If no
|
// Load persisted operator fee config (aiolabs/lamassu-next#57). If no
|
||||||
// config has ever been applied (fresh ATM, pre-operator-publish),
|
// config has ever been applied (fresh ATM, pre-operator-publish),
|
||||||
// enter the 'awaiting-fees' maintenance state — UI shows the operator
|
// enter the 'awaiting-fees' maintenance state — UI shows the operator
|
||||||
|
|
@ -1491,6 +1597,61 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
actor.value.send(event)
|
actor.value.send(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Access control (ADR-003) ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit stub — records an access decision. PR1 logs only (hashed id, never a
|
||||||
|
* raw credential); a fast-follow persists to state.db and optionally a Nostr
|
||||||
|
* event (see ADR-003).
|
||||||
|
*/
|
||||||
|
function recordAccessAudit(outcome: {
|
||||||
|
result: 'granted' | 'denied'
|
||||||
|
role?: AccessRole
|
||||||
|
credentialIdHash: string
|
||||||
|
reason?: string
|
||||||
|
}) {
|
||||||
|
console.info('[Access] audit', {
|
||||||
|
result: outcome.result,
|
||||||
|
role: outcome.role ?? null,
|
||||||
|
// Truncate the hash in logs — it's already non-reversible, but no need to
|
||||||
|
// splash the full value across the journal.
|
||||||
|
credentialIdHash: outcome.credentialIdHash.slice(0, 12),
|
||||||
|
reason: outcome.reason ?? null,
|
||||||
|
at: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grant terminal access after a credential (and any PIN) is authorized. */
|
||||||
|
function grantAccess(role: AccessRole, credentialIdHash: string) {
|
||||||
|
recordAccessAudit({ result: 'granted', role, credentialIdHash })
|
||||||
|
send({ type: 'ACCESS_GRANTED', role, credentialIdHash })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject an access attempt; the machine stays locked and shows the reason. */
|
||||||
|
function denyAccess(reason: string, credentialIdHash = '') {
|
||||||
|
recordAccessAudit({ result: 'denied', credentialIdHash, reason })
|
||||||
|
send({ type: 'ACCESS_DENIED', reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runtime dev/operator unlock (gated by the machine's devUnlockAllowed guard). */
|
||||||
|
function devUnlock() {
|
||||||
|
recordAccessAudit({ result: 'granted', role: 'operator', credentialIdHash: 'dev-unlock' })
|
||||||
|
send({ type: 'DEV_UNLOCK' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the current tap-in session and re-lock immediately (drops the loaded
|
||||||
|
* Bolt Card via `locked`'s entry). Routed through the machine's root-level
|
||||||
|
* END_SESSION so it locks from any unlocked state. Callers: the "End session"
|
||||||
|
* button, the idle-inactivity timer, and the absolute session cap. `reason`
|
||||||
|
* is recorded for the access audit trail — never a raw credential. No-op
|
||||||
|
* unless the gate is active (guarded in the machine).
|
||||||
|
*/
|
||||||
|
function endSession(reason: 'user' | 'inactivity' | 'session-cap' = 'user') {
|
||||||
|
console.info(`[ATM] Ending session — reason=${reason}`)
|
||||||
|
send({ type: 'END_SESSION' })
|
||||||
|
}
|
||||||
|
|
||||||
// Convenience methods for common events
|
// Convenience methods for common events
|
||||||
function selectCashIn() {
|
function selectCashIn() {
|
||||||
send({ type: 'SELECT_CASH_IN' })
|
send({ type: 'SELECT_CASH_IN' })
|
||||||
|
|
@ -1640,6 +1801,18 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
boltCardProcessing,
|
boltCardProcessing,
|
||||||
simulateBoltCardTap,
|
simulateBoltCardTap,
|
||||||
simulateBoltCardReceive,
|
simulateBoltCardReceive,
|
||||||
|
// Tap-to-enter: card loaded at the locked screen, reused at Complete
|
||||||
|
loadedBoltCard,
|
||||||
|
completeWithCard,
|
||||||
|
simulateBoltCardEntry,
|
||||||
|
|
||||||
|
// Access control (ADR-003)
|
||||||
|
accessControl,
|
||||||
|
isLocked,
|
||||||
|
grantAccess,
|
||||||
|
denyAccess,
|
||||||
|
devUnlock,
|
||||||
|
endSession,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
initialize,
|
initialize,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
|
|
||||||
/* Hide cursor completely on touchscreen kiosk */
|
/* Hide cursor completely on touchscreen kiosk.
|
||||||
*,
|
Scoped to .kiosk (set on <html> by main.ts) so the public web demo, which
|
||||||
*::before,
|
runs in an ordinary browser with a mouse, keeps a visible pointer. */
|
||||||
*::after {
|
.kiosk,
|
||||||
|
.kiosk *,
|
||||||
|
.kiosk *::before,
|
||||||
|
.kiosk *::after {
|
||||||
cursor: none !important;
|
cursor: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
22
apps/machine/src/types/electron.d.ts
vendored
22
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -2,6 +2,26 @@
|
||||||
* Type declarations for Electron API exposed via preload
|
* Type declarations for Electron API exposed via preload
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { AllowListEntry } from '../services/access/authorize'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access-control config (ADR-003). Loaded by the main process from env +
|
||||||
|
* an optional /var/lib/bitspire/access.json. `enabled` defaults false, so a
|
||||||
|
* machine with no access config behaves exactly as before.
|
||||||
|
*/
|
||||||
|
export interface AccessControlConfig {
|
||||||
|
/** Master switch for the badge-to-enter gate. */
|
||||||
|
enabled: boolean
|
||||||
|
/** Allow the runtime dev/operator unlock gesture on the locked screen. */
|
||||||
|
devUnlock: boolean
|
||||||
|
/** Prototype: admit any valid npub when the allow-list has no match. */
|
||||||
|
openEnrollment: boolean
|
||||||
|
/** Per-machine salt for hashing credentials/PINs. */
|
||||||
|
salt: string
|
||||||
|
/** Authorized identities (hashed). Empty in open-enrollment prototype mode. */
|
||||||
|
allowList: AllowListEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface RuntimeConfig {
|
export interface RuntimeConfig {
|
||||||
relayUrl: string
|
relayUrl: string
|
||||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||||
|
|
@ -17,6 +37,8 @@ export interface RuntimeConfig {
|
||||||
maintenanceMode: boolean
|
maintenanceMode: boolean
|
||||||
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
|
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
|
||||||
branding: BrandingConfig | null
|
branding: BrandingConfig | null
|
||||||
|
/** Access-control gate config (ADR-003). Always present; `enabled` defaults false. */
|
||||||
|
accessControl: AccessControlConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Operator branding config. Wire payload from Electron IPC; renderer applies via useBranding(). */
|
/** Operator branding config. Wire payload from Electron IPC; renderer applies via useBranding(). */
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,24 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tap-to-enter: card already loaded → one-press Complete (no re-tap) -->
|
||||||
|
<div
|
||||||
|
v-if="atmStore.loadedBoltCard"
|
||||||
|
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
|
||||||
|
>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
class="w-full bg-success text-success-foreground"
|
||||||
|
size="kiosk-lg"
|
||||||
|
:disabled="atmStore.boltCardProcessing"
|
||||||
|
@click="atmStore.completeWithCard()"
|
||||||
|
>
|
||||||
|
{{ atmStore.boltCardProcessing ? 'Completing…' : 'Complete Purchase' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- LNURL URI (web-ui only) -->
|
<!-- LNURL URI (web-ui only) -->
|
||||||
<div v-if="!isElectron && currentQrValue" class="pt-4 text-center space-y-1">
|
<div v-if="!isElectron && currentQrValue" class="pt-4 text-center space-y-1">
|
||||||
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,24 @@ function formatFiat(cents: number): string {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tap-to-enter: card already loaded → one-press Complete (no re-tap) -->
|
||||||
|
<div
|
||||||
|
v-if="atmStore.loadedBoltCard"
|
||||||
|
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
|
||||||
|
>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
class="w-full bg-success text-success-foreground"
|
||||||
|
size="kiosk-lg"
|
||||||
|
:disabled="atmStore.boltCardProcessing"
|
||||||
|
@click="atmStore.completeWithCard()"
|
||||||
|
>
|
||||||
|
{{ atmStore.boltCardProcessing ? 'Completing…' : 'Complete Sale' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Invoice info with copy button (web-ui only) -->
|
<!-- Invoice info with copy button (web-ui only) -->
|
||||||
<div v-if="context?.invoice && !isElectron" class="pt-4 text-center">
|
<div v-if="context?.invoice && !isElectron" class="pt-4 text-center">
|
||||||
<p class="font-mono-code mb-2 truncate text-xs text-muted-foreground max-w-[300px]">
|
<p class="font-mono-code mb-2 truncate text-xs text-muted-foreground max-w-[300px]">
|
||||||
|
|
|
||||||
|
|
@ -173,15 +173,37 @@ function handleCashOut() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Help button (top-left) -->
|
<!-- Top-left utility buttons: Help, plus an End-session "✕" while the access
|
||||||
|
gate is engaged. Kept in the left corner (not top-right) so they never
|
||||||
|
collide with the centered balance/commission chips, which wrap into the
|
||||||
|
top-right on narrower screens (e.g. sintra). A tap-in loads the holder's
|
||||||
|
Bolt Card for the whole session, so the ✕ gives them an explicit way to
|
||||||
|
re-lock the moment they're done rather than waiting out the idle timeout
|
||||||
|
(which would leave the card usable by the next person meanwhile). -->
|
||||||
|
<div class="absolute top-4 left-4 lg:top-8 lg:left-8 flex items-center gap-2 lg:gap-3">
|
||||||
|
<!-- End session first (leftmost): a solid `destructive` swatch so the exit
|
||||||
|
reads as red in every theme (--destructive is theme-scoped). Shown
|
||||||
|
only while the access gate is engaged. -->
|
||||||
|
<Button
|
||||||
|
v-if="atmStore.accessControl.enabled"
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
class="h-14 w-14 lg:h-20 lg:w-20 rounded-full text-2xl lg:text-4xl font-bold"
|
||||||
|
aria-label="End session"
|
||||||
|
@click="atmStore.endSession()"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="absolute top-4 left-4 lg:top-8 lg:left-8 h-14 w-14 lg:h-20 lg:w-20 rounded-full border-2 border-muted-foreground/30 text-2xl lg:text-4xl text-muted-foreground"
|
class="h-14 w-14 lg:h-20 lg:w-20 rounded-full border-2 border-muted-foreground/30 text-2xl lg:text-4xl text-muted-foreground"
|
||||||
|
aria-label="Help"
|
||||||
@click="$router.push('/support')"
|
@click="$router.push('/support')"
|
||||||
>
|
>
|
||||||
?
|
?
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Debug toggle (only visible when debug bar is hidden, never in production) -->
|
<!-- Debug toggle (only visible when debug bar is hidden, never in production) -->
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
109
apps/machine/src/views/LockedView.vue
Normal file
109
apps/machine/src/views/LockedView.vue
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Access gate — "tap your Bolt Card" screen (ADR-003, tap-to-enter).
|
||||||
|
*
|
||||||
|
* Shown when the machine is healthy but locked (App.vue's `isLocked` branch).
|
||||||
|
* Entry is a single Bolt Card tap: the card is read by the main-process NFC
|
||||||
|
* reader and routed to the store (`handleBoltCardEntry`) which authorizes it
|
||||||
|
* (open-enrollment) and, on grant, loads the card into the session so buy/sell
|
||||||
|
* only need "Complete". This view is presentation-only — it shows the prompt
|
||||||
|
* and live reader status; the store owns the tap handling and machine events.
|
||||||
|
*
|
||||||
|
* Card-only by design: no camera/npub-QR, no PIN. A dev paste-box (debug builds)
|
||||||
|
* and a dev-unlock button remain for testing without hardware.
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useAtmStore } from '@/stores/atm'
|
||||||
|
import { useBranding } from '@/composables/useBranding'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import ColorModeToggle from '@/components/ColorModeToggle.vue'
|
||||||
|
import { Nfc } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const atmStore = useAtmStore()
|
||||||
|
const { logoUrl, title } = useBranding()
|
||||||
|
|
||||||
|
const denyReason = computed(() => atmStore.snapshot?.context.accessDenyReason ?? null)
|
||||||
|
const showDevUnlock = computed(() => atmStore.accessControl.devUnlock)
|
||||||
|
const nfc = computed(() => atmStore.nfcStatus)
|
||||||
|
const reading = computed(() => atmStore.boltCardProcessing)
|
||||||
|
|
||||||
|
// Dev: paste an lnurlw to simulate a tap-to-enter without a card.
|
||||||
|
const mockLnurlw = ref('')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="relative flex flex-1 flex-col items-center justify-center gap-10 bg-background p-8 text-foreground"
|
||||||
|
>
|
||||||
|
<!-- Light/dark toggle — shared kiosk-sized component -->
|
||||||
|
<ColorModeToggle class="absolute right-4 top-4 z-10" />
|
||||||
|
|
||||||
|
<!-- Brand: logo + title only, colours from the active theme (branding.json) -->
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<img v-if="logoUrl" :src="logoUrl" alt="" class="h-[16vh] max-h-44 w-auto object-contain" />
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight lg:text-5xl">{{ title }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tap target -->
|
||||||
|
<div class="flex flex-col items-center gap-6">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center rounded-full border-4 border-primary bg-card shadow-xl"
|
||||||
|
:class="reading ? 'animate-pulse' : ''"
|
||||||
|
style="width: min(48vw, 15rem); aspect-ratio: 1 / 1"
|
||||||
|
>
|
||||||
|
<Nfc class="size-24 text-primary lg:size-28" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-2xl font-semibold text-foreground lg:text-4xl">
|
||||||
|
{{ reading ? 'Reading card…' : 'Tap your Bolt Card to begin' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Reader status / denial reason -->
|
||||||
|
<p v-if="denyReason" class="text-lg font-medium text-destructive lg:text-2xl">
|
||||||
|
{{ denyReason }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="nfc?.message"
|
||||||
|
class="text-base lg:text-xl"
|
||||||
|
:class="
|
||||||
|
nfc.state === 'declined' || nfc.state === 'error'
|
||||||
|
? 'text-destructive'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ nfc.message }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="max-w-md text-center text-base text-muted-foreground lg:text-xl">
|
||||||
|
Hold your card flat against the reader
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dev affordances -->
|
||||||
|
<div class="mt-2 flex flex-col items-center gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="showDevUnlock"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="text-muted-foreground opacity-40 transition-opacity hover:opacity-100"
|
||||||
|
@click="atmStore.devUnlock()"
|
||||||
|
>
|
||||||
|
Dev unlock
|
||||||
|
</Button>
|
||||||
|
<div v-if="atmStore.debugMode" class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="mockLnurlw"
|
||||||
|
placeholder="lnurlw://… (paste to simulate a tap)"
|
||||||
|
class="w-56 rounded border border-input bg-background px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!mockLnurlw"
|
||||||
|
@click="atmStore.simulateBoltCardEntry(mockLnurlw)"
|
||||||
|
>
|
||||||
|
Tap
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
5
deploy/nixos/access.example.json
Normal file
5
deploy/nixos/access.example.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"openEnrollment": true,
|
||||||
|
"devUnlock": true
|
||||||
|
}
|
||||||
|
|
@ -33,7 +33,7 @@ esac
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
echo "=== Building Lamassu ATM Live USB ISO (model: $MODEL) ==="
|
echo "=== Building bitSpire ATM Live USB ISO (model: $MODEL) ==="
|
||||||
echo ""
|
echo ""
|
||||||
echo "This is a pure Nix build — no local pnpm required."
|
echo "This is a pure Nix build — no local pnpm required."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,8 @@
|
||||||
|
|
||||||
# pcscd gates client access via polkit; without a rule the sandboxed
|
# pcscd gates client access via polkit; without a rule the sandboxed
|
||||||
# `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize
|
# `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize
|
||||||
# it to talk to the daemon and the card.
|
# it to talk to the daemon and the card. The second rule lets the app trigger
|
||||||
|
# the NFC reader wedge-recovery service (see nfc-reader-reset below).
|
||||||
security.polkit.extraConfig = ''
|
security.polkit.extraConfig = ''
|
||||||
polkit.addRule(function(action, subject) {
|
polkit.addRule(function(action, subject) {
|
||||||
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
|
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
|
||||||
|
|
@ -103,8 +104,47 @@
|
||||||
return polkit.Result.YES;
|
return polkit.Result.YES;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
polkit.addRule(function(action, subject) {
|
||||||
|
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||||
|
action.lookup("unit") == "nfc-reader-reset.service" &&
|
||||||
|
subject.user == "bitspire") {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
});
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
# NFC reader wedge-recovery. The Feitian R502-CL CCID reader (and, less often,
|
||||||
|
# any CCID reader) can wedge: it keeps detecting a card but every APDU returns
|
||||||
|
# "card absent or mute", and ONLY a USB power-cycle clears it — restarting
|
||||||
|
# pcscd or the app does not. This oneshot re-binds the reader's USB device (a
|
||||||
|
# software replug); pcscd + nfc-pcsc then re-detect it on hotplug with no app
|
||||||
|
# restart (verified on-device). The app (unprivileged `bitspire`) starts it via
|
||||||
|
# the polkit rule above when it sees repeated read failures. Reader-agnostic:
|
||||||
|
# it matches the USB CCID interface class (0x0B), so it also covers a future
|
||||||
|
# ACR1252U swap without a config change.
|
||||||
|
systemd.services.nfc-reader-reset = {
|
||||||
|
description = "Power-cycle a wedged CCID NFC reader (USB re-bind)";
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
ExecStart = pkgs.writeShellScript "reset-nfc-reader" ''
|
||||||
|
set -u
|
||||||
|
found=0
|
||||||
|
for iface in /sys/bus/usb/devices/*:*/bInterfaceClass; do
|
||||||
|
[ -f "$iface" ] || continue
|
||||||
|
[ "$(${pkgs.coreutils}/bin/cat "$iface" 2>/dev/null)" = "0b" ] || continue
|
||||||
|
ifname=$(${pkgs.coreutils}/bin/basename "$(${pkgs.coreutils}/bin/dirname "$iface")")
|
||||||
|
dev=''${ifname%%:*}
|
||||||
|
echo "reset-nfc-reader: power-cycling CCID reader USB device $dev" >&2
|
||||||
|
echo -n "$dev" > /sys/bus/usb/drivers/usb/unbind 2>/dev/null || true
|
||||||
|
${pkgs.coreutils}/bin/sleep 2
|
||||||
|
echo -n "$dev" > /sys/bus/usb/drivers/usb/bind 2>/dev/null || true
|
||||||
|
found=1
|
||||||
|
done
|
||||||
|
[ "$found" = 1 ] || { echo "reset-nfc-reader: no CCID reader found" >&2; exit 1; }
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
# Disable suspend/hibernate for kiosk
|
# Disable suspend/hibernate for kiosk
|
||||||
systemd.targets = {
|
systemd.targets = {
|
||||||
sleep.enable = false;
|
sleep.enable = false;
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,27 @@
|
||||||
cpuFreqGovernor = "performance";
|
cpuFreqGovernor = "performance";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# PC/SC daemon for the HID Global OMNIKEY 5022 contactless reader
|
||||||
|
# (076b:5022, a CCID smart-card reader) used for Bolt Card tap-to-enter
|
||||||
|
# (ADR-003). pcscd binds the CCID driver; the app talks to pcscd's socket
|
||||||
|
# (via nfc-pcsc) rather than the USB device directly. Device-agnostic —
|
||||||
|
# same wiring as batm3's Feitian KP382; harmless if no reader is attached,
|
||||||
|
# pcscd just idles. Shared by every upboard machine (sintra, tejo).
|
||||||
|
services.pcscd.enable = true;
|
||||||
|
|
||||||
|
# pcscd gates client access via polkit; without a rule the sandboxed
|
||||||
|
# `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize
|
||||||
|
# it to talk to the daemon and the card.
|
||||||
|
security.polkit.extraConfig = ''
|
||||||
|
polkit.addRule(function(action, subject) {
|
||||||
|
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
|
||||||
|
action.id == "org.debian.pcsc-lite.access_card") &&
|
||||||
|
subject.user == "bitspire") {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
'';
|
||||||
|
|
||||||
# Disable suspend/hibernate for kiosk
|
# Disable suspend/hibernate for kiosk
|
||||||
systemd.targets = {
|
systemd.targets = {
|
||||||
sleep.enable = false;
|
sleep.enable = false;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Lamassu ATM Live USB Configuration
|
# bitSpire ATM Live USB Configuration
|
||||||
# Bootable ISO for testing on physical hardware without installing to disk.
|
# Bootable ISO for testing on physical hardware without installing to disk.
|
||||||
#
|
#
|
||||||
# Parameterized by machineModel (passed via specialArgs from flake.nix):
|
# Parameterized by machineModel (passed via specialArgs from flake.nix):
|
||||||
|
|
|
||||||
69
deploy/nixos/provision-access.sh
Executable file
69
deploy/nixos/provision-access.sh
Executable file
|
|
@ -0,0 +1,69 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Provision the access-control gate (ADR-003) to a deployed bitSpire ATM.
|
||||||
|
# Pushes an access.json to /var/lib/bitspire/ and restarts the service, so the
|
||||||
|
# gate can be toggled on a machine without an image rebuild (mirrors
|
||||||
|
# provision-branding.sh). Env defaults are overridden by whatever this file sets.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash provision-access.sh <access.json> # SSH to localhost:2222 (QEMU)
|
||||||
|
# bash provision-access.sh <access.json> 192.168.1.50 # a real ATM on the LAN
|
||||||
|
# bash provision-access.sh <access.json> 192.168.1.50 22 # custom SSH port
|
||||||
|
#
|
||||||
|
# access.json schema (all keys optional; omitted keys fall back to env/defaults):
|
||||||
|
# {
|
||||||
|
# "enabled": true, // master switch for the gate
|
||||||
|
# "openEnrollment": true, // prototype: admit any valid npub
|
||||||
|
# "devUnlock": true, // allow the on-screen dev/operator unlock
|
||||||
|
# "salt": "per-machine", // hashing salt (provision a real one for prod)
|
||||||
|
# "allowList": [ // authorized identities (hashed); empty in open mode
|
||||||
|
# { "idHash": "<hashId(hexpubkey,salt)>", "role": "user", "pinHash": "<hashPin(pin,salt)>" }
|
||||||
|
# ]
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# To DISABLE the gate again: push a file with {"enabled": false} (or delete
|
||||||
|
# /var/lib/bitspire/access.json on the machine) and restart.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACCESS_FILE="${1:-}"
|
||||||
|
ATM_HOST="${2:-localhost}"
|
||||||
|
ATM_SSH_PORT="${3:-2222}"
|
||||||
|
ATM_USER="bitspire"
|
||||||
|
REMOTE_FILE="/var/lib/bitspire/access.json"
|
||||||
|
|
||||||
|
if [ -z "$ACCESS_FILE" ]; then
|
||||||
|
echo "Usage: $0 <access.json> [host] [port]" >&2
|
||||||
|
echo " $0 ./access.json (QEMU on localhost:2222)" >&2
|
||||||
|
echo " $0 ./access.json 192.168.1.50 (real ATM)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$ACCESS_FILE" ]; then
|
||||||
|
echo "ERROR: access file not found: $ACCESS_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fail fast on malformed JSON before touching the machine.
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
jq empty "$ACCESS_FILE" || { echo "ERROR: $ACCESS_FILE is not valid JSON" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Provisioning access gate to $ATM_HOST:$ATM_SSH_PORT ==="
|
||||||
|
echo "Local file : $ACCESS_FILE"
|
||||||
|
echo "Remote file: $REMOTE_FILE"
|
||||||
|
cat "$ACCESS_FILE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Copy over SSH. --rsync-path=sudo because /var/lib/bitspire is owned by the
|
||||||
|
# bitspire service user, not the SSH user.
|
||||||
|
rsync -avz \
|
||||||
|
--rsync-path="sudo rsync" \
|
||||||
|
-e "ssh -o StrictHostKeyChecking=no -p $ATM_SSH_PORT" \
|
||||||
|
"$ACCESS_FILE" \
|
||||||
|
"$ATM_USER@$ATM_HOST:$REMOTE_FILE"
|
||||||
|
|
||||||
|
# Restart so loadAccessControl() re-reads the file.
|
||||||
|
ssh -o StrictHostKeyChecking=no -p "$ATM_SSH_PORT" "$ATM_USER@$ATM_HOST" \
|
||||||
|
"sudo systemctl restart bitspire"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Access gate provisioned. Service restarted. ==="
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Lamassu ATM Hardware udev Rules
|
# bitSpire ATM Hardware udev Rules
|
||||||
# Place in /etc/udev/rules.d/ or use services.udev.extraRules in NixOS
|
# Place in /etc/udev/rules.d/ or use services.udev.extraRules in NixOS
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
|
||||||
201
docs/adr/003-nfc-access-control-layer.md
Normal file
201
docs/adr/003-nfc-access-control-layer.md
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
# ADR-003: NFC Access-Control Layer (badge-to-enter) + Developer Bypass
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-07-29
|
||||||
|
**Context:** batm3 gaining a physical access layer — an NFC card must be presented to unlock the machine before anyone can transact. Reader hardware is not yet on hand; this ADR defines the direction and a non-breaking skeleton that is fully testable without it.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
1. **Add a top-level `locked` state to the ATM state machine, and make it the initial state.** It sits *below* the existing initialization gates (`unpaired` / `awaiting-fees` / `maintenance` / `signer-unreachable`, which live in `App.vue`). A healthy, paired machine boots into `locked` and only reveals `idle` (Buy/Sell) after an access grant.
|
||||||
|
|
||||||
|
2. **Access control is opt-in via runtime config (`accessControl.enabled`, default `false`).** When disabled, the machine behaves exactly as today (boots straight to `idle`). This makes the whole feature non-breaking for the current test unit and for production ATMs, and lets a half-built access layer never brick a working box. This is the single most important constraint on the design.
|
||||||
|
|
||||||
|
3. **The reader lives behind an `AccessReader` abstraction that mirrors the existing `PairingSource` seam.** First implementation is a `MockAccessReader` (dev button / hotkey), so the locked→idle→transaction path is exercisable today with zero hardware. Web NFC and serial-NFC implementations follow.
|
||||||
|
|
||||||
|
4. **Credential model is a discriminated union with an explicit upgrade path.** v1 = card UID matched against a hashed allow-list. v2 = challenge-response (card-held key signs a machine nonce), verified against an operator-authorized set. Ship v1; design the types so v2 is additive.
|
||||||
|
|
||||||
|
5. **Three-tier developer bypass**, following existing conventions: a build flag (`VITE_SKIP_ACCESS_GATE`), the config disable (`accessControl.enabled=false`), and a runtime operator/dev unlock gesture that dispatches a synthetic grant.
|
||||||
|
|
||||||
|
6. **Authorization is owned by the machine operator, not the SaaS operator** — consistent with [ADR-002](./002-remote-access-and-fleet-management.md). The card allow-list is authorized by the operator key (the [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) allow-list mechanism), local first, operator-synced later. When access control is *enabled* and the reader is absent/broken, the machine **fails closed** (with the operator/dev unlock as the escape hatch); when *disabled*, reader state is irrelevant.
|
||||||
|
|
||||||
|
7. **Every grant/deny is audited** to `state.db` (hashed credential + timestamp + role + outcome), with optional later publication as a Nostr event. No PII, consistent with the KYC-free principle.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### What this is (and what it is not)
|
||||||
|
|
||||||
|
This is the **end-user physical access plane**: a person must badge in to use the machine. It is distinct from the three planes in [ADR-002](./002-remote-access-and-fleet-management.md) — it is *not* the operator's SSH/NetBird recovery plane, and *not* the SaaS payment plane. It shares one idea with ADR-002: **the machine operator owns who is authorized**, expressed through the operator key / #42 allow-list.
|
||||||
|
|
||||||
|
### Why the codebase is well-shaped for this
|
||||||
|
|
||||||
|
Three seams already exist; we extend them rather than invent:
|
||||||
|
|
||||||
|
- **The idle→transaction transition is unguarded.** `packages/state-machine/src/machine.ts` starts at `initial: 'idle'` (~L431) and `idle` moves to `cashIn`/`cashOut` via plain `SELECT_CASH_IN` / `SELECT_CASH_OUT` transitions with no guards (~L457-464). Inserting a `locked` predecessor state is a localized change.
|
||||||
|
- **`PairingSource` is a reader abstraction designed to grow.** Its doc (`apps/machine/src/services/pairing/types.ts`) explicitly anticipates *"an NFC reader or a HAL barcode scanner… a HAL-scanner source can be added the same way without touching the wizard."* `AccessReader` mirrors it: `qr-source.ts` / `nfc-source.ts` → `mock-reader.ts` / `web-nfc-reader.ts` / `serial-reader.ts`.
|
||||||
|
- **Dev-flag and config conventions are established.** `import.meta.env.VITE_* === 'true'` (e.g. `VITE_MAINTENANCE_MODE`, `VITE_FORCE_MOCK`), plus Electron `get-config` fields that the renderer reads (`electron/main.ts` L280–312: `maintenanceMode`, `branding`). A new `accessControl` config field and a `VITE_SKIP_ACCESS_GATE` flag follow the same shape.
|
||||||
|
|
||||||
|
### Hardware reality check (important)
|
||||||
|
|
||||||
|
The batm3 already exposes an NFC device, but it is **serial**: `deploy/nixos/hardware/batm3.nix` L154 maps udev serial `A9ZF8ELY` → `/dev/ttyNFC`. The *existing* `pairing/nfc-source.ts` uses **Web NFC** (`NDEFReader`), which drives a phone/laptop NFC radio, **not** a serial reader. So the real batm3 access reader needs a **main-process serial driver** (HAL-style, per [ADR-001](./001-hal-architecture.md)) exposing card events to the renderer over IPC — the Web NFC path is only useful for laptop/phone dev. This ADR keeps that driver as a clearly-scoped later PR so the skeleton doesn't pretend the scaffold "just works" on the panel.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Boot / render layering
|
||||||
|
|
||||||
|
```
|
||||||
|
Electron get-config ─┐
|
||||||
|
▼
|
||||||
|
App.vue init gates (unchanged):
|
||||||
|
unpaired? → PairingWizard
|
||||||
|
initError (maintenance / awaiting-fees / signer-unreachable)? → maintenance screen
|
||||||
|
else ▼
|
||||||
|
State machine (paired + healthy):
|
||||||
|
┌───────────────────────────────────────────────┐
|
||||||
|
│ locked ──ACCESS_GRANTED──▶ idle │ ← NEW initial state
|
||||||
|
│ ▲ │ SELECT_CASH_* │
|
||||||
|
│ │ re-lock (session end / ▼ │
|
||||||
|
│ │ inactivity / complete) cashIn / cashOut │
|
||||||
|
│ └──────────────────────────┘ │
|
||||||
|
└───────────────────────────────────────────────┘
|
||||||
|
(when accessControl.enabled === false,
|
||||||
|
`locked` immediately `always`-bypasses to `idle`)
|
||||||
|
```
|
||||||
|
|
||||||
|
The access gate is strictly below App.vue's init gates: a machine that is unpaired or in maintenance never reaches `locked`.
|
||||||
|
|
||||||
|
### 1. State machine (`packages/state-machine`)
|
||||||
|
|
||||||
|
- New top-level state `locked`, `initial: 'locked'`.
|
||||||
|
- New events on the machine's event union: `ACCESS_GRANTED` (carries an authorized `CardCredential` + resolved role), `ACCESS_DENIED` (carries a reason), `DEV_UNLOCK`.
|
||||||
|
- `locked` transitions:
|
||||||
|
- `always: [{ guard: 'accessBypass', target: 'idle' }]` — instant pass-through when disabled/bypassed (no UI flicker; the view is gated on the same predicate).
|
||||||
|
- `on: { ACCESS_GRANTED: { target: 'idle', actions: ['startSession', 'recordAccessGrant'] }, ACCESS_DENIED: { actions: 'recordAccessDeny' }, DEV_UNLOCK: { guard: 'devUnlockAllowed', target: 'idle', actions: 'startDevSession' } }`.
|
||||||
|
- Re-lock: the existing `complete` auto-return (currently 60s → `idle`) and the inactivity timeouts (`INACTIVITY_TIMEOUT`/`TIMEOUT_MS`, ~L422-429) target `locked` instead of `idle`. Because `locked` `always`-bypasses when disabled, this is one code path for both modes.
|
||||||
|
- Purity: the state-machine package must not read Vite env. `accessControl.enabled` and the bypass boolean are passed in as **actor input → context** (`context.accessControlEnabled`, `context.accessBypass`); guard `accessBypass` reads context only. New context fields: `accessControlEnabled`, `accessBypass`, `session` (`{ role, grantedAt, credentialIdHash } | null`).
|
||||||
|
- Guards: `accessBypass`, `devUnlockAllowed`. Actions: `startSession`, `startDevSession`, `recordAccessGrant`, `recordAccessDeny`, and `resetContext` extended to clear `session`.
|
||||||
|
|
||||||
|
### 2. Config plumbing
|
||||||
|
|
||||||
|
- `apps/machine/src/types/electron.d.ts` — extend `RuntimeConfig` (L5) with:
|
||||||
|
```ts
|
||||||
|
accessControl: {
|
||||||
|
enabled: boolean // default false
|
||||||
|
devUnlock: boolean // allow the runtime operator/dev unlock gesture
|
||||||
|
// v2: allowListSource, challengeRequired, …
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `apps/machine/electron/main.ts` — the `get-config` handler (L280) returns `accessControl`, sourced from env for now (`ACCESS_CONTROL_ENABLED === 'true'`, `VITE_SKIP_ACCESS_GATE` → forces `enabled:false`), later from a provisioned file under `/var/lib/bitspire/` alongside branding.
|
||||||
|
- `apps/machine/.env.example` — document `VITE_SKIP_ACCESS_GATE=true` (browser/dev straight to idle) and `ACCESS_CONTROL_ENABLED`.
|
||||||
|
|
||||||
|
### 3. Reader abstraction (`apps/machine/src/services/access/`)
|
||||||
|
|
||||||
|
Mirrors `services/pairing/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
services/access/
|
||||||
|
types.ts # AccessReader, CardCredential (union), AccessRole, StopCapture
|
||||||
|
mock-reader.ts # PR1 — fires a card event on demand (dev button / hotkey)
|
||||||
|
web-nfc-reader.ts # PR3 — NDEFReader, dev on laptop/phone
|
||||||
|
serial-reader.ts # PR4 — /dev/ttyNFC via main-process HAL + IPC
|
||||||
|
authorize.ts # allow-list check + role resolution (hashed UID v1)
|
||||||
|
index.ts # availableAccessReaders(): AccessReader[]
|
||||||
|
__tests__/
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type AccessRole = 'user' | 'operator'
|
||||||
|
|
||||||
|
export type CardCredential =
|
||||||
|
| { kind: 'uid'; uidHash: string } // v1
|
||||||
|
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string } // v2 (seam)
|
||||||
|
|
||||||
|
export interface AccessReader {
|
||||||
|
readonly kind: 'mock' | 'nfc-web' | 'nfc-serial'
|
||||||
|
readonly label: string
|
||||||
|
isAvailable(): Promise<boolean>
|
||||||
|
start(opts: {
|
||||||
|
onCard: (cred: CardCredential) => void
|
||||||
|
onError?: (e: unknown) => void
|
||||||
|
}): Promise<StopCapture>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Renderer wiring
|
||||||
|
|
||||||
|
- `apps/machine/src/views/LockedView.vue` (new) — the badge-in screen. Shows brand/logo + "Present your card", a live reader status, and (when `accessControl.devUnlock`) a discreet operator/dev unlock affordance (hidden long-press corner, or a button on the existing debug bar).
|
||||||
|
- `apps/machine/src/App.vue` — add a `locked` render branch mirroring the `PairingWizard` branch (L179) and the `initError` branch (L183): `<LockedView v-else-if="atmStore.isLocked" />`, then the existing `<router-view>` only when unlocked. Keeps rendering state-driven and matches the current shape.
|
||||||
|
- `apps/machine/src/stores/atm.ts` —
|
||||||
|
- `createActor(machine, { input: { accessControlEnabled, accessBypass } })` at the existing `createActor(machine)` site (L452), seeded from `RuntimeConfig`.
|
||||||
|
- `isLocked` computed off the snapshot (peer of `isIdle`, ~L422).
|
||||||
|
- `grantAccess(cred, role)` / `denyAccess(reason)` / `devUnlock()` that `send({ type: 'ACCESS_GRANTED' | 'ACCESS_DENIED' | 'DEV_UNLOCK', … })` (peers of `selectCashIn` at L1382, using the existing `send` at L1378).
|
||||||
|
- On init, when `accessControl.enabled`, subscribe to `availableAccessReaders()[0]`; on `onCard`, run `authorize()` → `grantAccess`/`denyAccess`. When disabled, do nothing (machine `always`-bypasses).
|
||||||
|
|
||||||
|
### 5. Audit
|
||||||
|
|
||||||
|
- Add `recordAccessEvent({ credentialIdHash, role, outcome, at })` alongside the existing state.db handlers (`state:record-transaction` etc. in `electron/main.ts`, exposed via `preload.ts`). v1 writes locally; a later PR can mirror to a replaceable Nostr event.
|
||||||
|
|
||||||
|
### Session semantics (decided)
|
||||||
|
|
||||||
|
**One badge = one transaction-scoped session.** A grant unlocks `idle`, the user runs a single transaction (Buy or Sell), and the machine re-locks on `complete`, on inactivity, or on an explicit "Done". The `session` context field is deliberately shaped as a general access session (`{ role, grantedAt, credentialIdHash }`), not a transaction handle, because this terminal may later handle **non-transaction functions** — so "unlock the terminal" and "authorize a transaction" stay separate concepts.
|
||||||
|
|
||||||
|
**Step-up authorization (future seam, not in PR1).** The badge tap grants *terminal access*; a specific sensitive action can independently *request re-authorization* — e.g. "tap your phone" or "enter a PIN" — without conflating the two. This is why the credential model is a union and the machine carries a `session` rather than a boolean "unlocked": a later `REQUIRE_STEPUP` event can gate an individual action against a fresh credential/PIN while the terminal session stays open. PR1 ships only the entry gate; step-up is a documented extension.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Gate between `idle` and the transaction** (idle visible, tap requires a card). Rejected: the requirement is "gain access to the exchange" — the whole machine should be locked, not just the transact button. A `locked` predecessor matches the mental model and gives a clean re-lock boundary.
|
||||||
|
- **Web NFC only** (reuse `nfc-source.ts` as-is). Rejected: the batm3 reader is serial (`ttyNFC`); Web NFC can't drive it. Web NFC stays a dev-only convenience.
|
||||||
|
- **Fail-open by default** (no card → allow). Rejected for an access-control feature; but note the *disabled* default sidesteps this — access is simply off until an operator turns it on, at which point it fails **closed**.
|
||||||
|
- **OS/kiosk-level lock** (lock the desktop, not the app). Rejected: too coarse, no per-transaction audit, no role model, and it fights the existing state-driven UI.
|
||||||
|
- **UID allow-list as the permanent model.** Rejected as an endpoint (UIDs clone trivially) but accepted as v1 behind a union type, so challenge-response is additive.
|
||||||
|
|
||||||
|
## Security considerations
|
||||||
|
|
||||||
|
- **UID cloning** — card UIDs are not secret and are cloneable; v1 is "better than nothing" and is explicitly labeled upgradeable. The v2 challenge-response path (card signs a machine nonce) is the real security boundary; design the credential union and the `authorize()` seam for it now.
|
||||||
|
- **Data at rest** — store only a salted hash of the credential id; never raw UIDs or any PII (KYC-free). Never log a card secret or nsec (repo security priority #1).
|
||||||
|
- **Fail-closed when enabled** — reader absent/broken + `enabled` ⇒ locked, escape hatch = operator/dev unlock. Reader problems on a *disabled* machine are inert.
|
||||||
|
- **Operator ownership** — authorization derives from the operator key / #42 allow-list, not the SaaS operator (ADR-002 boundary). Local allow-list first; operator-published (NIP-51-style) sync later.
|
||||||
|
- **Dev bypass blast radius** — `VITE_SKIP_ACCESS_GATE` is build-time and never set in a production image; `devUnlock` is gated by `accessControl.devUnlock` (off in a locked-down deployment) and every dev unlock is audited with role `operator`/`dev`.
|
||||||
|
|
||||||
|
## Resolved decisions (2026-07-29)
|
||||||
|
|
||||||
|
1. **Session model** — ✅ one badge = one **transaction-scoped session**, with the `session` context modeled generally (terminal access, not a transaction handle) to allow non-transaction functions and per-action **step-up auth** (tap phone / PIN) later. See *Session semantics* above.
|
||||||
|
2. **v1 credential** — ✅ **UID allow-list first** (hashed), behind a `CardCredential` union so challenge-response is additive (PR5).
|
||||||
|
3. **Allow-list home** — ✅ **local first** (`state.db` / provisioned `access.json`, peer of `branding/`); operator-Nostr sync is a later PR.
|
||||||
|
|
||||||
|
Still open (cosmetic, decide during PR2):
|
||||||
|
|
||||||
|
4. **Dev unlock affordance** — hidden long-press corner vs a labeled button on the existing debug bar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation plan (phased PRs)
|
||||||
|
|
||||||
|
Each PR is independently mergeable. **PR1 changes nothing observable while `accessControl.enabled=false` (the default).**
|
||||||
|
|
||||||
|
### PR1 — Non-breaking skeleton (state + config + mock reader + dev bypass + audit stub)
|
||||||
|
**Goal:** the locked→idle→transaction path is exercisable on the batm3 today, and the flag-off machine is byte-for-byte behavior-identical.
|
||||||
|
- `packages/state-machine`: add `locked` state (`initial`), `ACCESS_GRANTED`/`ACCESS_DENIED`/`DEV_UNLOCK` events, `accessBypass`/`devUnlockAllowed` guards, `startSession`/`recordAccess*` actions, context fields + actor `input`. Re-point `complete`/inactivity re-lock targets to `locked`.
|
||||||
|
- `apps/machine/src/types/electron.d.ts`: `RuntimeConfig.accessControl`.
|
||||||
|
- `apps/machine/electron/main.ts`: `get-config` returns `accessControl` (env-sourced); `.env.example` documents `VITE_SKIP_ACCESS_GATE` + `ACCESS_CONTROL_ENABLED`.
|
||||||
|
- `apps/machine/src/services/access/`: `types.ts`, `mock-reader.ts`, `authorize.ts` (UID allow-list, hashed), `index.ts`.
|
||||||
|
- `apps/machine/src/stores/atm.ts`: actor `input`, `isLocked`, `grantAccess`/`denyAccess`/`devUnlock`, reader subscription (only when enabled).
|
||||||
|
- `apps/machine/src/views/LockedView.vue` + `App.vue` `locked` render branch.
|
||||||
|
- Audit stub: `recordAccessEvent` handler + preload exposure (local write only).
|
||||||
|
- **Tests:** state-machine `locked → idle` on grant; `always`-bypass when disabled; re-lock from `complete`; `authorize()` allow/deny; `devUnlock` gated by config.
|
||||||
|
- **Flag state on merge:** `enabled=false`. CI green = no behavior change.
|
||||||
|
|
||||||
|
### PR2 — Locked UX polish
|
||||||
|
- Reader status/animation, brand-aware LockedView, denied-flash + reason, inactivity copy, operator/dev unlock affordance per open-question #4. Pure renderer.
|
||||||
|
|
||||||
|
### PR3 — Web NFC reader (dev)
|
||||||
|
- `web-nfc-reader.ts` (`NDEFReader`), registered in `availableAccessReaders()` behind availability check. Lets a laptop/phone drive the gate for demos/dev. No hardware dependency.
|
||||||
|
|
||||||
|
### PR4 — Serial NFC HAL driver (real batm3 hardware)
|
||||||
|
- Main-process serial driver for `/dev/ttyNFC` (HAL-style per ADR-001), IPC channel `access:watch-card` + `preload.ts` exposure; `serial-reader.ts` renderer client. Requires the physical reader to validate. Document the reader's protocol/baud in `docs/device-configuration.md`.
|
||||||
|
|
||||||
|
### PR5 — Challenge-response credential + operator allow-list sync
|
||||||
|
- Extend `CardCredential` with the `challenge` variant; `authorize.ts` verifies a signature over a machine nonce against the operator-authorized set; allow-list synced from an operator-published event (#42 mechanism). This is the real security upgrade; v1 UID path stays as a fallback/dev mode.
|
||||||
|
|
||||||
|
### Cross-cutting
|
||||||
|
- **Docs:** update `docs/machine-installation.md` (enabling access control, enrolling cards) and `deploy/nixos/README.md` (the `accessControl` config + `/dev/ttyNFC`) as PR4/PR5 land.
|
||||||
|
- **Provisioning:** a later change can add an `access.json` under `/var/lib/bitspire/` (peer of `branding/`) with the allow-list + `enabled`, plus a `provision-access.sh` mirroring `provision-branding.sh`.
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
description = "Lamassu Next - Nostr-Native Lightning ATM";
|
description = "bitSpire - Nostr-Native Lightning ATM";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
# Stable NixOS for the ATM OS base
|
# Stable NixOS for the ATM OS base
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Pure Nix derivation for the Lamassu ATM Electron app.
|
# Pure Nix derivation for the bitSpire ATM Electron app.
|
||||||
#
|
#
|
||||||
# Uses fetchPnpmDeps + pnpmConfigHook to build entirely inside the Nix sandbox,
|
# Uses fetchPnpmDeps + pnpmConfigHook to build entirely inside the Nix sandbox,
|
||||||
# eliminating the need for --impure or a local pnpm install.
|
# eliminating the need for --impure or a local pnpm install.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo dev",
|
"dev": "turbo dev",
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
|
"build:web": "turbo build:web",
|
||||||
"test": "turbo test",
|
"test": "turbo test",
|
||||||
"lint": "turbo lint",
|
"lint": "turbo lint",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@bitSpire/hal",
|
"name": "@bitSpire/hal",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Hardware Abstraction Layer for Lamassu ATM devices",
|
"description": "Hardware Abstraction Layer for bitSpire ATM devices",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
"src"
|
"src"
|
||||||
],
|
],
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"lamassu",
|
"bitspire",
|
||||||
"atm",
|
"atm",
|
||||||
"hardware",
|
"hardware",
|
||||||
"bill-validator",
|
"bill-validator",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* @bitSpire/hal - Hardware Abstraction Layer
|
* @bitSpire/hal - Hardware Abstraction Layer
|
||||||
*
|
*
|
||||||
* Provides drivers for Lamassu ATM hardware devices:
|
* Provides drivers for bitSpire ATM hardware devices:
|
||||||
* - Bill validators (JCM iVIZION via ID003 protocol)
|
* - Bill validators (JCM iVIZION via ID003 protocol)
|
||||||
* - Bill dispensers (Fujitsu F53/F56)
|
* - Bill dispensers (Fujitsu F53/F56)
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import type {
|
||||||
CreateInvoiceBody,
|
CreateInvoiceBody,
|
||||||
PayInvoiceBody,
|
PayInvoiceBody,
|
||||||
WalletInfo,
|
WalletInfo,
|
||||||
|
CreatedWallet,
|
||||||
MachineConfigResponse,
|
MachineConfigResponse,
|
||||||
SubscribePaymentsBody,
|
SubscribePaymentsBody,
|
||||||
SubscribeAck,
|
SubscribeAck,
|
||||||
|
|
@ -211,6 +212,17 @@ export class LnbitsClient {
|
||||||
return data ?? []
|
return data ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an additional wallet on the calling account (`create_wallet`).
|
||||||
|
*
|
||||||
|
* Account-scoped (AUTH_ACCOUNT): the envelope carries no `wallet_id`, which
|
||||||
|
* is what makes the server resolve auth to the Account rather than a Wallet.
|
||||||
|
* NOT wrapped in `idempotent()` — a retry would mint a duplicate wallet.
|
||||||
|
*/
|
||||||
|
async createWallet(name: string): Promise<CreatedWallet> {
|
||||||
|
return this.sendRpc<CreatedWallet>('create_wallet', { body: { name } })
|
||||||
|
}
|
||||||
|
|
||||||
/** Pull server-delivered machine config (operator pubkey + fee config) over
|
/** Pull server-delivered machine config (operator pubkey + fee config) over
|
||||||
* the authenticated transport — spirekeeper's `get_machine_config` RPC
|
* the authenticated transport — spirekeeper's `get_machine_config` RPC
|
||||||
* (bitspire#70 P1). Lets a seed-only ATM configure itself with no per-machine
|
* (bitspire#70 P1). Lets a seed-only ATM configure itself with no per-machine
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ export type {
|
||||||
CreateInvoiceBody,
|
CreateInvoiceBody,
|
||||||
PayInvoiceBody,
|
PayInvoiceBody,
|
||||||
WalletInfo,
|
WalletInfo,
|
||||||
|
CreatedWallet,
|
||||||
SubscribePaymentsBody,
|
SubscribePaymentsBody,
|
||||||
SubscribeAck,
|
SubscribeAck,
|
||||||
SubscribePush,
|
SubscribePush,
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,15 @@ export interface WalletInfo {
|
||||||
balance: number
|
balance: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reply shape of the `create_wallet` RPC — unlike WalletInfo it carries the
|
||||||
|
* fresh wallet's keys, so never log it verbatim. */
|
||||||
|
export interface CreatedWallet {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
adminkey: string
|
||||||
|
inkey: string
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Subscriptions
|
// Subscriptions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@bitSpire/nostr-client",
|
"name": "@bitSpire/nostr-client",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Nostr client library for Lamassu ATM",
|
"description": "Nostr client library for bitSpire ATM",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Nostr client for Lamassu ATM
|
* Nostr client for bitSpire ATM
|
||||||
*
|
*
|
||||||
* Manages connections to Nostr relays with support for:
|
* Manages connections to Nostr relays with support for:
|
||||||
* - NIP-42 authentication
|
* - NIP-42 authentication
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Event creation utilities for Lamassu ATM
|
* Event creation utilities for bitSpire ATM
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type Event, type EventTemplate, type VerifiedEvent, getEventHash } from 'nostr-tools'
|
import { type Event, type EventTemplate, type VerifiedEvent, getEventHash } from 'nostr-tools'
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* @bitSpire/nostr-client
|
* @bitSpire/nostr-client
|
||||||
*
|
*
|
||||||
* Nostr client library for Lamassu ATM communication.
|
* Nostr client library for bitSpire ATM communication.
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - NIP-42 authentication for private relays
|
* - NIP-42 authentication for private relays
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Nostr client type definitions for Lamassu ATM
|
* Nostr client type definitions for bitSpire ATM
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Event } from 'nostr-tools'
|
import type { Event } from 'nostr-tools'
|
||||||
|
|
|
||||||
123
packages/state-machine/src/__tests__/access-control.test.ts
Normal file
123
packages/state-machine/src/__tests__/access-control.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { createActor } from 'xstate'
|
||||||
|
import { createATMMachine } from '../machine.js'
|
||||||
|
|
||||||
|
// ADR-003 access gate. Key invariant: with the gate DISABLED (the default),
|
||||||
|
// the machine is behaviourally identical to the pre-access machine — it
|
||||||
|
// settles into `idle` on start via the `locked` state's `always` bypass.
|
||||||
|
describe('ATM access control (ADR-003)', () => {
|
||||||
|
describe('gate disabled (default)', () => {
|
||||||
|
it('settles into idle on start (non-breaking)', () => {
|
||||||
|
const actor = createActor(createATMMachine())
|
||||||
|
actor.start()
|
||||||
|
expect(actor.getSnapshot().value).toBe('idle')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('settles into idle even with accessControlEnabled:false explicit', () => {
|
||||||
|
const actor = createActor(createATMMachine({}, { accessControlEnabled: false }))
|
||||||
|
actor.start()
|
||||||
|
expect(actor.getSnapshot().value).toBe('idle')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('gate enabled', () => {
|
||||||
|
it('stays locked on start', () => {
|
||||||
|
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
|
||||||
|
actor.start()
|
||||||
|
expect(actor.getSnapshot().value).toBe('locked')
|
||||||
|
expect(actor.getSnapshot().context.accessSession).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ACCESS_GRANTED unlocks to idle and records the session', () => {
|
||||||
|
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
|
||||||
|
actor.start()
|
||||||
|
actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc123' })
|
||||||
|
|
||||||
|
const snap = actor.getSnapshot()
|
||||||
|
expect(snap.value).toBe('idle')
|
||||||
|
expect(snap.context.accessSession).toMatchObject({ role: 'user', credentialIdHash: 'abc123' })
|
||||||
|
expect(typeof snap.context.accessSession?.grantedAt).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ACCESS_DENIED stays locked and surfaces the reason', () => {
|
||||||
|
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
|
||||||
|
actor.start()
|
||||||
|
actor.send({ type: 'ACCESS_DENIED', reason: 'card not authorized' })
|
||||||
|
|
||||||
|
const snap = actor.getSnapshot()
|
||||||
|
expect(snap.value).toBe('locked')
|
||||||
|
expect(snap.context.accessDenyReason).toBe('card not authorized')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DEV_UNLOCK unlocks to idle (operator session)', () => {
|
||||||
|
const actor = createATMMachine({}, { accessControlEnabled: true })
|
||||||
|
const running = createActor(actor)
|
||||||
|
running.start()
|
||||||
|
running.send({ type: 'DEV_UNLOCK' })
|
||||||
|
|
||||||
|
const snap = running.getSnapshot()
|
||||||
|
expect(snap.value).toBe('idle')
|
||||||
|
expect(snap.context.accessSession?.role).toBe('operator')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// NOTE: idle inactivity re-lock is no longer an XState `after` delay — an
|
||||||
|
// 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.
|
||||||
|
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 }))
|
||||||
|
actor.start()
|
||||||
|
actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc' })
|
||||||
|
expect(actor.getSnapshot().value).toBe('idle')
|
||||||
|
actor.send({ type: 'END_SESSION' })
|
||||||
|
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`.
|
||||||
|
const rateServices = {
|
||||||
|
getExchangeRate: () => Promise.resolve(2500),
|
||||||
|
getAvailableBalance: () => Promise.resolve(1_000_000),
|
||||||
|
}
|
||||||
|
const actor = createActor(createATMMachine(rateServices, { accessControlEnabled: true }))
|
||||||
|
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
|
||||||
|
actor.send({ type: 'END_SESSION' })
|
||||||
|
expect(actor.getSnapshot().value).toBe('locked')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('END_SESSION is a no-op when the gate is disabled (stays at idle)', () => {
|
||||||
|
const actor = createActor(createATMMachine()) // gate off → rests at idle
|
||||||
|
actor.start()
|
||||||
|
expect(actor.getSnapshot().value).toBe('idle')
|
||||||
|
actor.send({ type: 'END_SESSION' })
|
||||||
|
expect(actor.getSnapshot().value).toBe('idle')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('build/dev bypass', () => {
|
||||||
|
it('accessBypassFlag opens the gate even when enabled', () => {
|
||||||
|
const actor = createActor(
|
||||||
|
createATMMachine({}, { accessControlEnabled: true, accessBypassFlag: true })
|
||||||
|
)
|
||||||
|
actor.start()
|
||||||
|
expect(actor.getSnapshot().value).toBe('idle')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DEV_UNLOCK is a no-op while bypassing (already idle)', () => {
|
||||||
|
const actor = createActor(
|
||||||
|
createATMMachine({}, { accessControlEnabled: true, accessBypassFlag: true })
|
||||||
|
)
|
||||||
|
actor.start()
|
||||||
|
// already idle; DEV_UNLOCK guard is false, so no throw / no change
|
||||||
|
actor.send({ type: 'DEV_UNLOCK' })
|
||||||
|
expect(actor.getSnapshot().value).toBe('idle')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -52,6 +52,8 @@ export {
|
||||||
type PaymentMethod,
|
type PaymentMethod,
|
||||||
type DispenseCashResult,
|
type DispenseCashResult,
|
||||||
type CassetteBillResult,
|
type CassetteBillResult,
|
||||||
|
type AccessRole,
|
||||||
|
type AccessSession,
|
||||||
initialContext,
|
initialContext,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,14 @@ export interface ATMMachineOptions {
|
||||||
currency?: string
|
currency?: string
|
||||||
cashInFeeFraction?: number
|
cashInFeeFraction?: number
|
||||||
cashOutFeeFraction?: number
|
cashOutFeeFraction?: number
|
||||||
|
/**
|
||||||
|
* ADR-003 access gate. When true, the machine boots into `locked` and
|
||||||
|
* waits for an ACCESS_GRANTED (or DEV_UNLOCK) before reaching `idle`.
|
||||||
|
* Defaults false → `locked` immediately bypasses to `idle` (no gate).
|
||||||
|
*/
|
||||||
|
accessControlEnabled?: boolean
|
||||||
|
/** Build/dev bypass (VITE_SKIP_ACCESS_GATE) — opens the gate even when enabled. */
|
||||||
|
accessBypassFlag?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createATMMachine(
|
export function createATMMachine(
|
||||||
|
|
@ -167,9 +175,41 @@ export function createATMMachine(
|
||||||
inventory: context.inventory,
|
inventory: context.inventory,
|
||||||
cashInFeeFraction: context.cashInFeeFraction,
|
cashInFeeFraction: context.cashInFeeFraction,
|
||||||
cashOutFeeFraction: context.cashOutFeeFraction,
|
cashOutFeeFraction: context.cashOutFeeFraction,
|
||||||
|
// Preserve the access-gate config across resets — it comes from
|
||||||
|
// machine options, not the transaction, and must survive re-lock.
|
||||||
|
accessControlEnabled: context.accessControlEnabled,
|
||||||
|
accessBypassFlag: context.accessBypassFlag,
|
||||||
|
// Preserve the active access session: `idle`'s entry runs resetContext
|
||||||
|
// AFTER the locked→idle transition action that set the session, so
|
||||||
|
// without this the just-granted session would be wiped. The session is
|
||||||
|
// cleared instead on re-lock (locked's entry), i.e. when access ends.
|
||||||
|
accessSession: context.accessSession,
|
||||||
cashInSessionId: null,
|
cashInSessionId: null,
|
||||||
dispenseResult: null,
|
dispenseResult: null,
|
||||||
})),
|
})),
|
||||||
|
// ADR-003 access-control actions
|
||||||
|
startAccessSession: assign({
|
||||||
|
accessSession: ({ event }) => {
|
||||||
|
if (event.type !== 'ACCESS_GRANTED') return null
|
||||||
|
return { role: event.role, grantedAt: Date.now(), credentialIdHash: event.credentialIdHash }
|
||||||
|
},
|
||||||
|
accessDenyReason: null,
|
||||||
|
}),
|
||||||
|
startDevAccessSession: assign({
|
||||||
|
accessSession: () => ({
|
||||||
|
role: 'operator' as const,
|
||||||
|
grantedAt: Date.now(),
|
||||||
|
credentialIdHash: 'dev-unlock',
|
||||||
|
}),
|
||||||
|
accessDenyReason: null,
|
||||||
|
}),
|
||||||
|
setAccessDenyReason: assign({
|
||||||
|
accessDenyReason: ({ event }) => (event.type === 'ACCESS_DENIED' ? event.reason : null),
|
||||||
|
}),
|
||||||
|
clearAccessDenyReason: assign({ accessDenyReason: null }),
|
||||||
|
// On (re-)entering `locked`, access has ended: drop any prior session so a
|
||||||
|
// stale grant can't leak across the gate.
|
||||||
|
clearAccessSession: assign({ accessSession: null }),
|
||||||
setStartTime: assign({
|
setStartTime: assign({
|
||||||
startedAt: () => Date.now(),
|
startedAt: () => Date.now(),
|
||||||
txid: () => generateTxId(),
|
txid: () => generateTxId(),
|
||||||
|
|
@ -376,6 +416,18 @@ export function createATMMachine(
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
guards: {
|
guards: {
|
||||||
|
// ADR-003: gate is open when access control is off, or the build/dev
|
||||||
|
// bypass is set. Used by `locked`'s eventless `always` transition so a
|
||||||
|
// machine with the gate disabled settles straight into `idle`.
|
||||||
|
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,
|
hasInsertedBills: ({ context }) => context.billsInserted.length > 0,
|
||||||
// Legacy brain.js parity: "send coins" is a no-op while a bill is
|
// Legacy brain.js parity: "send coins" is a no-op while a bill is
|
||||||
// between the stack command and the validator's stacked-confirmation.
|
// between the stack command and the validator's stacked-confirmation.
|
||||||
|
|
@ -426,10 +478,16 @@ export function createATMMachine(
|
||||||
COMPLETE_DELAY: 60000,
|
COMPLETE_DELAY: 60000,
|
||||||
DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond
|
DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond
|
||||||
DISPENSE_ERROR_TIMEOUT: 30000, // 30s like brain.js _timedState
|
DISPENSE_ERROR_TIMEOUT: 30000, // 30s like brain.js _timedState
|
||||||
|
// NOTE: idle inactivity re-lock + hard session cap are enforced at the
|
||||||
|
// DOM layer (useSessionSecurity), not as XState `after` delays — see the
|
||||||
|
// idle state comment. No IDLE_LOCK_TIMEOUT delay here by design.
|
||||||
},
|
},
|
||||||
}).createMachine({
|
}).createMachine({
|
||||||
id: 'atm',
|
id: 'atm',
|
||||||
initial: 'idle',
|
// ADR-003: `locked` is the resting state. With the gate disabled (the
|
||||||
|
// default), its `always` transition bypasses straight to `idle` on start,
|
||||||
|
// so behavior is identical to the pre-access machine.
|
||||||
|
initial: 'locked',
|
||||||
context: {
|
context: {
|
||||||
...initialContext,
|
...initialContext,
|
||||||
...(options?.currency ? { currency: options.currency } : {}),
|
...(options?.currency ? { currency: options.currency } : {}),
|
||||||
|
|
@ -437,6 +495,12 @@ export function createATMMachine(
|
||||||
...(options?.cashOutFeeFraction !== undefined
|
...(options?.cashOutFeeFraction !== undefined
|
||||||
? { cashOutFeeFraction: options.cashOutFeeFraction }
|
? { cashOutFeeFraction: options.cashOutFeeFraction }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(options?.accessControlEnabled !== undefined
|
||||||
|
? { accessControlEnabled: options.accessControlEnabled }
|
||||||
|
: {}),
|
||||||
|
...(options?.accessBypassFlag !== undefined
|
||||||
|
? { accessBypassFlag: options.accessBypassFlag }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
// Root-level handler: lets the operator-fees subscriber update the
|
// Root-level handler: lets the operator-fees subscriber update the
|
||||||
// active fee fractions reactively. Next cashIn/cashOut entry will
|
// active fee fractions reactively. Next cashIn/cashOut entry will
|
||||||
|
|
@ -449,10 +513,41 @@ 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) ===
|
||||||
|
// Resting/locked state. A paired, healthy machine sits here until a
|
||||||
|
// valid credential is presented. When the gate is disabled (default)
|
||||||
|
// the eventless `always` transition immediately hands off to `idle`,
|
||||||
|
// so a non-access machine never dwells here.
|
||||||
|
locked: {
|
||||||
|
entry: ['clearAccessDenyReason', 'clearAccessSession'],
|
||||||
|
always: [{ guard: 'accessBypass', target: 'idle' }],
|
||||||
|
on: {
|
||||||
|
ACCESS_GRANTED: { target: 'idle', actions: 'startAccessSession' },
|
||||||
|
DEV_UNLOCK: { guard: 'devUnlockAllowed', target: 'idle', actions: 'startDevAccessSession' },
|
||||||
|
// A denied tap keeps us locked; record the reason for the screen.
|
||||||
|
ACCESS_DENIED: { actions: 'setAccessDenyReason' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
idle: {
|
idle: {
|
||||||
entry: 'resetContext',
|
entry: 'resetContext',
|
||||||
|
// Idle inactivity re-lock is NOT modeled here as an XState `after`:
|
||||||
|
// that timer is anchored to state ENTRY and never resets on screen
|
||||||
|
// 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.
|
||||||
on: {
|
on: {
|
||||||
SELECT_CASH_IN: {
|
SELECT_CASH_IN: {
|
||||||
target: 'cashIn',
|
target: 'cashIn',
|
||||||
|
|
@ -491,7 +586,7 @@ export function createATMMachine(
|
||||||
INACTIVITY_TIMEOUT: [
|
INACTIVITY_TIMEOUT: [
|
||||||
{
|
{
|
||||||
guard: ({ context }) => context.billsInserted.length === 0,
|
guard: ({ context }) => context.billsInserted.length === 0,
|
||||||
target: '#atm.idle',
|
target: '#atm.locked',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: 'confirmAbandon',
|
target: 'confirmAbandon',
|
||||||
|
|
@ -524,7 +619,7 @@ export function createATMMachine(
|
||||||
{
|
{
|
||||||
// No bills inserted yet: safe to cancel
|
// No bills inserted yet: safe to cancel
|
||||||
guard: ({ context }) => context.billsInserted.length === 0,
|
guard: ({ context }) => context.billsInserted.length === 0,
|
||||||
target: '#atm.idle',
|
target: '#atm.locked',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Bills already stacked: warn user before abandoning
|
// Bills already stacked: warn user before abandoning
|
||||||
|
|
@ -534,7 +629,7 @@ export function createATMMachine(
|
||||||
TIMEOUT: [
|
TIMEOUT: [
|
||||||
{
|
{
|
||||||
guard: ({ context }) => context.billsInserted.length === 0,
|
guard: ({ context }) => context.billsInserted.length === 0,
|
||||||
target: '#atm.idle',
|
target: '#atm.locked',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
target: 'confirmAbandon',
|
target: 'confirmAbandon',
|
||||||
|
|
@ -586,10 +681,10 @@ export function createATMMachine(
|
||||||
// like the rest — clear the marker so nothing blocks on it.
|
// like the rest — clear the marker so nothing blocks on it.
|
||||||
entry: 'clearBillPending',
|
entry: 'clearBillPending',
|
||||||
after: {
|
after: {
|
||||||
60000: '#atm.idle',
|
60000: '#atm.locked',
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
CANCEL: '#atm.idle', // User confirms they want to leave
|
CANCEL: '#atm.locked', // User confirms they want to leave
|
||||||
RETRY: 'generatingNdebit', // Go back and try again
|
RETRY: 'generatingNdebit', // Go back and try again
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -614,10 +709,10 @@ export function createATMMachine(
|
||||||
},
|
},
|
||||||
complete: {
|
complete: {
|
||||||
after: {
|
after: {
|
||||||
COMPLETE_DELAY: '#atm.idle',
|
COMPLETE_DELAY: '#atm.locked',
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
CANCEL: '#atm.idle',
|
CANCEL: '#atm.locked',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
|
|
@ -646,7 +741,7 @@ export function createATMMachine(
|
||||||
{
|
{
|
||||||
// No bills inserted: safe to cancel
|
// No bills inserted: safe to cancel
|
||||||
guard: ({ context }) => context.billsInserted.length === 0,
|
guard: ({ context }) => context.billsInserted.length === 0,
|
||||||
target: '#atm.idle',
|
target: '#atm.locked',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Bills inserted: show abandon warning first
|
// Bills inserted: show abandon warning first
|
||||||
|
|
@ -689,7 +784,7 @@ export function createATMMachine(
|
||||||
// User selects denomination buttons to build up the cash amount
|
// User selects denomination buttons to build up the cash amount
|
||||||
// UI shows: available denominations, running total, sats equivalent
|
// UI shows: available denominations, running total, sats equivalent
|
||||||
after: {
|
after: {
|
||||||
INACTIVITY_TIMEOUT: '#atm.idle',
|
INACTIVITY_TIMEOUT: '#atm.locked',
|
||||||
},
|
},
|
||||||
entry: 'clearCashOutSelection',
|
entry: 'clearCashOutSelection',
|
||||||
on: {
|
on: {
|
||||||
|
|
@ -709,8 +804,8 @@ export function createATMMachine(
|
||||||
target: 'generatingInvoice',
|
target: 'generatingInvoice',
|
||||||
actions: 'calculateDispenseFromSelection',
|
actions: 'calculateDispenseFromSelection',
|
||||||
},
|
},
|
||||||
CANCEL: '#atm.idle',
|
CANCEL: '#atm.locked',
|
||||||
TIMEOUT: '#atm.idle',
|
TIMEOUT: '#atm.locked',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
generatingInvoice: {
|
generatingInvoice: {
|
||||||
|
|
@ -758,7 +853,7 @@ export function createATMMachine(
|
||||||
TIMEOUT: {
|
TIMEOUT: {
|
||||||
target: 'selectingAmount',
|
target: 'selectingAmount',
|
||||||
},
|
},
|
||||||
CANCEL: '#atm.idle',
|
CANCEL: '#atm.locked',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dispensingCash: {
|
dispensingCash: {
|
||||||
|
|
@ -826,20 +921,20 @@ export function createATMMachine(
|
||||||
},
|
},
|
||||||
complete: {
|
complete: {
|
||||||
after: {
|
after: {
|
||||||
COMPLETE_DELAY: '#atm.idle',
|
COMPLETE_DELAY: '#atm.locked',
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
CANCEL: '#atm.idle',
|
CANCEL: '#atm.locked',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dispenseError: {
|
dispenseError: {
|
||||||
// Payment received but cash not (fully) dispensed.
|
// Payment received but cash not (fully) dispensed.
|
||||||
// Show error + txid for 30s, then auto-idle (matches brain.js _timedState).
|
// Show error + txid for 30s, then auto-idle (matches brain.js _timedState).
|
||||||
after: {
|
after: {
|
||||||
DISPENSE_ERROR_TIMEOUT: '#atm.idle',
|
DISPENSE_ERROR_TIMEOUT: '#atm.locked',
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
CANCEL: '#atm.idle',
|
CANCEL: '#atm.locked',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
|
|
@ -849,7 +944,7 @@ export function createATMMachine(
|
||||||
target: 'fetchingRate',
|
target: 'fetchingRate',
|
||||||
actions: 'incrementRetry',
|
actions: 'incrementRetry',
|
||||||
},
|
},
|
||||||
CANCEL: '#atm.idle',
|
CANCEL: '#atm.locked',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,47 @@ export interface OfferRequestEvent {
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access-control role resolved from a presented credential.
|
||||||
|
* `user` may transact; `operator` may additionally reach operator
|
||||||
|
* functions (config/maintenance/enrollment) — reserved for later PRs.
|
||||||
|
* See ADR-003.
|
||||||
|
*/
|
||||||
|
export type AccessRole = 'user' | 'operator'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An active access session, created when a valid credential is presented
|
||||||
|
* (or via the dev unlock). Modeled as general *terminal access*, not a
|
||||||
|
* transaction handle, so the terminal can later gate non-transaction
|
||||||
|
* functions and per-action step-up auth (ADR-003).
|
||||||
|
*/
|
||||||
|
export interface AccessSession {
|
||||||
|
role: AccessRole
|
||||||
|
/** ms epoch when access was granted */
|
||||||
|
grantedAt: number
|
||||||
|
/** salted hash of the presented credential id — never the raw UID (KYC-free) */
|
||||||
|
credentialIdHash: string
|
||||||
|
}
|
||||||
|
|
||||||
/** ATM machine context */
|
/** ATM machine context */
|
||||||
export interface ATMContext {
|
export interface ATMContext {
|
||||||
|
// Access control (ADR-003)
|
||||||
|
/**
|
||||||
|
* Whether the badge-to-enter access gate is active. When false (the
|
||||||
|
* default), the machine's `locked` initial state immediately bypasses
|
||||||
|
* to `idle` — behavior is identical to a machine with no access layer.
|
||||||
|
*/
|
||||||
|
accessControlEnabled: boolean
|
||||||
|
/**
|
||||||
|
* Build/dev bypass (VITE_SKIP_ACCESS_GATE). Forces the gate open even
|
||||||
|
* when accessControlEnabled is true — for browser dev / CI.
|
||||||
|
*/
|
||||||
|
accessBypassFlag: boolean
|
||||||
|
/** Active access session, or null while locked. */
|
||||||
|
accessSession: AccessSession | null
|
||||||
|
/** Reason for the last denied access attempt (for the locked screen). */
|
||||||
|
accessDenyReason: string | null
|
||||||
|
|
||||||
// Transaction details
|
// Transaction details
|
||||||
/** Fiat amount in cents */
|
/** Fiat amount in cents */
|
||||||
fiatCents: number
|
fiatCents: number
|
||||||
|
|
@ -136,6 +175,14 @@ export type ATMEvent =
|
||||||
| { type: 'SELECT_CASH_IN' }
|
| { type: 'SELECT_CASH_IN' }
|
||||||
| { type: 'SELECT_CASH_OUT' }
|
| { type: 'SELECT_CASH_OUT' }
|
||||||
| { type: 'CANCEL' }
|
| { type: 'CANCEL' }
|
||||||
|
// Access control (ADR-003)
|
||||||
|
| { type: 'ACCESS_GRANTED'; role: AccessRole; credentialIdHash: string }
|
||||||
|
| { type: 'ACCESS_DENIED'; reason: string }
|
||||||
|
| { type: 'DEV_UNLOCK' }
|
||||||
|
// User-initiated end of a tap-in session: re-lock immediately instead of
|
||||||
|
// waiting out IDLE_LOCK_TIMEOUT, so a loaded Bolt Card can't be reused by
|
||||||
|
// the next person the moment its holder steps away.
|
||||||
|
| { type: 'END_SESSION' }
|
||||||
| { type: 'SELECT_AMOUNT'; amount: number }
|
| { type: 'SELECT_AMOUNT'; amount: number }
|
||||||
| { type: 'FINISH_INSERTING' }
|
| { type: 'FINISH_INSERTING' }
|
||||||
| { type: 'USER_SCANNED_NPUB'; npub: string }
|
| { type: 'USER_SCANNED_NPUB'; npub: string }
|
||||||
|
|
@ -173,6 +220,12 @@ export type ATMEvent =
|
||||||
|
|
||||||
/** Initial context values */
|
/** Initial context values */
|
||||||
export const initialContext: ATMContext = {
|
export const initialContext: ATMContext = {
|
||||||
|
// Access control defaults OFF — a machine built without the access
|
||||||
|
// options behaves exactly as before (locked → bypass → idle). See ADR-003.
|
||||||
|
accessControlEnabled: false,
|
||||||
|
accessBypassFlag: false,
|
||||||
|
accessSession: null,
|
||||||
|
accessDenyReason: null,
|
||||||
fiatCents: 0,
|
fiatCents: 0,
|
||||||
satsAmount: 0,
|
satsAmount: 0,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@bitSpire/ui-shared",
|
"name": "@bitSpire/ui-shared",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Shared Vue 3 components for Lamassu ATM and dashboard",
|
"description": "Shared Vue 3 components for bitSpire ATM and dashboard",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* @bitSpire/ui-shared
|
* @bitSpire/ui-shared
|
||||||
*
|
*
|
||||||
* Shared Vue 3 components for Lamassu ATM and dashboard.
|
* Shared Vue 3 components for bitSpire ATM and dashboard.
|
||||||
* This package will contain common UI components like:
|
* This package will contain common UI components like:
|
||||||
* - QR code display
|
* - QR code display
|
||||||
* - Number pad
|
* - Number pad
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
|
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
|
||||||
},
|
},
|
||||||
|
"build:web": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**"],
|
||||||
|
"env": ["VITE_*"]
|
||||||
|
},
|
||||||
"dev": {
|
"dev": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": true
|
"persistent": true
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue