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:
parent
722635a5a6
commit
f37555565e
15 changed files with 739 additions and 171 deletions
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
271
apps/machine/electron/state-store.ts
Normal file
271
apps/machine/electron/state-store.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,6 @@
|
|||
"outDir": "../dist-electron",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["main.ts"],
|
||||
"include": ["main.ts", "state-store.ts"],
|
||||
"exclude": ["preload.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
101
apps/machine/src/composables/useAvailabilityBroadcast.ts
Normal file
101
apps/machine/src/composables/useAvailabilityBroadcast.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
18
apps/machine/src/types/electron.d.ts
vendored
18
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
apps/machine/src/types/state.ts
Normal file
27
apps/machine/src/types/state.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
show_status() {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
138
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue