master
John Lauer v2.0.382 (insiders): Insiders tier, corrected to the fleet convention (John: a separate private page is wiki-bloat): th… e7b1214 2mo ago
// card_vault.js — payment-card vault for pup (issue #84).
//
// Why this exists
// ───────────────
// pup's credential vault stores LOGINS; there was no equivalent for payment cards, so an AI
// could drive an entire checkout and then stall at the Stripe step (the Venture Dallas case,
// 2026-09-02: pass selected, form filled, no card). A normal Chrome profile keeps cards in its
// autofill store; pup's durable profile had nothing. This module is the storage half; the verbs
// in server.js own the CONSENT gate (a per-fill desktop toast naming merchant + amount + last4).
//
// Storage (mirrors credential_vault.js)
// ─────────────────────────────────────
//   1. PAN + CVV live in the OS keychain via keytar. Service `adom-pup-cards`,
//      account = the card's nickname. OS-encrypted at rest; never our own crypto.
//   2. The INDEX (nickname, brand, last4, expiry, cardholder name, timestamps — NEVER the PAN
//      or CVV) lives at ~/.adom/pup-cards.index.json for fast listing.
//
// Hard rules
//   - list/verb surfaces return nickname/brand/last4/expiry only. No verb returns a PAN or CVV.
//   - The full card is read back ONLY by the in-process fill path (getCard), which types it
//     into a page pup is driving and returns booleans.
//   - Every audit line carries the last4 at most.

const fs = require('fs');
const path = require('path');
const os = require('os');

const KEYTAR_SERVICE = 'adom-pup-cards';
const INDEX_PATH = path.join(os.homedir(), '.adom', 'pup-cards.index.json');

let keytar = null;
let keytarLoadError = null;
try { keytar = require('keytar'); } catch (e) { keytarLoadError = e.message; }

function readIndex() {
  try { return JSON.parse(fs.readFileSync(INDEX_PATH, 'utf8')); } catch (e) { return { cards: [] }; }
}
function writeIndex(ix) {
  try { fs.mkdirSync(path.dirname(INDEX_PATH), { recursive: true }); fs.writeFileSync(INDEX_PATH, JSON.stringify(ix, null, 2)); } catch (e) {}
}

function normalizePan(s) {
  const pan = String(s || '').replace(/[\s-]/g, '');
  if (!/^\d{12,19}$/.test(pan)) return null;
  // Luhn
  let sum = 0, dbl = false;
  for (let i = pan.length - 1; i >= 0; i--) {
    let d = pan.charCodeAt(i) - 48;
    if (dbl) { d *= 2; if (d > 9) d -= 9; }
    sum += d; dbl = !dbl;
  }
  return (sum % 10 === 0) ? pan : null;
}

function brandOf(pan) {
  if (/^4/.test(pan)) return 'Visa';
  if (/^(5[1-5]|22[2-9]|2[3-6]|27[01]|2720)/.test(pan)) return 'Mastercard';
  if (/^3[47]/.test(pan)) return 'Amex';
  if (/^(6011|65|64[4-9])/.test(pan)) return 'Discover';
  return 'Card';
}

function normalizeExpiry(s) {
  const m = String(s || '').trim().match(/^(\d{1,2})\s*[\/\-]?\s*(\d{2}|\d{4})$/);
  if (!m) return null;
  const mo = Number(m[1]);
  if (mo < 1 || mo > 12) return null;
  const yr = m[2].length === 4 ? m[2].slice(2) : m[2];
  return String(mo).padStart(2, '0') + '/' + yr;
}

