← back to Norma Sdcc Pitch

server.js

598 lines

#!/usr/bin/env node
// Inline .env loader (no dotenv dependency)
(() => {
  try {
    const f = require('fs').readFileSync(require('path').join(__dirname, '.env'), 'utf-8');
    f.split(/\r?\n/).forEach((line) => {
      const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
      if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
    });
  } catch { /* .env missing — that's fine in dev */ }
})();

/**
 * norma-sdcc-pitch — lecture-style viewer pitching SDCC on adopting Norma.
 *
 * Routes:
 *   GET  /                       — single-page lecture viewer
 *   GET  /api/features           — feature catalog (lecture + agents)
 *   GET  /api/pitch/:role        — load saved pitch state for role (steve|sdcc)
 *   POST /api/pitch/:role        — save pitch state for role
 *   POST /api/run-example        — proxy to Norma at :7400, run an agent example
 *   POST /api/build-video        — kick off Playwright recorder
 *   GET  /api/video-status       — poll for video completion
 *   GET  /videos/:filename       — serve generated MP4
 */
const express = require('express');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');

const PORT = parseInt(process.env.PORT || '9876', 10);
const ROOT = __dirname;
const PITCH_DIR = path.join(ROOT, 'data', 'pitch-state');
const VIDEO_DIR = path.join(ROOT, 'videos');
const NORMA_BASE = process.env.NORMA_BASE || 'http://localhost:7400';
// ── Login gate ─────────────────────────────────────────────────────────────
// Env-var plaintexts are scrypt-hashed at module load. The plaintexts are
// cleared from process.env immediately after hashing so they only exist in
// memory for the duration of the hash call. Login comparison uses scrypt +
// crypto.timingSafeEqual (constant-time) — no plaintext compare on any
// request path.
//
// In production, BOTH env vars must be set. Booting with hard-coded defaults
// under NODE_ENV=production is refused — silent fallback to known passwords
// is the worst kind of credential drift (the kind nobody notices until it's
// in a leak). In dev (NODE_ENV != production), defaults are allowed for
// convenience.
const SCRYPT_N = 16384;       // 2^14 — fast enough for login, slow enough vs brute force
const SCRYPT_KEYLEN = 64;
function deriveLoginHash(plaintext, salt) {
  return crypto.scryptSync(plaintext, salt, SCRYPT_KEYLEN, { N: SCRYPT_N });
}

const LOGIN_RECORDS = (() => {
  const out = {};
  const seed = {
    sdcc:  { env: 'SDCC_PASSWORD',  default: 'Pulse-2026'    },
    steve: { env: 'STEVE_PASSWORD', default: 'DWSecure2024!' },
  };
  const isProd = process.env.NODE_ENV === 'production';
  const missing = [];
  for (const [user, { env }] of Object.entries(seed)) {
    if (!process.env[env]) missing.push({ user, env });
  }
  if (isProd && missing.length > 0) {
    const lines = missing.map(({ user, env }) => `  - ${env}  (for "${user}" account)`);
    console.error(
      '\n[auth] REFUSING TO BOOT in NODE_ENV=production with missing login passwords:\n' +
      lines.join('\n') +
      '\n\nSet them in /root/Projects/norma-sdcc-pitch/.env (or whatever you use)\n' +
      'and restart. Falling back to hard-coded defaults in prod is not allowed.\n',
    );
    process.exit(2);
  }
  for (const [user, { env, default: dflt }] of Object.entries(seed)) {
    const pt = process.env[env] || dflt;
    if (!process.env[env]) {
      console.warn(`[auth] dev fallback: ${env} not set, using default for "${user}"`);
    }
    const salt = crypto.randomBytes(16);
    out[user] = { salt, hash: deriveLoginHash(pt, salt) };
    // Best-effort scrub: blank out the env var so the plaintext doesn't sit
    // in process.env for the life of the process.
    delete process.env[env];
  }
  return out;
})();

function verifyLogin(user, plaintext) {
  const rec = LOGIN_RECORDS[user];
  if (!rec) return false;
  const candidate = deriveLoginHash(String(plaintext), rec.salt);
  // Constant-time compare. Lengths are always equal by construction (both 64).
  return crypto.timingSafeEqual(candidate, rec.hash);
}

// ── Login brute-force protection ───────────────────────────────────────────
// Per-IP fixed-window rate limit on POST /login. 10 fails per 15 min → 30 min
// lockout. Counter clears on successful login. Trusts X-Forwarded-For since
// nginx is in front (pitch.sdcc.agentabrams.com → :9720).
const LOGIN_WINDOW_MS  = 15 * 60 * 1000;
const LOGIN_LOCKOUT_MS = 30 * 60 * 1000;
const LOGIN_MAX_FAILS  = 10;
const loginAttempts = new Map(); // ip → { count, firstFailAt, lockedUntil }

function loginClientIp(req) {
  const xff = req.headers['x-forwarded-for'];
  if (typeof xff === 'string' && xff.length > 0) return xff.split(',')[0].trim();
  return req.ip || req.connection?.remoteAddress || 'unknown';
}

function checkLoginAttempt(ip) {
  const now = Date.now();
  const rec = loginAttempts.get(ip);
  if (!rec) return { allowed: true, retryAfter: 0 };
  if (rec.lockedUntil && now < rec.lockedUntil) {
    return { allowed: false, retryAfter: Math.ceil((rec.lockedUntil - now) / 1000) };
  }
  // Window expired? reset
  if (now - rec.firstFailAt > LOGIN_WINDOW_MS) {
    loginAttempts.delete(ip);
    return { allowed: true, retryAfter: 0 };
  }
  return { allowed: true, retryAfter: 0 };
}

function recordLoginFailure(ip) {
  const now = Date.now();
  const rec = loginAttempts.get(ip) || { count: 0, firstFailAt: now };
  rec.count = (rec.count || 0) + 1;
  if (rec.count >= LOGIN_MAX_FAILS) {
    rec.lockedUntil = now + LOGIN_LOCKOUT_MS;
  }
  loginAttempts.set(ip, rec);
}

function clearLoginCounter(ip) {
  loginAttempts.delete(ip);
}

// Periodic cleanup so the Map doesn't grow forever
setInterval(() => {
  const now = Date.now();
  for (const [ip, rec] of loginAttempts) {
    const stale =
      (rec.lockedUntil && now > rec.lockedUntil) ||
      (!rec.lockedUntil && now - rec.firstFailAt > LOGIN_WINDOW_MS);
    if (stale) loginAttempts.delete(ip);
  }
}, 5 * 60 * 1000).unref();
const COOKIE_SECRET = process.env.COOKIE_SECRET || 'norma-sdcc-pitch-dev-secret-change-in-prod';
const COOKIE_NAME = 'sdcc-pitch-auth';
// crypto is required at the top of this file.

function sign(value) {
  return value + '.' + crypto.createHmac('sha256', COOKIE_SECRET).update(value).digest('hex');
}
function verify(signed) {
  if (!signed) return null;
  const idx = signed.lastIndexOf('.');
  if (idx === -1) return null;
  const value = signed.slice(0, idx);
  const sig = signed.slice(idx + 1);
  const expect = crypto.createHmac('sha256', COOKIE_SECRET).update(value).digest('hex');
  if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null;
  return value;
}
function parseCookies(req) {
  const out = {};
  const raw = req.headers.cookie || '';
  raw.split(';').forEach((c) => {
    const i = c.indexOf('=');
    if (i > -1) out[c.slice(0, i).trim()] = decodeURIComponent(c.slice(i + 1).trim());
  });
  return out;
}

fs.mkdirSync(PITCH_DIR, { recursive: true });
fs.mkdirSync(VIDEO_DIR, { recursive: true });

const app = express();
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true }));

// ── auth middleware ──
const PUBLIC_PATHS = new Set(['/login', '/logout', '/health']);
app.use((req, res, next) => {
  if (PUBLIC_PATHS.has(req.path) || req.path.startsWith('/login')) return next();
  const cookies = parseCookies(req);
  const who = verify(cookies[COOKIE_NAME]);
  if (!who) {
    if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'login required', login_url: '/login' });
    return res.redirect('/login?next=' + encodeURIComponent(req.originalUrl));
  }
  req.user = who;
  next();
});

// ── login UI ──
app.get('/login', (req, res) => {
  const next = req.query.next && /^\/[^/]/.test(req.query.next) ? req.query.next : '/';
  const err = req.query.err ? '<div class="lerr">Invalid username or password.</div>' : '';
  res.set('Content-Type', 'text/html').send(`<!doctype html><html><head><meta charset="utf-8">
<title>Norma × SDCC — Sign in</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root{--blue:#1b4d7a;--blue-deep:#0d2e4d;--amber:#f59e0b;--ink:#0e1729;--muted:#5d6b85;--bg:#fafafa;--surface:#fff;--border:#e1e6ef;}
*{box-sizing:border-box}html,body{margin:0;padding:0;font-family:ui-sans-serif,system-ui,-apple-system,'Inter',sans-serif;color:var(--ink);background:radial-gradient(900px 500px at 50% -10%,rgba(245,158,11,.12),transparent 60%),linear-gradient(180deg,#fff,var(--bg));min-height:100vh}
.wrap{max-width:420px;margin:80px auto;padding:0 24px}
.brand{display:flex;align-items:center;gap:10px;margin-bottom:24px;justify-content:center}
.mark{width:42px;height:42px;border-radius:11px;background:linear-gradient(135deg,var(--blue),var(--blue-deep));color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:15px}
.title{font-family:Georgia,serif;font-size:30px;color:var(--blue-deep);margin:0 0 8px;letter-spacing:-.5px;text-align:center}
.sub{color:var(--muted);text-align:center;margin:0 0 28px;font-size:15px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:28px;box-shadow:0 4px 20px rgba(15,29,53,.06)}
label{display:block;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin:0 0 6px;font-weight:600}
input{width:100%;padding:11px 13px;font-size:15px;border:1px solid var(--border);border-radius:8px;font-family:inherit;background:var(--bg)}
input:focus{outline:none;border-color:var(--blue);background:#fff}
.row{margin-bottom:18px}
button{width:100%;padding:12px;font-size:15px;font-weight:600;color:#fff;background:var(--blue);border:0;border-radius:8px;cursor:pointer;font-family:inherit}
button:hover{background:var(--blue-deep)}
.foot{color:var(--muted);font-size:12px;text-align:center;margin-top:20px;line-height:1.55}
.lerr{background:#fef2f2;border:1px solid #fecaca;border-left:3px solid #b91c1c;color:#7f1d1d;padding:10px 14px;border-radius:8px;font-size:13px;margin-bottom:16px}
</style></head><body>
<div class="wrap">
  <div class="brand"><div class="mark">N×</div><strong>Norma × SDCC</strong></div>
  <h1 class="title">Welcome to the pitch</h1>
  <p class="sub">Sign in with the credentials your contact at SDCC shared.</p>
  <div class="card">
    ${err}
    <form method="POST" action="/login">
      <input type="hidden" name="next" value="${next.replace(/"/g, '&quot;')}">
      <div class="row"><label for="u">Username</label><input id="u" name="username" autocomplete="username" required autofocus></div>
      <div class="row"><label for="p">Password</label><input id="p" name="password" type="password" autocomplete="current-password" required></div>
      <button type="submit">Sign in</button>
    </form>
  </div>
  <p class="foot">Trouble signing in? Contact your SDCC representative or steve@agentabrams.com.</p>
</div></body></html>`);
});

app.post('/login', (req, res) => {
  const ip = loginClientIp(req);
  const gate = checkLoginAttempt(ip);
  if (!gate.allowed) {
    res.set('Retry-After', String(gate.retryAfter));
    return res
      .status(429)
      .type('html')
      .send(
        `<!doctype html><meta charset="utf-8"><title>Too many attempts</title>` +
          `<style>body{font:14px/1.6 system-ui,sans-serif;max-width:520px;margin:96px auto;padding:0 16px;color:#1a1a1a}` +
          `h1{font-size:22px;margin-bottom:8px}.r{color:#666;font-variant-numeric:tabular-nums}</style>` +
          `<h1>Too many login attempts.</h1>` +
          `<p>This IP is temporarily locked. Try again in <span class="r">` +
          `${Math.ceil(gate.retryAfter / 60)} min</span> (~${gate.retryAfter}s).</p>` +
          `<p><a href="/login">Back</a></p>`,
      );
  }

  const { username = '', password = '', next = '/' } = req.body || {};
  const u = String(username).toLowerCase().trim();
  if (verifyLogin(u, password)) {
    clearLoginCounter(ip);
    const value = u + '|' + Date.now();
    res.set('Set-Cookie', `${COOKIE_NAME}=${encodeURIComponent(sign(value))}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 86400}`);
    return res.redirect(/^\/[^/]/.test(next) ? next : '/');
  }
  recordLoginFailure(ip);
  res.redirect('/login?err=1&next=' + encodeURIComponent(next));
});

app.get('/logout', (_req, res) => {
  res.set('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; Max-Age=0`);
  res.redirect('/login');
});

app.use(express.static(path.join(ROOT, 'public')));

// ── feature catalog ────────────────────────────────────────────────────────
app.get('/api/features', (_req, res) => {
  const file = path.join(ROOT, 'data', 'features.json');
  const raw = fs.readFileSync(file, 'utf-8');
  res.json(JSON.parse(raw));
});

// ── pitch state per role ───────────────────────────────────────────────────
function pitchPath(role) {
  const safe = role.replace(/[^a-z0-9_-]/gi, '').toLowerCase() || 'default';
  return path.join(PITCH_DIR, `${safe}.json`);
}

app.get('/api/pitch/:role', (req, res) => {
  const file = pitchPath(req.params.role);
  if (fs.existsSync(file)) {
    res.json(JSON.parse(fs.readFileSync(file, 'utf-8')));
  } else {
    res.json({ role: req.params.role, included: [], skipped: [], notes: {}, updated_at: null });
  }
});

app.post('/api/pitch/:role', (req, res) => {
  const file = pitchPath(req.params.role);
  const body = req.body || {};
  const state = {
    role: req.params.role,
    included: Array.isArray(body.included) ? body.included : [],
    skipped: Array.isArray(body.skipped) ? body.skipped : [],
    notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
    updated_at: new Date().toISOString(),
  };
  fs.writeFileSync(file, JSON.stringify(state, null, 2));
  res.json({ saved: true, state });
});

// ── canned mock responses for when Norma isn't reachable (pitch demo mode) ──
const MOCK_RESPONSES = {
  '/api/petitions/topics': () => ({
    topics: [
      { id: '3d49b69c-cd54-4085-8ee4-4f17faba9608', title: 'Federal Loan Forgiveness', subtitle: 'critical priority', category: 'policy', urgency: 'critical', source: 'headline', description: 'Updates on federal student loan forgiveness programs', date: new Date().toISOString() },
      { id: '619b8f6c-92da-4f1c-b26f-8e6b7e0bd522', title: 'Racial Debt Disparities', subtitle: 'high priority', category: 'equity', urgency: 'high', source: 'headline', description: 'Black borrowers carry 30% more debt 12 years after graduation.', date: new Date().toISOString() },
      { id: 'a7e1c3f0-3220-49a3-9b6e-92c8d6e5a0e1', title: 'SAVE Plan Litigation Update', subtitle: 'breaking', category: 'policy', urgency: 'critical', source: 'headline', description: '8th Circuit ruling impacts 8M borrowers in SAVE plan.', date: new Date().toISOString() },
      { id: 'b4d3a1c8-1f9e-4b22-9a05-7e8d6f9c3a21', title: 'PSLF Processing Backlog', subtitle: 'urgent', category: 'operations', urgency: 'high', source: 'headline', description: '47,000 PSLF applications stuck in MOHELA queue.', date: new Date().toISOString() },
      { id: 'c8f2e7b9-2a3d-4c5f-8d6e-1b9a8c7d6e5f', title: 'Parent PLUS Reform Bill', subtitle: 'medium priority', category: 'legislation', urgency: 'medium', source: 'headline', description: 'New bipartisan bill caps Parent PLUS interest at 5%.', date: new Date().toISOString() },
    ],
  }),
  '/api/agents/advocacy/generate': () => ({
    statement: "The Department of Education's announcement today that 1 million borrowers have been approved for Public Service Loan Forgiveness is a significant step forward — and a long-overdue one. Public servants — teachers, nurses, social workers, firefighters — have spent a decade waiting for the relief they were promised. We applaud this milestone, AND we will not stop pressing until every eligible borrower receives their forgiveness without delay. The processing backlog must be cleared. The communication from MOHELA must improve. And Congress must finally make permanent the reforms that allowed this progress. — Natalia Abrams, Executive Director, Student Debt Crisis Center",
    citations: [
      { source: 'ED press release', url: 'https://ed.gov/press-releases/2026-05-20-pslf-discharge', date: '2026-05-20' },
      { source: 'SDCC 2024 PSLF position', url: 'https://studentdebtcrisis.org/positions/pslf', date: '2024-08-15' },
    ],
    confidence: 0.87,
    generated_at: new Date().toISOString(),
    review_required: true,
    voice_match_score: 0.92,
  }),
  '/api/leads/discover': () => ({
    stories: [
      { id: 'bs-2026-1834', borrower_name_anonymized: 'Sarah K.', state: 'Texas', debt_type: 'federal', program: 'pslf', urgency: 'medium', press_ready: true, story_summary: 'Public school teacher 11 years, paid every month, denied PSLF 3x for paperwork errors. Now approved as of May 20.' },
      { id: 'bs-2026-1801', borrower_name_anonymized: 'James M.', state: 'Texas', debt_type: 'federal', program: 'pslf', urgency: 'high', press_ready: true, story_summary: 'Nonprofit director, recent PSLF approval after 12 years.' },
      { id: 'bs-2026-1745', borrower_name_anonymized: 'Maria T.', state: 'Texas', debt_type: 'federal', program: 'pslf', urgency: 'medium', press_ready: false, story_summary: 'Public health nurse — consented for press but anonymously only.' },
    ],
    total_matching: 3,
    query_time_ms: 47,
  }),
  '/api/ai-chat': () => ({
    response: '"This milestone is welcome but overdue — public servants should never have to wait 12 years for the forgiveness they were promised. SDCC will keep pushing until every eligible borrower gets relief without the bureaucratic gauntlet."',
    cited_sources: [
      { type: 'prior_statement', date: '2024-11-12', excerpt: '"public servants should never have to wait for the relief they were promised"' },
      { type: 'prior_statement', date: '2025-03-08', excerpt: '"the bureaucratic gauntlet that turns 10-year promises into 12-year waits"' },
    ],
    confidence: 0.89,
  }),
  '/api/contacts': () => ({
    contacts: [
      { id: 'c-1', name: 'Danielle Douglas-Gabriel', org: 'Washington Post', beat: 'higher education', last_contact: '2026-04-12', notes: 'Wrote 3 stories citing SDCC in 2025.' },
      { id: 'c-2', name: 'Michael Stratford', org: 'POLITICO', beat: 'education policy', last_contact: '2026-05-14', notes: 'Currently working on a PSLF processing piece.' },
      { id: 'c-3', name: 'Cory Turner', org: 'NPR', beat: 'education', last_contact: '2026-03-22', notes: 'Used 2 SDCC borrower stories in March segment.' },
      { id: 'c-4', name: 'Anya Kamenetz', org: 'freelance', beat: 'education', last_contact: '2026-02-18', notes: 'Substack newsletter on student debt.' },
      { id: 'c-5', name: 'Adam Harris', org: 'The Atlantic', beat: 'higher education', last_contact: '2026-01-30', notes: 'Long-form, slow turnaround.' },
      { id: 'c-6', name: 'Stacy Cowley', org: 'NY Times', beat: 'consumer finance / debt', last_contact: '2026-04-02', notes: '' },
      { id: 'c-7', name: 'Emily Wilkins', org: 'CNBC', beat: 'Washington/policy', last_contact: '2026-05-05', notes: '' },
      { id: 'c-8', name: 'Kate Magill', org: 'Higher Ed Dive', beat: 'higher education', last_contact: '2026-03-18', notes: '' },
      { id: 'c-9', name: 'Andrew Kreighbaum', org: 'Bloomberg', beat: 'higher ed / policy', last_contact: '2026-02-22', notes: '' },
      { id: 'c-10', name: 'Hannah Knowles', org: 'Washington Post', beat: 'policy', last_contact: '2026-04-29', notes: '' },
    ],
    total: 10,
  }),
  '/api/agents/petition/generate': () => ({
    title: 'Block the Rollback of SAVE Plan Protections',
    body: "Millions of student-loan borrowers in the SAVE plan are facing an unprecedented threat. The Administration's proposal to roll back income-driven protections would push 8 million borrowers into payments they cannot afford — overnight.\n\nWe've been here before. We won SAVE because borrowers, advocates, and policymakers built coalition pressure that the previous administration couldn't ignore. We will do it again.\n\nThis petition demands three things: (1) keep the SAVE plan's $0 monthly payment for borrowers earning under $32,800; (2) prevent retroactive interest accrual for the millions in administrative forbearance; (3) hold servicers accountable for accurate payment counts.\n\nSign now. Share widely. Call your senators. The deadline for public comment is in 30 days.",
    share_text: { x: 'The Admin wants to roll back SAVE plan protections — pushing 8M borrowers into payments they can\'t afford. We won SAVE once. We can do it again. Sign now → [link]', bluesky: 'Block the SAVE rollback. 8M borrowers depend on it. Sign + share.', threads: 'SAVE plan rollback = 8M borrowers in crisis. Sign the petition. We won this fight once. Time to win it again.' },
    follow_up_sequence: ['Day 1 — thank you + share ask', 'Day 3 — story from a SAVE borrower', 'Day 7 — call-your-senator scripts', 'Day 14 — coalition partners + escalation'],
    hero_image_prompt: 'Photorealistic borrower mid-30s at kitchen table, paperwork spread out, soft natural light, looking determined toward camera. SDCC brand royal blue accent.',
    confidence: 0.91,
  }),
  '/api/agents/grants/match': () => ({
    matches: [
      { funder: 'Lumina Foundation', program: 'Equitable Pathways Initiative', deadline: '2026-07-15', range: '$100,000-$250,000', fit_score: 0.93, why: 'SDCC mission aligns with Lumina\'s equitable-completion focus + prior funding to similar advocacy orgs.' },
      { funder: 'Open Society Foundations', program: 'Economic Justice', deadline: '2026-09-30', range: '$50,000-$500,000', fit_score: 0.88, why: 'OSF funded SDCC in 2022; renewal cycle approaches.' },
      { funder: 'JEHT Foundation Successor (Funders Collaborative)', program: 'Borrower Defense Advocacy', deadline: '2026-06-30', range: '$75,000-$150,000', fit_score: 0.84, why: 'Direct match — borrower defense is SDCC\'s wheelhouse.' },
      { funder: 'Sloan Foundation', program: 'Higher Ed Public Policy', deadline: '2026-08-12', range: '$50,000-$200,000', fit_score: 0.71, why: 'Lighter fit — Sloan prefers data/research-heavy proposals; SDCC could partner with TICAS.' },
    ],
    total_scanned: 12847,
    matching_count: 4,
    last_updated: new Date().toISOString(),
  }),
  '/api/agents/congress-by-zip': () => ({
    zip: '90404',
    state: 'CA',
    district: 'CA-36',
    senators: [
      { name: 'Alex Padilla', party: 'D', phone: '202-224-3553', email: 'senator@padilla.senate.gov', committees: ['Judiciary', 'HSGAC'], recent_debt_votes: { 'SAVE-protections': 'support', 'PSLF-reform': 'support' } },
      { name: 'Adam Schiff', party: 'D', phone: '202-224-3841', email: 'senator@schiff.senate.gov', committees: ['Judiciary', 'Intelligence'], recent_debt_votes: { 'SAVE-protections': 'support', 'PSLF-reform': 'support' } },
    ],
    house: { name: 'Ted Lieu', party: 'D', district: 'CA-36', phone: '202-225-3976', email: 'rep@lieu.house.gov', committees: ['Judiciary', 'Foreign Affairs'], recent_debt_votes: { 'SAVE-protections': 'support', 'PSLF-reform': 'support' } },
    state_legislature: { state_senator: 'Ben Allen (D-SD-24)', state_assembly: 'Rick Chavez Zbur (D-AD-51)' },
  }),
};

function maybeMockResponse(route, method) {
  const path = route.split('?')[0];
  if (MOCK_RESPONSES[path]) return MOCK_RESPONSES[path]();
  return null;
}

// ── proxy: run an agent example against the live Norma at :7400, or canned mock if Norma unreachable ──
app.post('/api/run-example', async (req, res) => {
  const { route, payload, method } = req.body || {};
  if (!route || typeof route !== 'string' || !route.startsWith('/api/')) {
    return res.status(400).json({ error: 'route must be a /api/* path on Norma' });
  }
  const m = (method || (payload ? 'POST' : 'GET')).toUpperCase();

  // If a canned mock exists AND Norma is set to "demo" mode, short-circuit straight to mock.
  // Otherwise try to reach Norma first, fall back to mock on any failure.
  const tryLive = NORMA_BASE && NORMA_BASE !== 'demo';

  // helper to send mock
  async function sendMock() {
    const mock = maybeMockResponse(route, m);
    if (mock) {
      await new Promise((r) => setTimeout(r, 250 + Math.random() * 400));
      return res.json({ ok: true, status: 200, ms: 320 + Math.floor(Math.random() * 200), body: mock, source: 'demo-mock', _note: 'Live Norma backend not deployed on this host — showing realistic demo data with production response shape.' });
    }
    return res.status(502).json({ ok: false, status: 502, ms: 0, body: { error: 'no mock + Norma unreachable for ' + route }, source: 'error' });
  }

  if (!tryLive) return sendMock();

  // Light cookie cache for live mode
  const cookieFile = path.join(PITCH_DIR, '.norma-cookie');
  let cookie = null;
  if (fs.existsSync(cookieFile)) {
    const stat = fs.statSync(cookieFile);
    if (Date.now() - stat.mtimeMs < 60 * 60 * 1000) {
      cookie = fs.readFileSync(cookieFile, 'utf-8').trim();
    }
  }
  if (!cookie) {
    try {
      const lr = await fetch(`${NORMA_BASE}/api/auth/login`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username: 'admin', password: process.env.NORMA_ADMIN_PASSWORD || 'DWSecure2024!' }),
        signal: AbortSignal.timeout(2500),
      });
      if (lr.ok) {
        const setCookie = lr.headers.get('set-cookie');
        if (setCookie) {
          cookie = setCookie.split(';')[0];
          fs.writeFileSync(cookieFile, cookie);
        }
      }
    } catch {
      return sendMock();
    }
  }

  try {
    const url = NORMA_BASE + route;
    const headers = { 'Content-Type': 'application/json' };
    if (cookie) headers.Cookie = cookie;
    const init = { method: m, headers, signal: AbortSignal.timeout(3000) };
    if (m !== 'GET' && payload) init.body = JSON.stringify(payload);
    const t0 = Date.now();
    const r = await fetch(url, init);
    const ms = Date.now() - t0;
    const text = await r.text();
    let body;
    try { body = JSON.parse(text); } catch { body = { _raw: text.slice(0, 4000) }; }
    res.json({ ok: r.ok, status: r.status, ms, body, source: 'live' });
  } catch (err) {
    // live failed — fall back to mock
    return sendMock();
  }
});

// ── video generation ───────────────────────────────────────────────────────
const VIDEO_JOBS = new Map();

app.post('/api/build-video', (req, res) => {
  const { role = 'steve' } = req.body || {};
  const file = pitchPath(role);
  if (!fs.existsSync(file)) {
    return res.status(400).json({ error: `no saved pitch for role=${role}` });
  }
  const state = JSON.parse(fs.readFileSync(file, 'utf-8'));
  if (!state.included || state.included.length === 0) {
    return res.status(400).json({ error: 'pitch is empty — add at least one feature' });
  }

  const jobId = Date.now().toString(36);
  const outFile = path.join(VIDEO_DIR, `pitch-${role}-${jobId}.mp4`);
  const logFile = path.join(VIDEO_DIR, `pitch-${role}-${jobId}.log`);

  VIDEO_JOBS.set(jobId, { status: 'starting', role, outFile, logFile, started_at: Date.now() });

  const log = fs.openSync(logFile, 'w');
  const env = { ...process.env, NORMA_BASE, PITCH_ROLE: role, OUT_FILE: outFile, JOB_ID: jobId };
  const child = spawn('node', [path.join(ROOT, 'scripts', 'record-demo.js')], {
    cwd: ROOT, env, stdio: ['ignore', log, log], detached: true,
  });
  child.unref();

  VIDEO_JOBS.get(jobId).pid = child.pid;
  VIDEO_JOBS.get(jobId).status = 'recording';

  res.json({ job_id: jobId, status: 'recording', output: path.basename(outFile), log: path.basename(logFile) });
});

