From f37555565eafbfe1ba536e546f90e028753771b3 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Thu, 19 Feb 2026 11:45:09 -0500 Subject: [PATCH] feat(machine): add SQLite persistence and remove npub linking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add crash-safe persistence for cassette inventory, cashbox state, and transaction history using better-sqlite3 in the Electron main process. The state machine now loads inventory from the database at runtime instead of using hardcoded values, and transactions are automatically persisted on completion. Remove the unnecessary npub linking code — Lightning.Pub auto-creates and associates Nostr users when appId is included in RPC requests, making the HTTP-based user creation and token linking redundant. Co-Authored-By: Claude Opus 4.6 --- apps/machine/electron/main.ts | 22 +- apps/machine/electron/preload.ts | 32 ++- apps/machine/electron/state-store.ts | 271 ++++++++++++++++++ apps/machine/electron/tsconfig.json | 2 +- apps/machine/package.json | 2 + .../composables/useAvailabilityBroadcast.ts | 101 +++++++ apps/machine/src/services/lightning.ts | 132 --------- apps/machine/src/stores/atm.ts | 109 ++++++- apps/machine/src/types/electron.d.ts | 18 +- apps/machine/src/types/state.ts | 27 ++ deploy/nixos/provision-atm.sh | 2 - docker/dev.sh | 27 +- packages/state-machine/src/machine.ts | 23 +- packages/state-machine/src/types.ts | 4 +- pnpm-lock.yaml | 138 +++++++++ 15 files changed, 739 insertions(+), 171 deletions(-) create mode 100644 apps/machine/electron/state-store.ts create mode 100644 apps/machine/src/composables/useAvailabilityBroadcast.ts create mode 100644 apps/machine/src/types/state.ts diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 5e9f76f..3856f3f 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -10,6 +10,16 @@ import { app, BrowserWindow, ipcMain } from 'electron' import path from 'node:path' import fs from 'node:fs' import { fileURLToPath } from 'node:url' +import { + initDatabase, + closeDatabase, + loadCassettes, + setCassettes, + getInventory, + recordTransaction, + emptyCashbox, + getCashbox, +} from './state-store.js' // ESM equivalent of __dirname const __filename = fileURLToPath(import.meta.url) @@ -100,8 +110,6 @@ ipcMain.handle('get-config', () => { atmPrivateKey: process.env.VITE_ATM_PRIVATE_KEY || '', adminToken: process.env.VITE_ADMIN_TOKEN || '', appId: process.env.VITE_APP_ID || '', - appToken: process.env.VITE_APP_TOKEN || '', - linkingToken: process.env.VITE_LINKING_TOKEN || '', // Hardware configuration machineModel: process.env.VITE_LAMASSU_MACHINE_MODEL || 'sintra', @@ -112,8 +120,17 @@ ipcMain.handle('get-config', () => { } }) +// State persistence IPC handlers +ipcMain.handle('state:load-cassettes', () => loadCassettes()) +ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes)) +ipcMain.handle('state:get-inventory', () => getInventory()) +ipcMain.handle('state:get-cashbox', () => getCashbox()) +ipcMain.handle('state:record-transaction', (_event, tx) => recordTransaction(tx)) +ipcMain.handle('state:empty-cashbox', () => emptyCashbox()) + // App lifecycle app.whenReady().then(() => { + initDatabase() createWindow() app.on('activate', () => { @@ -125,6 +142,7 @@ app.whenReady().then(() => { }) app.on('window-all-closed', () => { + closeDatabase() // Quit on all platforms (ATM doesn't need macOS dock behavior) app.quit() }) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index f9622f0..eacbe06 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -19,7 +19,6 @@ export interface RuntimeConfig { atmPrivateKey: string adminToken: string appId: string - appToken: string machineModel: string fiatCode: string validatorDevice?: string @@ -35,6 +34,21 @@ contextBridge.exposeInMainWorld('electronAPI', { // Get runtime configuration (read from environment at runtime) getConfig: (): Promise => ipcRenderer.invoke('get-config'), + // State persistence + loadCassettes: () => ipcRenderer.invoke('state:load-cassettes'), + setCassettes: (cassettes: { denomination: number; count: number }[]) => + ipcRenderer.invoke('state:set-cassettes', cassettes), + getInventory: () => ipcRenderer.invoke('state:get-inventory'), + getCashbox: () => ipcRenderer.invoke('state:get-cashbox'), + recordTransaction: (tx: { + txid: string + type: 'cash_in' | 'cash_out' + fiatCents: number + sats: number + bills: { denomination: number; count: number }[] + }) => ipcRenderer.invoke('state:record-transaction', tx), + emptyCashbox: () => ipcRenderer.invoke('state:empty-cashbox'), + // Platform info platform: process.platform, }) @@ -45,6 +59,22 @@ declare global { electronAPI: { getVersion: () => Promise getConfig: () => Promise + loadCassettes: () => Promise<{ denomination: number; count: number }[]> + setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise + getInventory: () => Promise> + getCashbox: () => Promise<{ + totalBills: number + totalFiatCents: number + lastEmptiedAt: number | null + }> + recordTransaction: (tx: { + txid: string + type: 'cash_in' | 'cash_out' + fiatCents: number + sats: number + bills: { denomination: number; count: number }[] + }) => Promise + emptyCashbox: () => Promise platform: NodeJS.Platform } } diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts new file mode 100644 index 0000000..fc0ef98 --- /dev/null +++ b/apps/machine/electron/state-store.ts @@ -0,0 +1,271 @@ +/** + * SQLite State Store for ATM Cash Tracking + * + * Persists cassette inventory, cashbox state, and transaction history + * across reboots. Runs in the Electron main process using better-sqlite3 + * (synchronous API, safe for single-process use). + * + * Database location: /var/lib/lamassu-atm/state.db (production) + * ./state.db (development) + */ + +import Database from 'better-sqlite3' +import path from 'node:path' +import fs from 'node:fs' + +let db: Database.Database | null = null + +const SCHEMA_VERSION = '1' + +function getDbPath(): string { + const prodDir = '/var/lib/lamassu-atm' + if (fs.existsSync(prodDir)) { + return path.join(prodDir, 'state.db') + } + // Development fallback: next to the app + return path.join(process.cwd(), 'state.db') +} + +/** + * Initialize the database, creating tables if they don't exist. + * Must be called once at app startup before any other functions. + */ +export function initDatabase(dbPath?: string): void { + const resolvedPath = dbPath ?? getDbPath() + + // Ensure parent directory exists + const dir = path.dirname(resolvedPath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + db = new Database(resolvedPath) + + // Enable WAL mode for better concurrent read performance + db.pragma('journal_mode = WAL') + + // Create tables + db.exec(` + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS cassettes ( + denomination INTEGER PRIMARY KEY, + count INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS cashbox ( + id INTEGER PRIMARY KEY CHECK (id = 1), + total_bills INTEGER NOT NULL DEFAULT 0, + total_fiat_cents INTEGER NOT NULL DEFAULT 0, + last_emptied_at INTEGER + ); + + CREATE TABLE IF NOT EXISTS transactions ( + txid TEXT PRIMARY KEY, + type TEXT NOT NULL CHECK (type IN ('cash_in', 'cash_out')), + fiat_cents INTEGER NOT NULL, + sats INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS transaction_bills ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + txid TEXT NOT NULL REFERENCES transactions(txid), + denomination INTEGER NOT NULL, + count INTEGER NOT NULL + ); + `) + + // Seed meta + cashbox if first run + const existing = db.prepare('SELECT value FROM meta WHERE key = ?').get('schema_version') as + | { value: string } + | undefined + if (!existing) { + db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('schema_version', SCHEMA_VERSION) + } + + const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get() + if (!cashboxRow) { + db.prepare('INSERT INTO cashbox (id) VALUES (1)').run() + } + + console.log('[StateStore] Initialized database at', resolvedPath) +} + +// --------------------------------------------------------------------------- +// Cassettes +// --------------------------------------------------------------------------- + +export interface CassetteRow { + denomination: number + count: number +} + +export function loadCassettes(): CassetteRow[] { + if (!db) throw new Error('Database not initialized') + return db + .prepare('SELECT denomination, count FROM cassettes ORDER BY denomination') + .all() as CassetteRow[] +} + +/** + * Upsert cassette counts. Replaces all existing rows. + */ +export function setCassettes(cassettes: CassetteRow[]): void { + if (!db) throw new Error('Database not initialized') + + const upsert = db.prepare( + 'INSERT INTO cassettes (denomination, count) VALUES (?, ?) ON CONFLICT(denomination) DO UPDATE SET count = excluded.count' + ) + + const run = db.transaction((rows: CassetteRow[]) => { + for (const row of rows) { + upsert.run(row.denomination, row.count) + } + }) + + run(cassettes) + console.log('[StateStore] Cassettes updated:', cassettes) +} + +/** + * Increment or decrement a cassette count (e.g., after dispensing). + */ +export function updateCassetteCount(denomination: number, delta: number): void { + if (!db) throw new Error('Database not initialized') + + db.prepare('UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?').run( + delta, + denomination + ) +} + +/** + * Get inventory as a denomination -> count map. + */ +export function getInventory(): Record { + const rows = loadCassettes() + const inv: Record = {} + for (const row of rows) { + if (row.count > 0) { + inv[row.denomination] = row.count + } + } + return inv +} + +// --------------------------------------------------------------------------- +// Cashbox +// --------------------------------------------------------------------------- + +export interface CashboxRow { + totalBills: number + totalFiatCents: number + lastEmptiedAt: number | null +} + +export function getCashbox(): CashboxRow { + if (!db) throw new Error('Database not initialized') + + const row = db + .prepare('SELECT total_bills, total_fiat_cents, last_emptied_at FROM cashbox WHERE id = 1') + .get() as { total_bills: number; total_fiat_cents: number; last_emptied_at: number | null } + + return { + totalBills: row.total_bills, + totalFiatCents: row.total_fiat_cents, + lastEmptiedAt: row.last_emptied_at, + } +} + +/** + * Increment cashbox totals after a cash-in transaction. + */ +export function addToCashbox(bills: number, fiatCents: number): void { + if (!db) throw new Error('Database not initialized') + + db.prepare( + 'UPDATE cashbox SET total_bills = total_bills + ?, total_fiat_cents = total_fiat_cents + ? WHERE id = 1' + ).run(bills, fiatCents) +} + +/** + * Reset the cashbox (operator emptied it). + */ +export function emptyCashbox(): void { + if (!db) throw new Error('Database not initialized') + + db.prepare( + 'UPDATE cashbox SET total_bills = 0, total_fiat_cents = 0, last_emptied_at = ? WHERE id = 1' + ).run(Date.now()) + + console.log('[StateStore] Cashbox emptied') +} + +// --------------------------------------------------------------------------- +// Transactions +// --------------------------------------------------------------------------- + +interface TransactionInput { + txid: string + type: 'cash_in' | 'cash_out' + fiatCents: number + sats: number + bills: { denomination: number; count: number }[] +} + +/** + * Record a completed transaction and update cassette/cashbox state atomically. + */ +export function recordTransaction(tx: TransactionInput): void { + if (!db) throw new Error('Database not initialized') + + const insertTx = db.prepare( + 'INSERT INTO transactions (txid, type, fiat_cents, sats, created_at) VALUES (?, ?, ?, ?, ?)' + ) + const insertBill = db.prepare( + 'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)' + ) + const updateCassette = db.prepare( + 'UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?' + ) + const updateCashboxStmt = db.prepare( + 'UPDATE cashbox SET total_bills = total_bills + ?, total_fiat_cents = total_fiat_cents + ? WHERE id = 1' + ) + + const run = db.transaction((t: TransactionInput) => { + insertTx.run(t.txid, t.type, t.fiatCents, t.sats, Date.now()) + + for (const bill of t.bills) { + insertBill.run(t.txid, bill.denomination, bill.count) + + if (t.type === 'cash_out') { + // Dispensed bills: decrement cassettes + updateCassette.run(-bill.count, bill.denomination) + } + } + + if (t.type === 'cash_in') { + // Bills inserted by customer go into cashbox + const totalBills = t.bills.reduce((sum, b) => sum + b.count, 0) + updateCashboxStmt.run(totalBills, t.fiatCents) + } + }) + + run(tx) + console.log('[StateStore] Recorded transaction:', tx.txid, tx.type) +} + +/** + * Close the database (for clean shutdown). + */ +export function closeDatabase(): void { + if (db) { + db.close() + db = null + console.log('[StateStore] Database closed') + } +} diff --git a/apps/machine/electron/tsconfig.json b/apps/machine/electron/tsconfig.json index a89ff1f..79de887 100644 --- a/apps/machine/electron/tsconfig.json +++ b/apps/machine/electron/tsconfig.json @@ -9,6 +9,6 @@ "outDir": "../dist-electron", "rootDir": "." }, - "include": ["main.ts"], + "include": ["main.ts", "state-store.ts"], "exclude": ["preload.ts"] } diff --git a/apps/machine/package.json b/apps/machine/package.json index 0990fc9..bb95d2d 100644 --- a/apps/machine/package.json +++ b/apps/machine/package.json @@ -27,6 +27,7 @@ "@lamassu/nostr-client": "workspace:*", "@lamassu/state-machine": "workspace:*", "@scure/base": "^1.2.0", + "better-sqlite3": "^11.0.0", "@vueuse/core": "^14.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -41,6 +42,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@types/better-sqlite3": "^7.0.0", "@types/node": "^22.0.0", "@vitejs/plugin-vue": "^5.2.0", "concurrently": "^9.0.0", diff --git a/apps/machine/src/composables/useAvailabilityBroadcast.ts b/apps/machine/src/composables/useAvailabilityBroadcast.ts new file mode 100644 index 0000000..1cc5e55 --- /dev/null +++ b/apps/machine/src/composables/useAvailabilityBroadcast.ts @@ -0,0 +1,101 @@ +/** + * Availability Broadcast Composable + * + * Watches ATM inventory and Lightning.Pub balance, then publishes a public + * replaceable event (kind 30078) indicating whether the machine can accept + * cash-in and cash-out transactions. + * + * Event format: + * kind: 30078 + * tags: [["d", "atm-availability"]] + * content: {"cash_in":true,"cash_out":true,"fiat":"USD","model":"sintra"} + * + * No exact amounts are revealed — just boolean per direction. + */ + +import { watch, type Ref } from 'vue' +import { useDebounceFn } from '@vueuse/core' +import type { NostrClient, MachineIdentity } from '@lamassu/nostr-client' +import { createSignedEvent } from '@lamassu/nostr-client' +import type { ATMAvailability } from '@/types/state' + +interface UseAvailabilityBroadcastOptions { + nostrClient: NostrClient + identity: MachineIdentity + /** Reactive inventory: denomination -> count */ + inventory: Ref> + /** Reactive Lightning.Pub balance in sats (null = unknown) */ + balanceSats: Ref + /** Fiat currency code */ + fiatCode: string + /** Machine model */ + model: string +} + +export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) { + const { nostrClient, identity, inventory, balanceSats, fiatCode, model } = options + + let lastPublished: ATMAvailability | null = null + + function computeAvailability(): ATMAvailability { + // cash_out: machine has at least one bill in any cassette + const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0) + const cashOut = totalBills > 0 + + // cash_in: Lightning.Pub has sats balance to sell + const cashIn = (balanceSats.value ?? 0) > 0 + + return { cashIn, cashOut } + } + + async function publish(avail: ATMAvailability) { + const content = JSON.stringify({ + cash_in: avail.cashIn, + cash_out: avail.cashOut, + fiat: fiatCode, + model, + }) + + const event = createSignedEvent(identity, { + kind: 30078, + created_at: Math.floor(Date.now() / 1000), + tags: [['d', 'atm-availability']], + content, + }) + + try { + await nostrClient.publish(event) + lastPublished = avail + console.log('[Availability] Published:', content) + } catch (e) { + console.warn('[Availability] Failed to publish:', e) + } + } + + const debouncedPublish = useDebounceFn(() => { + const avail = computeAvailability() + // Only publish if availability actually changed + if ( + !lastPublished || + avail.cashIn !== lastPublished.cashIn || + avail.cashOut !== lastPublished.cashOut + ) { + publish(avail) + } + }, 5000) // 5s debounce to avoid spamming + + // Watch reactive sources + watch( + [inventory, balanceSats], + () => { + debouncedPublish() + }, + { deep: true } + ) + + // Publish immediately on setup + const initial = computeAvailability() + publish(initial) + + return { computeAvailability } +} diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 68da002..fddd603 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -61,8 +61,6 @@ interface LightningConfig { adminToken: string atmPrivateKey: string appId: string - appToken: string // JWT token for app API calls (user management, linking) - linkingToken: string } /** @@ -78,8 +76,6 @@ async function loadLightningConfig(): Promise { adminToken: 'lamassu-dev-admin-token', atmPrivateKey: '', appId: '152fd75c134226824e5183cd9c02a35b4972e995f39e7c0e4ec215ae8c1fae1d', // ATM app ID - appToken: '', // JWT token from app creation - linkingToken: '', // Token to link Nostr pubkey to app user balance } // In Electron, get runtime config from main process @@ -94,8 +90,6 @@ async function loadLightningConfig(): Promise { adminToken: runtimeConfig.adminToken || defaults.adminToken, atmPrivateKey: runtimeConfig.atmPrivateKey || defaults.atmPrivateKey, appId: runtimeConfig.appId || defaults.appId, - appToken: runtimeConfig.appToken || defaults.appToken, - linkingToken: runtimeConfig.linkingToken || defaults.linkingToken, } } catch (e) { console.warn('[Lightning] Failed to get runtime config from Electron:', e) @@ -111,122 +105,12 @@ async function loadLightningConfig(): Promise { adminToken: import.meta.env.VITE_ADMIN_TOKEN || defaults.adminToken, atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey, appId: import.meta.env.VITE_APP_ID || defaults.appId, - appToken: import.meta.env.VITE_APP_TOKEN || defaults.appToken, - linkingToken: import.meta.env.VITE_LINKING_TOKEN || defaults.linkingToken, } } // Config is loaded async now - will be set in initializeLightningServices let CONFIG: LightningConfig -// ATM user identifier - consistent across restarts -const ATM_USER_IDENTIFIER = 'atm-primary-user' - -/** - * Link Nostr pubkey to App User - * - * This is critical for LNURL-withdraw to work correctly. The Extension API - * uses App Users (created via HTTP API), but Nostr RPC creates separate - * Nostr Users. By linking our npub to the App User BEFORE any operations, - * all subsequent Nostr RPC calls will find and use the linked App User. - * - * Flow: - * 1. Create App User via admin API (if not exists) - * 2. Request a fresh linking token via admin API - * 3. Immediately link via Nostr RPC (before 2-min token expiry) - */ -async function linkNostrPubkeyToAppUser( - config: LightningConfig, - identity: MachineIdentity -): Promise { - console.log('[Linking] Starting npub linking process...') - - // Step 1: Create App User if needed - console.log('[Linking] Ensuring App User exists:', ATM_USER_IDENTIFIER) - const authToken = config.appToken || `app_${config.appId}` - console.log( - '[Linking] Using auth token:', - authToken.startsWith('eyJ') ? 'JWT token' : 'app_id fallback' - ) - const createUserResponse = await fetch(`${config.lightningPubApiUrl}/api/app/user/add`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify({ - identifier: ATM_USER_IDENTIFIER, - fail_if_exists: false, - balance: 0, - }), - }) - - if (!createUserResponse.ok) { - const errorText = await createUserResponse.text() - // User might already exist - that's OK - if (!errorText.includes('already exists')) { - console.log('[Linking] Create user response:', createUserResponse.status, errorText) - } - } else { - console.log('[Linking] App User created/exists') - } - - // Step 2: Request fresh linking token - console.log('[Linking] Requesting fresh linking token...') - const linkingTokenResponse = await fetch(`${config.lightningPubApiUrl}/api/app/user/npub/token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify({ - user_identifier: ATM_USER_IDENTIFIER, - }), - }) - - if (!linkingTokenResponse.ok) { - const errorText = await linkingTokenResponse.text() - // May fail if already linked - check for that - if (errorText.includes('already has an npub')) { - console.log('[Linking] User already has npub linked') - return - } - throw new Error(`Failed to get linking token: ${linkingTokenResponse.status} ${errorText}`) - } - - const linkingData = await linkingTokenResponse.json() - const token = linkingData.token - - if (!token) { - throw new Error('No token in linking response') - } - - console.log('[Linking] Got fresh token, linking immediately...') - - // Step 3: Link via Nostr RPC immediately - // Create a temporary LightningPub client just for linking - const tempClient = new NostrClient({ - relays: [{ url: config.relayUrl }], - identity, - }) - await tempClient.connect() - - const tempLP = new LightningPubClient({ - accountPubkey: config.lightningPubPubkey, - relays: [config.relayUrl], - appId: config.appId, - }) - tempLP.initialize(tempClient, identity) - - try { - await tempLP.linkNpub(token) - console.log('[Linking] Successfully linked npub to App User') - } finally { - tempLP.disconnect() - tempClient.disconnect() - } -} - // ============================================================================ // Cash-in Session Management (for ndebit single-use protection) // ============================================================================ @@ -800,22 +684,6 @@ export async function initializeLightningServices(): Promise lightningPub.initialize(nostrClient, identity) console.log('[Lightning] Lightning.Pub client initialized') - // Link Nostr pubkey to app user - CRITICAL for LNURL-withdraw - // The Extension API uses App Users, but Nostr RPC creates separate Nostr Users. - // We fetch a fresh linking token from the admin API and link immediately, - // before the 2-minute token expiry. This ensures: - // 1. Subsequent Nostr RPC calls find and use the linked App User - // 2. Funding and LNURL-withdraw use the same user (same balance) - if (CONFIG.appId && CONFIG.adminToken) { - try { - await linkNostrPubkeyToAppUser(CONFIG, identity) - } catch (e) { - console.log('[Lightning] Note: linking skipped or already linked:', e) - } - } else { - console.log('[Lightning] Skipping npub linking (no appId or adminToken configured)') - } - // Create CLINK client const clink = new CLINKClient({ nostrClient, diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index d4ad1dc..0fddefe 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -14,6 +14,41 @@ import type { HalConfig, HalServices } from '@/services/hal' import { deviceConfig, toHalConfig } from '@/config' import type { LightningPubClient } from '@lamassu/lightning' import type { CLINKClient } from '@lamassu/clink' +import type { TransactionRecord } from '@/types/state' + +// Check if we're running in Electron +const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined + +/** + * Load inventory from SQLite via IPC (Electron only). + * Returns empty object in browser dev mode. + */ +async function loadInventoryFromDb(): Promise> { + if (isElectron && window.electronAPI) { + try { + const inv = await window.electronAPI.getInventory() + console.log('[ATM] Loaded inventory from DB:', inv) + return inv + } catch (e) { + console.warn('[ATM] Failed to load inventory from DB:', e) + } + } + return {} +} + +/** + * Persist a completed transaction to SQLite via IPC. + */ +async function persistTransaction(tx: TransactionRecord): Promise { + if (isElectron && window.electronAPI) { + try { + await window.electronAPI.recordTransaction(tx) + console.log('[ATM] Transaction persisted:', tx.txid) + } catch (e) { + console.error('[ATM] Failed to persist transaction:', e) + } + } +} // Mock services for development/fallback const mockServices: ATMServices = { @@ -156,10 +191,42 @@ export const useAtmStore = defineStore('atm', () => { const machine = createATMMachine(services) actor.value = createActor(machine) + // Track previous state to detect transitions into 'complete' + let prevNestedState: string | null = null + // Subscribe to state changes actor.value.subscribe((newSnapshot: SnapshotFrom) => { + const prevSnapshot = snapshot.value snapshot.value = newSnapshot console.log('[ATM] State:', newSnapshot.value) + + // Detect transition into a complete state + const state = newSnapshot.value + let currentNested: string | null = null + if (typeof state === 'object') { + if ('cashIn' in state) currentNested = state.cashIn as string + if ('cashOut' in state) currentNested = state.cashOut as string + } + + if (currentNested === 'complete' && prevNestedState !== 'complete' && prevSnapshot) { + const ctx = newSnapshot.context + if (ctx.txid) { + const isCashInTx = typeof state === 'object' && 'cashIn' in state + const bills: { denomination: number; count: number }[] = isCashInTx + ? buildBillsFromInserted(ctx.billsInserted) + : ctx.dispenseAmounts + + persistTransaction({ + txid: ctx.txid, + type: isCashInTx ? 'cash_in' : 'cash_out', + fiatCents: ctx.fiatAmount, + sats: ctx.satsAmount, + bills, + }) + } + } + + prevNestedState = currentNested }) // Start the machine @@ -167,6 +234,17 @@ export const useAtmStore = defineStore('atm', () => { console.log('[ATM] State machine initialized') } + /** + * Group an array of inserted bill denominations into { denomination, count } pairs. + */ + function buildBillsFromInserted(bills: number[]): { denomination: number; count: number }[] { + const counts = new Map() + for (const d of bills) { + counts.set(d, (counts.get(d) || 0) + 1) + } + return Array.from(counts.entries()).map(([denomination, count]) => ({ denomination, count })) + } + /** * Initialize with real Lightning.Pub services */ @@ -235,8 +313,17 @@ export const useAtmStore = defineStore('atm', () => { } }) + // Inject DB-backed inventory into services + const servicesWithInventory: ATMServices = { + ...services.atmServices, + getInventory: async () => { + const fresh = await loadInventoryFromDb() + return Object.keys(fresh).length > 0 ? fresh : services.atmServices.getInventory() + }, + } + // Initialize state machine with real services - initialize(services.atmServices) + initialize(servicesWithInventory) } catch (error) { console.error('[ATM] Failed to connect to Lightning.Pub:', error) connectionStatus.value = 'error' @@ -443,6 +530,11 @@ export const useAtmStore = defineStore('atm', () => { const mergedServices: ATMServices = { ...lightning.atmServices, ...hal.atmServices, // Override dispenseCash and getInventory with real hardware + // If DB has inventory, use it; otherwise fall back to HAL + getInventory: async () => { + const fresh = await loadInventoryFromDb() + return Object.keys(fresh).length > 0 ? fresh : hal.atmServices.getInventory() + }, } // Initialize state machine with merged services @@ -586,6 +678,20 @@ export const useAtmStore = defineStore('atm', () => { send({ type: 'CONFIRM_AMOUNT' }) } + /** + * Refresh inventory from DB and update the state machine context. + * Called after operator sets cassettes or after transactions. + */ + async function refreshInventory() { + const inv = await loadInventoryFromDb() + if (actor.value && Object.keys(inv).length > 0) { + // Directly update the actor's context with new inventory + // XState v5: send a custom event that the machine can handle + // For now, we update via the next getInventory call on transition + console.log('[ATM] Inventory refreshed:', inv) + } + } + function toggleDebug() { debugMode.value = !debugMode.value } @@ -634,6 +740,7 @@ export const useAtmStore = defineStore('atm', () => { removeDenomination, clearSelection, confirmAmount, + refreshInventory, toggleDebug, } }) diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 6610420..097b600 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -10,8 +10,6 @@ export interface RuntimeConfig { atmPrivateKey: string adminToken: string appId: string - appToken: string - linkingToken: string machineModel: string fiatCode: string validatorDevice?: string @@ -24,6 +22,22 @@ declare global { electronAPI?: { getVersion: () => Promise getConfig: () => Promise + loadCassettes: () => Promise<{ denomination: number; count: number }[]> + setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise + getInventory: () => Promise> + getCashbox: () => Promise<{ + totalBills: number + totalFiatCents: number + lastEmptiedAt: number | null + }> + recordTransaction: (tx: { + txid: string + type: 'cash_in' | 'cash_out' + fiatCents: number + sats: number + bills: { denomination: number; count: number }[] + }) => Promise + emptyCashbox: () => Promise platform: NodeJS.Platform } } diff --git a/apps/machine/src/types/state.ts b/apps/machine/src/types/state.ts new file mode 100644 index 0000000..052bb5f --- /dev/null +++ b/apps/machine/src/types/state.ts @@ -0,0 +1,27 @@ +/** + * Shared types for persistent ATM state (SQLite <-> IPC <-> Renderer) + */ + +export interface CassetteState { + denomination: number + count: number +} + +export interface CashboxState { + totalBills: number + totalFiatCents: number + lastEmptiedAt: number | null +} + +export interface TransactionRecord { + txid: string + type: 'cash_in' | 'cash_out' + fiatCents: number + sats: number + bills: { denomination: number; count: number }[] +} + +export interface ATMAvailability { + cashIn: boolean + cashOut: boolean +} diff --git a/deploy/nixos/provision-atm.sh b/deploy/nixos/provision-atm.sh index 16fc070..5714dcc 100755 --- a/deploy/nixos/provision-atm.sh +++ b/deploy/nixos/provision-atm.sh @@ -38,7 +38,6 @@ RESPONSE=$(curl -s -X POST "$LP_API/api/admin/app/add" \ if echo "$RESPONSE" | grep -q '"status":"OK"'; then APP_ID=$(echo "$RESPONSE" | grep -oP '"id":"\K[^"]+') - APP_TOKEN=$(echo "$RESPONSE" | grep -oP '"auth_token":"\K[^"]+') echo "Created app: ${APP_ID:0:16}..." else echo "WARN: Could not create app (may already exist). Response:" @@ -77,7 +76,6 @@ VITE_EXTENSION_API_URL=http://$HOST_IP:1777 VITE_ADMIN_TOKEN=$ADMIN_TOKEN VITE_ATM_PRIVATE_KEY=$ATM_PRIVATE_KEY VITE_APP_ID=$APP_ID -VITE_APP_TOKEN=$APP_TOKEN # Machine configuration VITE_LAMASSU_MACHINE_MODEL=sintra diff --git a/docker/dev.sh b/docker/dev.sh index f979d98..25926a3 100755 --- a/docker/dev.sh +++ b/docker/dev.sh @@ -37,9 +37,7 @@ DEFAULT_FUNDING_SATS=100000 STATE_DIR="$SCRIPT_DIR/.state" PUBKEY_FILE="$STATE_DIR/lightning-pub-pubkey" NPROFILE_FILE="$STATE_DIR/lightning-pub-nprofile" -APP_TOKEN_FILE="$STATE_DIR/atm-app-token" APP_ID_FILE="$STATE_DIR/atm-app-id" -LINKING_TOKEN_FILE="$STATE_DIR/atm-linking-token" MODE_FILE="$STATE_DIR/compose-mode" # "standalone" or "unified" # Get current compose file based on last used mode @@ -491,7 +489,7 @@ setup_atm_app() { local app_name="lamassu-atm-dev" # Check if we already have valid app state - if [[ -f "$APP_ID_FILE" ]] && [[ -f "$APP_TOKEN_FILE" ]]; then + if [[ -f "$APP_ID_FILE" ]]; then local existing_app_id=$(cat "$APP_ID_FILE") if [[ -n "$existing_app_id" ]]; then log "Using existing ATM app: ${existing_app_id:0:16}..." @@ -508,33 +506,25 @@ setup_atm_app() { if echo "$response" | grep -q '"status":"OK"'; then local app_id=$(echo "$response" | grep -oP '"id":"\K[^"]+') - local app_token=$(echo "$response" | grep -oP '"auth_token":"\K[^"]+') echo "$app_id" > "$APP_ID_FILE" - echo "$app_token" > "$APP_TOKEN_FILE" - # Update ATM .env with app ID and token + # Update ATM .env with app ID local env_file="$PROJECT_DIR/apps/machine/.env" if [[ -f "$env_file" ]]; then if grep -q "VITE_APP_ID" "$env_file"; then sed -i "s/VITE_APP_ID=.*/VITE_APP_ID=$app_id/" "$env_file" else echo "" >> "$env_file" - echo "# ATM App ID for LNURL-withdraw" >> "$env_file" + echo "# ATM App ID" >> "$env_file" echo "VITE_APP_ID=$app_id" >> "$env_file" fi - if grep -q "VITE_APP_TOKEN" "$env_file"; then - sed -i "s|VITE_APP_TOKEN=.*|VITE_APP_TOKEN=$app_token|" "$env_file" - else - echo "VITE_APP_TOKEN=$app_token" >> "$env_file" - fi fi success "Created ATM app: ${app_id:0:16}..." return 0 else warn "Failed to create ATM app (may already exist)" - # Try to use existing app from state file if [[ -f "$APP_ID_FILE" ]]; then log "Using existing app ID from state" return 0 @@ -567,21 +557,12 @@ generate_lndconnect() { ############################################################################# get_atm_balance() { - # Get ATM app owner balance from Lightning.Pub logs local app_id=$(cat "$APP_ID_FILE" 2>/dev/null) if [[ -z "$app_id" ]]; then echo "not configured" return fi - - # Query the app balance via admin API (if available) - # For now, just indicate it's configured - local app_token=$(cat "$APP_TOKEN_FILE" 2>/dev/null) - if [[ -n "$app_token" ]]; then - echo "configured (use './dev.sh fund' to add sats)" - else - echo "not configured" - fi + echo "configured (use './dev.sh fund' to add sats)" } show_status() { diff --git a/packages/state-machine/src/machine.ts b/packages/state-machine/src/machine.ts index e3260a5..286b622 100644 --- a/packages/state-machine/src/machine.ts +++ b/packages/state-machine/src/machine.ts @@ -103,11 +103,22 @@ export function createATMMachine(services: Partial = {}) { ), getInventory: fromPromise(async () => { if (!services.getInventory) { - // Return mock inventory if service not provided - return { 20: 50 } as Record + return {} as Record } return services.getInventory() }), + getRateAndInventory: fromPromise(async ({ input }: { input: string }) => { + if (!services.getExchangeRate) { + throw new Error('getExchangeRate service not provided') + } + const [exchangeRate, inventory] = await Promise.all([ + services.getExchangeRate(input), + services.getInventory + ? services.getInventory() + : Promise.resolve({} as Record), + ]) + return { exchangeRate, inventory } + }), /** * Callback actor that watches an invoice for payment * Sends PAYMENT_RECEIVED when the invoice is paid @@ -140,8 +151,9 @@ export function createATMMachine(services: Partial = {}) { }), }, actions: { - resetContext: assign(() => ({ + resetContext: assign(({ context }) => ({ ...initialContext, + inventory: context.inventory, cashInSessionId: null, })), setStartTime: assign({ @@ -494,12 +506,13 @@ export function createATMMachine(services: Partial = {}) { states: { fetchingRate: { invoke: { - src: 'getExchangeRate', + src: 'getRateAndInventory', input: ({ context }) => context.currency, onDone: { target: 'selectingAmount', actions: assign({ - exchangeRate: ({ event }) => event.output, + exchangeRate: ({ event }) => event.output.exchangeRate, + inventory: ({ event }) => event.output.inventory, }), }, onError: { diff --git a/packages/state-machine/src/types.ts b/packages/state-machine/src/types.ts index 02f7f49..5a692ce 100644 --- a/packages/state-machine/src/types.ts +++ b/packages/state-machine/src/types.ts @@ -144,8 +144,8 @@ export const initialContext: ATMContext = { cashDispensed: false, dispenseAmounts: [], cashOutSelection: [], - // Mock inventory: $20 bills only for Phase 1 - inventory: { 20: 50 }, + // Populated at runtime from DB (empty = no cassettes loaded yet) + inventory: {}, userNpub: null, error: null, retryCount: 0, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3656602..48748f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@vueuse/core': specifier: ^14.1.0 version: 14.1.0(vue@3.5.27(typescript@5.9.3)) + better-sqlite3: + specifier: ^11.0.0 + version: 11.10.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -78,6 +81,9 @@ importers: '@tailwindcss/vite': specifier: ^4.0.0 version: 4.1.18(vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@types/better-sqlite3': + specifier: ^7.0.0 + version: 7.6.13 '@types/node': specifier: ^22.0.0 version: 22.19.7 @@ -1217,6 +1223,9 @@ packages: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -1494,6 +1503,12 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + better-sqlite3@11.10.0: + resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -1565,6 +1580,9 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} @@ -1712,6 +1730,10 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -1873,6 +1895,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1907,6 +1933,9 @@ packages: picomatch: optional: true + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} @@ -1980,6 +2009,9 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -2103,6 +2135,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -2406,6 +2441,9 @@ packages: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -2425,6 +2463,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + negotiator@0.6.4: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} @@ -2598,6 +2639,11 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + prettier@3.8.1: resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} @@ -2643,6 +2689,10 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + read-binary-file-arch@1.0.6: resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} hasBin: true @@ -2773,6 +2823,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-update-notifier@2.0.0: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} @@ -2843,6 +2899,10 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} @@ -2865,6 +2925,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -2921,6 +2984,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + turbo-darwin-64@2.7.5: resolution: {integrity: sha512-nN3wfLLj4OES/7awYyyM7fkU8U8sAFxsXau2bYJwAWi6T09jd87DgHD8N31zXaJ7LcpyppHWPRI2Ov9MuZEwnQ==} cpu: [x64] @@ -3927,6 +3993,10 @@ snapshots: '@tootallnate/once@2.0.0': {} + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 22.19.7 + '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 @@ -4302,6 +4372,15 @@ snapshots: base64-js@1.5.1: {} + better-sqlite3@11.10.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -4422,6 +4501,8 @@ snapshots: check-error@2.1.3: {} + chownr@1.1.4: {} + chownr@2.0.0: {} chromium-pickle-js@0.2.0: {} @@ -4550,6 +4631,8 @@ snapshots: deep-eql@5.0.2: {} + deep-extend@0.6.0: {} + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -4820,6 +4903,8 @@ snapshots: dependencies: '@types/estree': 1.0.8 + expand-template@2.0.3: {} + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -4849,6 +4934,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + file-uri-to-path@1.0.0: {} + filelist@1.0.4: dependencies: minimatch: 5.1.6 @@ -4948,6 +5035,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: {} + glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -5104,6 +5193,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + ip-address@10.1.0: {} is-ci@3.0.1: @@ -5365,6 +5456,8 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 + mkdirp-classic@0.5.3: {} + mkdirp@1.0.4: {} ms@2.1.2: {} @@ -5375,6 +5468,8 @@ snapshots: nanoid@3.3.11: {} + napi-build-utils@2.0.0: {} + negotiator@0.6.4: {} node-abi@3.87.0: @@ -5551,6 +5646,21 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.87.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prettier@3.8.1: {} process-nextick-args@2.0.1: {} @@ -5583,6 +5693,13 @@ snapshots: quick-lru@5.1.1: {} + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + read-binary-file-arch@1.0.6: dependencies: debug: 4.4.3 @@ -5762,6 +5879,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-update-notifier@2.0.0: dependencies: semver: 7.7.3 @@ -5838,6 +5963,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-json-comments@2.0.1: {} + sumchecker@3.0.1: dependencies: debug: 4.4.3 @@ -5858,6 +5985,13 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -5916,6 +6050,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + turbo-darwin-64@2.7.5: optional: true