async function setCard({ nickname, number, expiry, cvv, name }) {
  if (!keytar) throw new Error('keytar unavailable: ' + (keytarLoadError || 'not loaded'));
  const nick = String(nickname || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-');
  if (!nick) throw new Error('nickname required (e.g. "adom-amex")');
  const pan = normalizePan(number);
  if (!pan) throw new Error('card number failed validation (digits + Luhn); check for typos');
  const exp = normalizeExpiry(expiry);
  if (!exp) throw new Error('expiry must be MM/YY or MM/YYYY');
  const csc = String(cvv || '').trim();
  if (!/^\d{3,4}$/.test(csc)) throw new Error('cvv must be 3 or 4 digits');
  await keytar.setPassword(KEYTAR_SERVICE, nick, JSON.stringify({ pan, cvv: csc }));
  const ix = readIndex();
  ix.cards = (ix.cards || []).filter(c => c.nickname !== nick);
  ix.cards.push({ nickname: nick, brand: brandOf(pan), last4: pan.slice(-4), expiry: exp, name: String(name || '').trim() || null, updatedAt: new Date().toISOString() });
  writeIndex(ix);
  return { nickname: nick, brand: brandOf(pan), last4: pan.slice(-4), expiry: exp };
}

function listCards() {
  return (readIndex().cards || []).map(c => ({ nickname: c.nickname, brand: c.brand, last4: c.last4, expiry: c.expiry, name: c.name || null }));
}

async function deleteCard(nickname) {
  const nick = String(nickname || '').trim().toLowerCase();
  const ix = readIndex();
  const had = (ix.cards || []).some(c => c.nickname === nick);
  ix.cards = (ix.cards || []).filter(c => c.nickname !== nick);
  writeIndex(ix);
  if (keytar) { try { await keytar.deletePassword(KEYTAR_SERVICE, nick); } catch (e) {} }
  return { removed: had };
}

// INTERNAL ONLY — feeds the fill path; no verb exposes this.
async function getCard(nickname) {
  if (!keytar) throw new Error('keytar unavailable: ' + (keytarLoadError || 'not loaded'));
  const nick = String(nickname || '').trim().toLowerCase();
  const meta = (readIndex().cards || []).find(c => c.nickname === nick);
  if (!meta) return null;
  const raw = await keytar.getPassword(KEYTAR_SERVICE, nick);
  if (!raw) return null;
  const sec = JSON.parse(raw);
  return { ...meta, pan: sec.pan, cvv: sec.cvv };
}

// ── The fill ───────────────────────────────────────────────────────────
// Walks EVERY frame (checkout card fields usually live in payment-processor iframes — Stripe
// mounts one iframe per field) and types via puppeteer's CDP keyboard, so the events are
// trusted and land inside cross-origin frames where DOM injection cannot.
const FIELD_SELECTORS = {
  number: ['input[autocomplete="cc-number"]', 'input[name="cardnumber"]', 'input[name="cardNumber"]', 'input[id*="cardnumber" i]', 'input[name*="card-number" i]', 'input[id*="card-number" i]', 'input[placeholder*="card number" i]', 'input[name="number"][inputmode="numeric"]'],
  expiry: ['input[autocomplete="cc-exp"]', 'input[name="exp-date"]', 'input[name="expiry"]', 'input[id*="expir" i]', 'input[placeholder*="MM / YY" i]', 'input[placeholder*="MM/YY" i]'],
  cvv: ['input[autocomplete="cc-csc"]', 'input[name="cvc"]', 'input[name="cvv"]', 'input[id*="cvc" i]', 'input[id*="cvv" i]', 'input[name*="security" i]', 'input[placeholder*="CVC" i]', 'input[placeholder*="CVV" i]'],
  name: ['input[autocomplete="cc-name"]', 'input[name="ccname"]', 'input[id*="cardholder" i]', 'input[name*="cardholder" i]', 'input[placeholder*="name on card" i]'],
};

async function fillCardIntoPage(page, card) {
  const filled = { number: false, expiry: false, cvv: false, name: false };
  const values = { number: card.pan, expiry: card.expiry, cvv: card.cvv, name: card.name || '' };
  const frames = page.frames();
  for (const [field, selectors] of Object.entries(FIELD_SELECTORS)) {
    if (!values[field]) continue;
    for (const fr of frames) {
      if (filled[field]) break;
      for (const sel of selectors) {
        if (filled[field]) break;
        let el = null;
        try { el = await fr.$(sel); } catch (e) { el = null; }
        if (!el) continue;
        try {
          const visible = await el.evaluate(n => { const r = n.getBoundingClientRect(); return r.width > 2 && r.height > 2 && !n.disabled && !n.readOnly; }).catch(() => false);
          if (!visible) continue;
          await el.click({ clickCount: 3 });                     // focus + select any existing value
          await el.type(String(values[field]), { delay: 25 });   // trusted CDP keystrokes (processor JS needs them)
          filled[field] = true;
        } catch (e) { /* next selector/frame */ }
        try { await el.dispose(); } catch (e) {}
      }
    }
  }
  // Split expiry fields (cc-exp-month / cc-exp-year) when the combined field wasn't found.
  if (!filled.expiry) {
    const [mo, yr] = card.expiry.split('/');
    for (const fr of frames) {
      if (filled.expiry) break;
      try {
        const m = await fr.$('input[autocomplete="cc-exp-month"], select[autocomplete="cc-exp-month"], select[name*="exp" i][name*="month" i]');
        const y = await fr.$('input[autocomplete="cc-exp-year"], select[autocomplete="cc-exp-year"], select[name*="exp" i][name*="year" i]');
        if (m && y) {
          const setOne = async (el, val, longVal) => {
            const tag = await el.evaluate(n => n.tagName).catch(() => 'INPUT');
            if (tag === 'SELECT') { await el.select(val).catch(() => el.select(longVal)); }
            else { await el.click({ clickCount: 3 }); await el.type(val, { delay: 25 }); }
          };
          await setOne(m, mo, mo);
          await setOne(y, yr, '20' + yr);
          filled.expiry = true;
        }
      } catch (e) {}
    }
  }
  return { filled, framesScanned: frames.length };
}

module.exports = { setCard, listCards, deleteCard, getCard, fillCardIntoPage, normalizePan, brandOf, normalizeExpiry };