feat(machine): add SQLite persistence and remove npub linking

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 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-19 11:45:09 -05:00
commit f37555565e
15 changed files with 739 additions and 171 deletions

View file

@ -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()
})

View file

@ -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<RuntimeConfig> => 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<string>
getConfig: () => Promise<RuntimeConfig>
loadCassettes: () => Promise<{ denomination: number; count: number }[]>
setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise<void>
getInventory: () => Promise<Record<number, number>>
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<void>
emptyCashbox: () => Promise<void>
platform: NodeJS.Platform
}
}

View file

@ -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<number, number> {
const rows = loadCassettes()
const inv: Record<number, number> = {}
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')
}
}

View file

@ -9,6 +9,6 @@
"outDir": "../dist-electron",
"rootDir": "."
},
"include": ["main.ts"],
"include": ["main.ts", "state-store.ts"],
"exclude": ["preload.ts"]
}

View file

@ -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",

View file

@ -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<Record<number, number>>
/** Reactive Lightning.Pub balance in sats (null = unknown) */
balanceSats: Ref<number | null>
/** 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 }
}

View file

@ -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<LightningConfig> {
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<LightningConfig> {
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<LightningConfig> {
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<void> {
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<LightningServices>
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,

View file

@ -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<Record<number, number>> {
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<void> {
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<ATMMachine>) => {
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<number, number>()
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,
}
})

View file

@ -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<string>
getConfig: () => Promise<RuntimeConfig>
loadCassettes: () => Promise<{ denomination: number; count: number }[]>
setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise<void>
getInventory: () => Promise<Record<number, number>>
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<void>
emptyCashbox: () => Promise<void>
platform: NodeJS.Platform
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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() {

View file

@ -103,11 +103,22 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
),
getInventory: fromPromise(async () => {
if (!services.getInventory) {
// Return mock inventory if service not provided
return { 20: 50 } as Record<number, number>
return {} as Record<number, number>
}
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<number, number>),
])
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<ATMServices> = {}) {
}),
},
actions: {
resetContext: assign(() => ({
resetContext: assign(({ context }) => ({
...initialContext,
inventory: context.inventory,
cashInSessionId: null,
})),
setStartTime: assign({
@ -494,12 +506,13 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
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: {

View file

@ -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,

138
pnpm-lock.yaml generated
View file

@ -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