app.get('/api/video-status', (req, res) => {
  const jobId = req.query.id;
  if (!jobId) {
    res.json({ jobs: Array.from(VIDEO_JOBS.entries()).map(([id, j]) => ({ id, status: j.status, output: path.basename(j.outFile), started_at: j.started_at })) });
    return;
  }
  const job = VIDEO_JOBS.get(jobId);
  if (!job) return res.status(404).json({ error: 'job not found' });
  const exists = fs.existsSync(job.outFile);
  const size = exists ? fs.statSync(job.outFile).size : 0;
  const stillRunning = (() => {
    try { process.kill(job.pid, 0); return true; } catch { return false; }
  })();
  let status = job.status;
  if (exists && size > 10000 && !stillRunning) status = 'done';
  else if (!stillRunning && job.status === 'recording') status = 'failed';
  job.status = status;
  res.json({
    id: jobId, status, output: path.basename(job.outFile),
    size_bytes: size, started_at: job.started_at,
    log_tail: fs.existsSync(job.logFile) ? fs.readFileSync(job.logFile, 'utf-8').split('\n').slice(-20).join('\n') : '',
    download_url: status === 'done' ? `/videos/${path.basename(job.outFile)}` : null,
  });
});

app.get('/videos/:filename', (req, res) => {
  const file = path.join(VIDEO_DIR, req.params.filename);
  if (!fs.existsSync(file)) return res.status(404).send('not found');
  res.sendFile(file);
});

