'use strict'; // SimpleFIN consumer client. // // This module handles the protocol-level work: claiming tokens, fetching // accounts/transactions, and normalizing the raw data into DB row shapes. // // Security rules enforced here: // - Only HTTPS claim URLs are accepted. // - Only HTTPS Access URLs are accepted. // - Tokens and Access URLs are never logged. // - Error messages are sanitized before being returned to callers. function sanitizeErrorMessage(msg) { if (typeof msg !== 'string') return 'Provider error'; // Strip embedded HTTP Basic Auth credentials (https://user:pass@host) return msg.replace(/https?:\/\/[^@\s]+:[^@\s]+@/gi, 'https://[credentials]@'); } function sanitizeError(err) { const msg = sanitizeErrorMessage(err?.message || String(err || 'Unknown error')); const e = new Error(msg); e.code = err?.code || 'SIMPLEFIN_ERROR'; e.status = err?.status; return e; } function sanitizeRawData(obj) { if (!obj || typeof obj !== 'object') return null; const safe = JSON.parse(JSON.stringify(obj)); // Remove org sfin-url which may embed auth if (safe.org) delete safe.org['sfin-url']; try { return JSON.stringify(safe).slice(0, 4096); } catch { return null; } } async function claimSetupToken(setupToken) { if (!setupToken || typeof setupToken !== 'string') { throw new Error('setupToken is required'); } const token = setupToken.trim(); // Base64-decode to get the claim URL; handle both raw base64 and data-URLs let claimUrl; try { const decoded = Buffer.from(token, 'base64').toString('utf8').trim(); claimUrl = decoded.startsWith('http') ? decoded : token; } catch { claimUrl = token; } // Also accept a raw URL pasted directly if (!claimUrl.startsWith('http')) { throw new Error('Could not decode setup token into a claim URL'); } if (!claimUrl.startsWith('https://')) { throw new Error('Setup token must use a secure HTTPS URL'); } let accessUrl; try { const res = await fetch(claimUrl, { method: 'POST', signal: AbortSignal.timeout(30000) }); if (res.status === 403) { throw new Error('This setup token has already been claimed or is invalid'); } if (!res.ok) { throw new Error(`Token claim failed (HTTP ${res.status})`); } accessUrl = (await res.text()).trim(); } catch (err) { throw sanitizeError(err); } if (!accessUrl.startsWith('https://')) { throw new Error('Provider returned an insecure Access URL — only HTTPS is supported'); } return accessUrl; } async function fetchAccountsAndTransactions(accessUrl, sinceEpoch) { let url; try { url = new URL(accessUrl); } catch { throw new Error('Invalid Access URL'); } const basicAuth = Buffer.from(`${url.username}:${url.password}`).toString('base64'); const baseUrl = `${url.protocol}//${url.host}${url.pathname.replace(/\/?$/, '')}`; const endpoint = `${baseUrl}/accounts?start-date=${Math.floor(sinceEpoch)}&version=2`; let data; try { const res = await fetch(endpoint, { headers: { 'Authorization': `Basic ${basicAuth}` }, signal: AbortSignal.timeout(30000), }); if (res.status === 403) { throw Object.assign(new Error('SimpleFIN access has been revoked — please reconnect'), { code: 'SIMPLEFIN_REVOKED' }); } if (!res.ok) { throw new Error(`SimpleFIN fetch failed (HTTP ${res.status})`); } data = await res.json(); } catch (err) { throw sanitizeError(err); } // Surface any connection-level errors from the errlist so callers can log them if (Array.isArray(data.errlist) && data.errlist.length > 0) { const msgs = data.errlist .map(e => sanitizeErrorMessage(e.message || e.msg || e.code || 'Unknown error')) .join('; '); data._errlistSummary = msgs; } return data; } function normalizeAccount(rawAccount, dataSourceId, userId) { const balance = rawAccount.balance != null ? Math.round(parseFloat(rawAccount.balance) * 100) : null; const availableBalance = rawAccount['available-balance'] != null ? Math.round(parseFloat(rawAccount['available-balance']) * 100) : null; return { user_id: userId, data_source_id: dataSourceId, provider_account_id: String(rawAccount.id), name: String(rawAccount.name || 'Account').slice(0, 255), org_name: (rawAccount.org?.name || rawAccount.conn_name) ? String(rawAccount.org?.name || rawAccount.conn_name).slice(0, 255) : null, account_type: null, currency: rawAccount.currency ? String(rawAccount.currency).slice(0, 10) : 'USD', balance: Number.isFinite(balance) ? balance : null, available_balance: Number.isFinite(availableBalance) ? availableBalance : null, raw_data: sanitizeRawData(rawAccount), }; } function normalizeTransaction(rawTx, localAccountId, dataSourceId, userId, connId, accountId) { const amount = Math.round(parseFloat(rawTx.amount) * 100); const postedDate = rawTx.posted ? new Date(rawTx.posted * 1000).toISOString().slice(0, 10) : null; const transactedAt = rawTx['transacted_at'] ? new Date(rawTx['transacted_at'] * 1000).toISOString() : null; // Format: simplefin:{data_source_id}:{simplefin_account_id}:{tx.id} const providerTxId = `simplefin:${connId}:${accountId}:${rawTx.id}`; return { user_id: userId, data_source_id: dataSourceId, account_id: localAccountId, provider_transaction_id: providerTxId, source_type: 'provider_sync', posted_date: postedDate, transacted_at: transactedAt, amount: Number.isFinite(amount) ? amount : 0, currency: 'USD', description: rawTx.description ? String(rawTx.description).slice(0, 500) : null, payee: rawTx.payee ? String(rawTx.payee).slice(0, 255) : null, memo: rawTx.memo ? String(rawTx.memo).slice(0, 500) : null, match_status: 'unmatched', ignored: 0, raw_data: sanitizeRawData(rawTx), }; } module.exports = { claimSetupToken, fetchAccountsAndTransactions, normalizeAccount, normalizeTransaction, sanitizeError, sanitizeErrorMessage, };