/* ──────────────────────────────────────────────────────────────── Drop‑in replacement for mysql2 that transparently encrypts / decrypts selected columns, using helpers above. ---------------------------------------------------------------- */ import mysql from 'mysql2/promise'; import { encrypt, decrypt, isEncrypted, initEncryption } from '../crypto/encryption.js'; const WRITE_RE = /^\s*(insert|update|replace)\s/i; /* ── map of columns that must be protected ─────────────────── */ const TABLE_MAP = { user_profile : [ 'username', 'firstname', 'lastname', 'email', 'phone_e164', 'zipcode', 'stripe_customer_id', 'interest_inventory_answers', 'riasec_scores', 'career_priorities', 'career_list' ], financial_profiles : [ 'current_salary','additional_income','monthly_expenses', 'monthly_debt_payments','retirement_savings','emergency_fund', 'retirement_contribution','emergency_contribution', 'extra_cash_emergency_pct','extra_cash_retirement_pct' ], career_profiles : [ 'planned_monthly_expenses','planned_monthly_debt_payments', 'planned_monthly_retirement_contribution','planned_monthly_emergency_contribution', 'planned_surplus_emergency_pct','planned_surplus_retirement_pct', 'planned_additional_income','career_goals','desired_retirement_income_monthly', 'career_name','start_date','retirement_start_date','scenario_title' ], college_profiles : [ 'selected_school','selected_program','annual_financial_aid', 'existing_college_debt','tuition','tuition_paid','loan_deferral_until_graduation', 'loan_term','interest_rate','extra_payment','expected_salary' ], milestones : ['title','description','date','progress'], tasks : ['title','description','due_date'], reminders : ['phone_e164','message_body'], milestone_impacts : ['amount','impact_type', 'direction'], ai_risk_analysis : ['reasoning','risk_level'], ai_generated_ksa : ['knowledge_json','abilities_json','skills_json'], context_cache : ['ctx_text'] }; /* ── initialise KMS unwrap once ─────────────────────────────── */ async function ensureCryptoReady () { await initEncryption(); } // choose cap per-process from env (server1/2/3 each have their own DB_POOL_SIZE) const cap = Number(process.env.DB_POOL_SIZE || 5); console.log(`[db] mysql pool connectionLimit=${cap}`); /* ── mysql connection pool (uses env injected by docker) ────── */ export const pool = mysql.createPool({ host : process.env.DB_HOST, port : process.env.DB_PORT, user : process.env.DB_USER, password : process.env.DB_PASSWORD, database : process.env.DB_NAME, waitForConnections : true, connectionLimit : cap, ssl : { ca : process.env.DB_SSL_CA, key : process.env.DB_SSL_KEY, cert : process.env.DB_SSL_CERT, servername: '' } }); /* ── tiny helpers to parse SQL (works for *your* queries) ───── */ function extractTables (sql) { return [...new Set( [...sql.matchAll(/\b(?:from|join|into|update)\s+`?([a-z0-9_]+)`?/ig)] .map(m => m[1].toLowerCase()) )]; } function extractColumn(sql, paramIndex) { const s = sql.replace(/\s+/g, ' ').toLowerCase(); // INSERT INTO t (c1, c2, ...) VALUES (?, ?, ...) const ins = s.match(/insert\s+into\s+[`\w]+\s*\(([^)]+)\)\s*values\s*\(([^)]+)\)/i); if (ins) { const cols = ins[1].split(',').map(c => c.replace(/`/g, '').trim()); return cols[paramIndex] || null; // only VALUES params exist here } // UPDATE t SET c1 = ?, c2 = ? WHERE ... const upd = s.match(/update\s+[`\w]+\s+set\s+(.+?)(?:\s+where|\s*$)/i); if (upd) { const setPart = upd[1]; // Build a list of columns in the same order as the '?'s in SET only const pairs = setPart.split(',').map(p => p.trim()); const colsForQs = []; for (const p of pairs) { const col = p.split('=')[0].replace(/`/g, '').trim(); const qCnt = (p.match(/\?/g) || []).length; for (let i = 0; i < qCnt; i++) colsForQs.push(col); } const setQCount = colsForQs.length; if (paramIndex < setQCount) return colsForQs[paramIndex]; // params after SET (WHERE/LIMIT/etc.) → no mapping return null; } // SELECT/DELETE/etc. → we don’t map WHERE/LIMIT params return null; } function decryptRow (row, tables) { for (const t of tables) { const encSet = new Set((TABLE_MAP[t] ?? []).map(c => c.toLowerCase())); for (const k of Object.keys(row)) { if (!encSet.has(k.toLowerCase())) continue; const val = row[k]; if (val != null && isEncrypted(val)) row[k] = decrypt(val); } } } /* ───────────────────────────────────────────────────────────── Replacement for pool.execute / pool.query ──────────────────────────────────────────────────────────── */ export async function exec(sql, params = []) { await ensureCryptoReady(); const isWrite = WRITE_RE.test(sql); const tables = extractTables(sql); const encryptNeeded = (col) => tables.some(t => (TABLE_MAP[t] || []).some(c => c.toLowerCase() === String(col || '').toLowerCase() )); const encParams = isWrite ? params.map((v, i) => { const col = extractColumn(sql, i); return (col && encryptNeeded(col) && v != null && !isEncrypted(v)) ? encrypt(v) : v; }) : params; // SELECT path: never touch params const [rows, fields] = await pool.execute(sql, encParams); if (Array.isArray(rows)) for (const row of rows) decryptRow(row, tables); return [rows, fields]; } /* ── mysql‑like façade so existing code keeps working ───────── */ export const query = exec; export const getConnection = () => pool.getConnection(); export const end = () => pool.end(); export const poolRaw = pool; // escape hatch