// ── token vault ──────────────────────────────────────────────────────────
const VAULT_FILE = path.join(PITCH_DIR, 'token-vault.json');
function readVault() {
  try { return JSON.parse(fs.readFileSync(VAULT_FILE, 'utf-8')); } catch { return {}; }
}
function writeVault(obj) {
  fs.writeFileSync(VAULT_FILE, JSON.stringify(obj, null, 2), { mode: 0o600 });
}
function last4(v) {
  if (!v || typeof v !== 'string') return null;
  return v.length > 6 ? '…' + v.slice(-4) : '…' + v;
}

app.get('/api/vault', (_req, res) => {
  const vault = readVault();
  const masked = {};
  for (const [k, entry] of Object.entries(vault)) {
    masked[k] = { last4: last4(entry.value), set_at: entry.set_at, set_by: entry.set_by };
  }
  res.json({ vault: masked });
});

app.post('/api/vault', (req, res) => {
  const updates = req.body && typeof req.body === 'object' ? req.body : {};
  const vault = readVault();
  let saved = 0;
  for (const [k, v] of Object.entries(updates)) {
    if (typeof v !== 'string') continue;
    if (!/^[A-Z][A-Z0-9_]+$/.test(k)) continue;
    const trimmed = v.trim();
    if (!trimmed) { delete vault[k]; continue; }
    vault[k] = { value: trimmed, set_at: new Date().toISOString(), set_by: req.user || 'unknown' };
    saved++;
  }
  writeVault(vault);
  res.json({ saved, total_keys: Object.keys(vault).length });
});

app.delete('/api/vault/:key', (req, res) => {
  const vault = readVault();
  if (vault[req.params.key]) {
    delete vault[req.params.key];
    writeVault(vault);
    return res.json({ deleted: true });
  }
  res.status(404).json({ error: 'not found' });
});

app.get('/health', (_req, res) => res.json({ ok: true, name: 'norma-sdcc-pitch', port: PORT, norma_base: NORMA_BASE }));

app.listen(PORT, () => {
  console.log(`norma-sdcc-pitch listening on http://localhost:${PORT}`);
  console.log(`  proxying agent demos to ${NORMA_BASE}`);
});