/** * Plaid Local Learning System * * A privacy-preserving financial learning system that runs entirely in the browser. * No financial data, learning patterns, or AI models ever leave the client device. * * ## Architecture * * ``` * ┌─────────────────────────────────────────────────────────────────────┐ * │ BROWSER (All Data Stays Here) │ * │ │ * │ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │ * │ │ Plaid Link │────▶│ Transaction │────▶│ Local Learning │ │ * │ │ (OAuth) │ │ Processor │ │ Engine (WASM) │ │ * │ └─────────────┘ └──────────────┘ └───────────────────┘ │ * │ │ │ │ │ * │ ▼ ▼ ▼ │ * │ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │ * │ │ IndexedDB │ │ IndexedDB │ │ IndexedDB │ │ * │ │ (Tokens) │ │ (Embeddings) │ │ (Q-Values) │ │ * │ └─────────────┘ └──────────────┘ └───────────────────┘ │ * │ │ * │ ┌─────────────────────────────────────────────────────────────┐ │ * │ │ RuVector WASM Engine │ │ * │ │ • HNSW Vector Index (150x faster similarity search) │ │ * │ │ • Spiking Neural Network (temporal pattern learning) │ │ * │ │ • Q-Learning (spending optimization) │ │ * │ │ • LSH (semantic categorization) │ │ * │ └─────────────────────────────────────────────────────────────┘ │ * └─────────────────────────────────────────────────────────────────────┘ * ``` * * ## Privacy Guarantees * * 1. Financial data NEVER leaves the browser * 2. Learning happens 100% client-side in WASM * 3. Optional encryption for IndexedDB storage * 4. No analytics, telemetry, or tracking * 5. User can delete all data instantly * * @example * ```typescript * import { PlaidLocalLearner } from './plaid-local-learner'; * * const learner = new PlaidLocalLearner(); * await learner.init(); * * // Process transactions (stays in browser) * const insights = await learner.processTransactions(transactions); * * // Get predictions (computed locally) * const category = await learner.predictCategory(newTransaction); * const anomaly = await learner.detectAnomaly(newTransaction); * * // All data persisted to IndexedDB * await learner.save(); * ``` */ import init, { PlaidLocalLearner as WasmLearner, WasmHnswIndex, WasmCrypto, WasmSpikingNetwork, } from './ruvector_edge'; // Database constants const DB_NAME = 'plaid_local_learning'; const DB_VERSION = 1; const STORES = { STATE: 'learning_state', TOKENS: 'plaid_tokens', TRANSACTIONS: 'transactions', INSIGHTS: 'insights', }; /** * Transaction from Plaid API */ export interface Transaction { transaction_id: string; account_id: string; amount: number; date: string; name: string; merchant_name?: string; category: string[]; pending: boolean; payment_channel: string; } /** * Spending pattern learned from transactions */ export interface SpendingPattern { pattern_id: string; category: string; avg_amount: number; frequency_days: number; confidence: number; last_seen: number; } /** * Category prediction result */ export interface CategoryPrediction { category: string; confidence: number; similar_transactions: string[]; } /** * Anomaly detection result */ export interface AnomalyResult { is_anomaly: boolean; anomaly_score: number; reason: string; expected_amount: number; } /** * Budget recommendation */ export interface BudgetRecommendation { category: string; recommended_limit: number; current_avg: number; trend: 'increasing' | 'stable' | 'decreasing'; confidence: number; } /** * Processing insights from batch */ export interface ProcessingInsights { transactions_processed: number; total_amount: number; patterns_learned: number; state_version: number; } /** * Learning statistics */ export interface LearningStats { version: number; patterns_count: number; q_values_count: number; embeddings_count: number; index_size: number; } /** * Temporal spending heatmap */ export interface TemporalHeatmap { day_of_week: number[]; // 7 values (Sun-Sat) day_of_month: number[]; // 31 values } /** * Plaid Link configuration */ export interface PlaidConfig { clientId?: string; environment: 'sandbox' | 'development' | 'production'; products: string[]; countryCodes: string[]; language: string; } /** * Browser-local financial learning engine * * All data processing happens in the browser using WebAssembly. * Financial data is never transmitted to any server. */ export class PlaidLocalLearner { private wasmLearner: WasmLearner | null = null; private db: IDBDatabase | null = null; private initialized = false; private encryptionKey: CryptoKey | null = null; /** * Initialize the local learner * * - Loads WASM module * - Opens IndexedDB * - Restores previous learning state */ async init(encryptionPassword?: string): Promise { if (this.initialized) return; // Initialize WASM await init(); // Create WASM learner this.wasmLearner = new WasmLearner(); // Open IndexedDB this.db = await this.openDatabase(); // Setup encryption if password provided if (encryptionPassword) { this.encryptionKey = await this.deriveKey(encryptionPassword); } // Load previous state await this.load(); this.initialized = true; console.log('🧠 PlaidLocalLearner initialized (100% browser-local)'); } /** * Open IndexedDB database */ private openDatabase(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; // Create object stores if (!db.objectStoreNames.contains(STORES.STATE)) { db.createObjectStore(STORES.STATE); } if (!db.objectStoreNames.contains(STORES.TOKENS)) { db.createObjectStore(STORES.TOKENS); } if (!db.objectStoreNames.contains(STORES.TRANSACTIONS)) { const store = db.createObjectStore(STORES.TRANSACTIONS, { keyPath: 'transaction_id', }); store.createIndex('date', 'date'); store.createIndex('category', 'category', { multiEntry: true }); } if (!db.objectStoreNames.contains(STORES.INSIGHTS)) { db.createObjectStore(STORES.INSIGHTS); } }; }); } /** * Derive encryption key from password * * Uses a unique salt per installation stored in IndexedDB. * This prevents rainbow table attacks across different users. */ private async deriveKey(password: string): Promise { const encoder = new TextEncoder(); // Get or create unique salt for this installation const salt = await this.getOrCreateSalt(); const keyMaterial = await crypto.subtle.importKey( 'raw', encoder.encode(password), 'PBKDF2', false, ['deriveBits', 'deriveKey'] ); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256', }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ); } /** * Get or create a unique salt for this installation * * Salt is stored in IndexedDB and persists across sessions. * Each browser/device gets a unique salt. */ private async getOrCreateSalt(): Promise { const SALT_KEY = '_encryption_salt'; return new Promise(async (resolve, reject) => { const transaction = this.db!.transaction([STORES.STATE], 'readwrite'); const store = transaction.objectStore(STORES.STATE); // Try to get existing salt const getRequest = store.get(SALT_KEY); getRequest.onsuccess = () => { if (getRequest.result) { // Use existing salt resolve(new Uint8Array(getRequest.result)); } else { // Generate new random salt (32 bytes) const newSalt = crypto.getRandomValues(new Uint8Array(32)); // Store it for future use const putRequest = store.put(newSalt.buffer, SALT_KEY); putRequest.onsuccess = () => resolve(newSalt); putRequest.onerror = () => reject(putRequest.error); } }; getRequest.onerror = () => reject(getRequest.error); }); } /** * Encrypt data for storage */ private async encrypt(data: string): Promise { if (!this.encryptionKey) { return new TextEncoder().encode(data); } const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, this.encryptionKey, new TextEncoder().encode(data) ); // Prepend IV to encrypted data const result = new Uint8Array(iv.length + encrypted.byteLength); result.set(iv); result.set(new Uint8Array(encrypted), iv.length); return result.buffer; } /** * Decrypt data from storage */ private async decrypt(data: ArrayBuffer): Promise { if (!this.encryptionKey) { return new TextDecoder().decode(data); } const dataArray = new Uint8Array(data); const iv = dataArray.slice(0, 12); const encrypted = dataArray.slice(12); const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, this.encryptionKey, encrypted ); return new TextDecoder().decode(decrypted); } /** * Save learning state to IndexedDB */ async save(): Promise { this.ensureInitialized(); const stateJson = this.wasmLearner!.saveState(); const encrypted = await this.encrypt(stateJson); return new Promise((resolve, reject) => { const transaction = this.db!.transaction([STORES.STATE], 'readwrite'); const store = transaction.objectStore(STORES.STATE); const request = store.put(encrypted, 'main'); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(); }); } /** * Load learning state from IndexedDB */ async load(): Promise { this.ensureInitialized(); return new Promise((resolve, reject) => { const transaction = this.db!.transaction([STORES.STATE], 'readonly'); const store = transaction.objectStore(STORES.STATE); const request = store.get('main'); request.onerror = () => reject(request.error); request.onsuccess = async () => { if (request.result) { try { const stateJson = await this.decrypt(request.result); this.wasmLearner!.loadState(stateJson); } catch (e) { console.warn('Failed to load state, starting fresh:', e); } } resolve(); }; }); } /** * Process a batch of transactions * * All processing happens locally in WASM. No data is transmitted. */ async processTransactions(transactions: Transaction[]): Promise { this.ensureInitialized(); // Store transactions locally await this.storeTransactions(transactions); // Process in WASM const insights = this.wasmLearner!.processTransactions( JSON.stringify(transactions) ) as ProcessingInsights; // Auto-save state await this.save(); return insights; } /** * Store transactions in IndexedDB */ private async storeTransactions(transactions: Transaction[]): Promise { return new Promise((resolve, reject) => { const transaction = this.db!.transaction([STORES.TRANSACTIONS], 'readwrite'); const store = transaction.objectStore(STORES.TRANSACTIONS); transactions.forEach((tx) => { store.put(tx); }); transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); }); } /** * Predict category for a transaction */ predictCategory(transaction: Transaction): CategoryPrediction { this.ensureInitialized(); return this.wasmLearner!.predictCategory( JSON.stringify(transaction) ) as CategoryPrediction; } /** * Detect if a transaction is anomalous */ detectAnomaly(transaction: Transaction): AnomalyResult { this.ensureInitialized(); return this.wasmLearner!.detectAnomaly( JSON.stringify(transaction) ) as AnomalyResult; } /** * Get budget recommendation for a category */ getBudgetRecommendation( category: string, currentSpending: number, budget: number ): BudgetRecommendation { this.ensureInitialized(); return this.wasmLearner!.getBudgetRecommendation( category, currentSpending, budget ) as BudgetRecommendation; } /** * Record spending outcome for Q-learning * * @param category - Spending category * @param action - 'under_budget', 'at_budget', or 'over_budget' * @param reward - Reward value (-1 to 1) */ recordOutcome( category: string, action: 'under_budget' | 'at_budget' | 'over_budget', reward: number ): void { this.ensureInitialized(); this.wasmLearner!.recordOutcome(category, action, reward); } /** * Get all learned spending patterns */ getPatterns(): SpendingPattern[] { this.ensureInitialized(); return this.wasmLearner!.getPatternsSummary() as SpendingPattern[]; } /** * Get temporal spending heatmap */ getTemporalHeatmap(): TemporalHeatmap { this.ensureInitialized(); return this.wasmLearner!.getTemporalHeatmap() as TemporalHeatmap; } /** * Find similar transactions */ findSimilar(transaction: Transaction, k: number = 5): { id: string; distance: number }[] { this.ensureInitialized(); return this.wasmLearner!.findSimilarTransactions( JSON.stringify(transaction), k ) as { id: string; distance: number }[]; } /** * Get learning statistics */ getStats(): LearningStats { this.ensureInitialized(); return this.wasmLearner!.getStats() as LearningStats; } /** * Clear all learned data * * Privacy feature: completely wipes all local learning data. */ async clearAllData(): Promise { this.ensureInitialized(); // Clear WASM state this.wasmLearner!.clear(); // Clear IndexedDB const stores = [STORES.STATE, STORES.TRANSACTIONS, STORES.INSIGHTS]; for (const storeName of stores) { await new Promise((resolve, reject) => { const transaction = this.db!.transaction([storeName], 'readwrite'); const store = transaction.objectStore(storeName); const request = store.clear(); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(); }); } console.log('🗑️ All local learning data cleared'); } /** * Get stored transactions from IndexedDB */ async getStoredTransactions( options: { startDate?: string; endDate?: string; category?: string; limit?: number; } = {} ): Promise { return new Promise((resolve, reject) => { const transaction = this.db!.transaction([STORES.TRANSACTIONS], 'readonly'); const store = transaction.objectStore(STORES.TRANSACTIONS); let request: IDBRequest; if (options.startDate && options.endDate) { const index = store.index('date'); request = index.getAll(IDBKeyRange.bound(options.startDate, options.endDate)); } else if (options.category) { const index = store.index('category'); request = index.getAll(options.category); } else { request = store.getAll(); } request.onerror = () => reject(request.error); request.onsuccess = () => { let results = request.result as Transaction[]; if (options.limit) { results = results.slice(0, options.limit); } resolve(results); }; }); } /** * Export all data for backup * * Returns encrypted data that can be imported later. */ async exportData(): Promise { this.ensureInitialized(); const exportData = { state: this.wasmLearner!.saveState(), transactions: await this.getStoredTransactions(), exportedAt: new Date().toISOString(), version: 1, }; return this.encrypt(JSON.stringify(exportData)); } /** * Import data from backup */ async importData(encryptedData: ArrayBuffer): Promise { this.ensureInitialized(); const json = await this.decrypt(encryptedData); const importData = JSON.parse(json); // Load state this.wasmLearner!.loadState(importData.state); // Store transactions if (importData.transactions) { await this.storeTransactions(importData.transactions); } await this.save(); } /** * Ensure learner is initialized */ private ensureInitialized(): void { if (!this.initialized || !this.wasmLearner || !this.db) { throw new Error('PlaidLocalLearner not initialized. Call init() first.'); } } /** * Close database connection */ close(): void { if (this.db) { this.db.close(); this.db = null; } this.initialized = false; } } /** * Plaid Link integration helper * * Handles Plaid Link flow while keeping tokens local. */ export class PlaidLinkHandler { private db: IDBDatabase | null = null; constructor(private config: PlaidConfig) {} /** * Initialize handler */ async init(): Promise { this.db = await this.openDatabase(); } private openDatabase(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); }); } /** * Store access token locally * * Token never leaves the browser. */ async storeToken(itemId: string, accessToken: string): Promise { return new Promise((resolve, reject) => { const transaction = this.db!.transaction([STORES.TOKENS], 'readwrite'); const store = transaction.objectStore(STORES.TOKENS); // Store encrypted (in production, use proper encryption) const request = store.put( { accessToken, storedAt: Date.now(), }, itemId ); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(); }); } /** * Get stored token */ async getToken(itemId: string): Promise { return new Promise((resolve, reject) => { const transaction = this.db!.transaction([STORES.TOKENS], 'readonly'); const store = transaction.objectStore(STORES.TOKENS); const request = store.get(itemId); request.onerror = () => reject(request.error); request.onsuccess = () => { resolve(request.result?.accessToken ?? null); }; }); } /** * Delete token */ async deleteToken(itemId: string): Promise { return new Promise((resolve, reject) => { const transaction = this.db!.transaction([STORES.TOKENS], 'readwrite'); const store = transaction.objectStore(STORES.TOKENS); const request = store.delete(itemId); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(); }); } /** * List all stored item IDs */ async listItems(): Promise { return new Promise((resolve, reject) => { const transaction = this.db!.transaction([STORES.TOKENS], 'readonly'); const store = transaction.objectStore(STORES.TOKENS); const request = store.getAllKeys(); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result as string[]); }); } } // Export default instance export default PlaidLocalLearner;