← back to Secrets Manager

viewer.js

399 lines

#!/usr/bin/env node
/**
 * Local-only web viewer for the secrets registry.
 *
 * Lists every secret with its digest, label, validation status, and the
 * destinations cli.js wrote it to. A "Reveal" button per row makes an
 * authenticated POST to read the raw value out of the master .env — that
 * action requires a same-origin POST + the X-Confirm-Reveal header, so a
 * stray GET / scroll never leaks anything.
 *
 * Hard security guards:
 *   - listen() binds to 127.0.0.1 only (never 0.0.0.0)
 *   - every handler checks req.socket.remoteAddress ∈ {127.0.0.1, ::1}
 *   - /api/reveal returns the value as JSON (so the browser tab is the only
 *     place it appears; nothing logged server-side)
 *   - reveal logs an entry to viewer-reveal.log with timestamp + key name
 *     (NOT the value) so Steve can audit which keys he viewed and when
 *
 * Zero deps — pure Node http module.
 *
 * Usage:
 *   node viewer.js
 *   open http://127.0.0.1:9698/
 */

'use strict';

const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');

const PORT = parseInt(process.env.SECRETS_VIEWER_PORT, 10) || 9698;
const ROOT = __dirname;
const REGISTRY = path.join(ROOT, 'registry.json');
const MASTER_ENV = path.join(ROOT, '.env');
const REVEAL_LOG = path.join(ROOT, 'viewer-reveal.log');
const ROTATION_DOC = path.join(ROOT, 'ROTATION-CHECKLIST.md');

function isLoopback(req) {
  const addr = req.socket && req.socket.remoteAddress;
  return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
}

function readRegistry() {
  try { return JSON.parse(fs.readFileSync(REGISTRY, 'utf8')); } catch { return { secrets: {} }; }
}

function parseEnv(file) {
  const out = {};
  let raw;
  try { raw = fs.readFileSync(file, 'utf8'); } catch { return out; }
  for (const line of raw.split(/\r?\n/)) {
    if (!line || line.startsWith('#')) continue;
    const eq = line.indexOf('=');
    if (eq < 1) continue;
    const k = line.slice(0, eq).trim();
    let v = line.slice(eq + 1);
    // Strip surrounding single or double quotes if perfectly balanced.
    if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
      v = v.slice(1, -1);
    }
    out[k] = v;
  }
  return out;
}

function htmlEsc(s) {
  return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
  }[c]));
}

function renderPage() {
  const reg = readRegistry();
  const rows = Object.entries(reg.secrets || {})
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([key, meta]) => {
      const destCount = (meta.written_to || []).length;
      const dests = (meta.written_to || []).map(htmlEsc).join('<br>');
      const validatedPill = meta.validated
        ? `<span class="pill pill-ok">verified · ${htmlEsc(meta.verify_status || '')}</span>`
        : (meta.verify_status === null || meta.verify_status === undefined
            ? `<span class="pill pill-unk">unverified</span>`
            : `<span class="pill pill-bad">failed · ${htmlEsc(meta.verify_status)}</span>`);
      return `
        <tr data-key="${htmlEsc(key)}">
          <td class="k">${htmlEsc(key)}</td>
          <td class="lbl">${htmlEsc(meta.label || '')}</td>
          <td class="dig"><code>${htmlEsc(meta.digest || '—')}</code></td>
          <td class="ver">${validatedPill}</td>
          <td class="dest" title="${htmlEsc((meta.written_to || []).join('\n'))}">${destCount} file${destCount === 1 ? '' : 's'}</td>
          <td class="upd">${htmlEsc((meta.last_updated || '').slice(0, 10))}</td>
          <td class="act">
            <button type="button" class="btn-reveal" data-key="${htmlEsc(key)}">Reveal</button>
            <button type="button" class="btn-copy" data-key="${htmlEsc(key)}" style="display:none">Copy</button>
            <button type="button" class="btn-hide" data-key="${htmlEsc(key)}" style="display:none">Hide</button>
            <div class="value-cell" style="display:none;margin-top:6px"><code class="val"></code></div>
            <div class="dest-list" style="display:none;margin-top:6px">${dests}</div>
            <button type="button" class="btn-dests" data-key="${htmlEsc(key)}">where?</button>
          </td>
        </tr>`;
    }).join('');

  return `<!doctype html><html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Secrets Viewer — localhost</title>
<style>
  :root { --ink:#0f0e0c; --soft:#555; --faint:#999; --line:#ddd; --bg:#faf8f3; --card:#fff;
          --ok:#14532d; --okBg:#d1fae5; --bad:#7f1d1d; --badBg:#fee2e2; --unk:#374151; --unkBg:#e5e7eb; }
  * { box-sizing: border-box; }
  body { font:14px/1.5 -apple-system, system-ui, sans-serif; color:var(--ink); background:var(--bg); margin:0; padding:24px 28px; }
  h1 { font:300 28px/1.1 Georgia, serif; margin:0 0 6px; }
  .sub { color:var(--soft); margin:0 0 22px; font-size:13px }
  .sub code { background:#e7e3d8; padding:2px 6px; border-radius:3px; font-size:12px }
  .toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:14px }
  .toolbar input[type=search] { flex:1; min-width:240px; padding:8px 12px; border:1px solid var(--line); border-radius:6px; font:14px var(--sans); background:var(--card) }
  .toolbar select { padding:8px 10px; border:1px solid var(--line); border-radius:6px; background:var(--card); font:13px var(--sans) }
  table { width:100%; border-collapse:collapse; background:var(--card); border:1px solid var(--line); border-radius:8px; overflow:hidden }
  th { text-align:left; font:11px/1 var(--sans); text-transform:uppercase; letter-spacing:.08em; color:var(--faint); padding:11px 12px; background:#f3eee2; border-bottom:1px solid var(--line) }
  td { padding:10px 12px; border-top:1px solid #ece8dd; vertical-align:top; font-size:13px }
  td.k { font:13px ui-monospace, Menlo, monospace; font-weight:600 }
  td.lbl { color:var(--soft) }
  td.dig code { font:12px ui-monospace, Menlo, monospace; color:var(--soft) }
  td.upd { color:var(--faint); font-size:12px; white-space:nowrap }
  td.act { white-space:nowrap }
  td.act button { font:12px var(--sans); padding:5px 11px; border:1px solid var(--line); background:#f3eee2; border-radius:4px; cursor:pointer; margin-right:4px }
  td.act button:hover { background:#e7e3d8 }
  td.act .btn-reveal { background:var(--ink); color:#fff; border-color:var(--ink) }
  td.act .btn-copy { background:#1e40af; color:#fff; border-color:#1e40af }
  td.act .btn-hide { background:#7f1d1d; color:#fff; border-color:#7f1d1d }
  td.act .btn-dests { background:transparent; color:var(--soft); border-color:#e7e3d8; font-size:11px }
  .pill { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; font-weight:600 }
  .pill-ok { background:var(--okBg); color:var(--ok) }
  .pill-bad { background:var(--badBg); color:var(--bad) }
  .pill-unk { background:var(--unkBg); color:var(--unk) }
  .value-cell { background:#fffbeb; border:1px solid #fcd34d; padding:7px 9px; border-radius:4px; max-width:480px; word-break:break-all }
  .value-cell code { font:12px ui-monospace, Menlo, monospace; color:var(--ink) }
  .dest-list { background:#f3eee2; border:1px solid var(--line); padding:7px 10px; border-radius:4px; font:11px ui-monospace, Menlo, monospace; color:var(--soft); max-width:520px }
  .stats { color:var(--soft); font-size:12px; margin-bottom:8px }
  .hide { display:none !important }
  footer { color:var(--faint); font-size:11px; margin-top:18px }
</style>
</head><body>
  <h1>Secrets Viewer</h1>
  <p class="sub">Local-only · binds to <code>127.0.0.1:${PORT}</code> · reads <code>${htmlEsc(path.basename(REGISTRY))}</code> · revealed values are logged to <code>${htmlEsc(path.basename(REVEAL_LOG))}</code></p>
  <p class="sub">🔄 <a href="/rotation" style="color:#7f1d1d;font-weight:600">Items we need to rotate →</a></p>

  <div class="toolbar">
    <input type="search" id="q" placeholder="Filter by key or label (e.g. eleven, godaddy, shopify)" autocomplete="off">
    <select id="filter">
      <option value="all">All secrets</option>
      <option value="ok">Verified only</option>
      <option value="bad">Failed verification</option>
      <option value="unk">Unverified</option>
    </select>
    <span class="stats" id="stats"></span>
  </div>

  <table>
    <thead>
      <tr>
        <th>Key</th><th>Label</th><th>Digest</th><th>Status</th><th>Destinations</th><th>Updated</th><th>Action</th>
      </tr>
    </thead>
    <tbody id="rows">${rows}</tbody>
  </table>

  <footer>Loopback-only. Every reveal click is logged to <code>${htmlEsc(REVEAL_LOG)}</code> with timestamp + key name (never the value).</footer>

<script>
(function(){
  const q = document.getElementById('q');
  const filter = document.getElementById('filter');
  const stats = document.getElementById('stats');
  const rows = Array.from(document.querySelectorAll('#rows tr'));

  function refilter(){
    const needle = (q.value || '').toLowerCase();
    const mode = filter.value;
    let visible = 0;
    rows.forEach(r => {
      const k = (r.querySelector('.k')?.textContent || '').toLowerCase();
      const lbl = (r.querySelector('.lbl')?.textContent || '').toLowerCase();
      const matches = !needle || k.includes(needle) || lbl.includes(needle);
      let modeOk = true;
      if (mode === 'ok')  modeOk = !!r.querySelector('.pill-ok');
      if (mode === 'bad') modeOk = !!r.querySelector('.pill-bad');
      if (mode === 'unk') modeOk = !!r.querySelector('.pill-unk');
      const show = matches && modeOk;
      r.classList.toggle('hide', !show);
      if (show) visible++;
    });
    stats.textContent = visible + ' of ' + rows.length + ' secrets';
  }
  q.addEventListener('input', refilter);
  filter.addEventListener('change', refilter);
  refilter();

  document.body.addEventListener('click', async function(e){
    const t = e.target;
    if (!(t instanceof HTMLButtonElement)) return;
    const row = t.closest('tr'); if (!row) return;
    const key = t.dataset.key;
    const reveal = row.querySelector('.btn-reveal');
    const copy = row.querySelector('.btn-copy');
    const hide = row.querySelector('.btn-hide');
    const valCell = row.querySelector('.value-cell');
    const valEl = row.querySelector('.val');
    const destList = row.querySelector('.dest-list');
    const destsBtn = row.querySelector('.btn-dests');

    if (t.classList.contains('btn-reveal')) {
      try {
        const r = await fetch('/api/reveal/' + encodeURIComponent(key), {
          method: 'POST',
          headers: { 'X-Confirm-Reveal': 'yes', 'Content-Type': 'application/json' },
          body: JSON.stringify({})
        });
        if (!r.ok) { alert('Reveal failed: HTTP ' + r.status); return; }
        const j = await r.json();
        valEl.textContent = j.value || '(not found in master .env)';
        valCell.style.display = 'block';
        reveal.style.display = 'none';
        copy.style.display = 'inline-block';
        hide.style.display = 'inline-block';
      } catch (err) { alert('Reveal error: ' + err.message); }
    }
    if (t.classList.contains('btn-copy')) {
      try { await navigator.clipboard.writeText(valEl.textContent); t.textContent = 'Copied ✓'; setTimeout(() => { t.textContent = 'Copy'; }, 1400); } catch (e) { alert('Clipboard error: ' + e.message); }
    }
    if (t.classList.contains('btn-hide')) {
      valEl.textContent = '';
      valCell.style.display = 'none';
      reveal.style.display = 'inline-block';
      copy.style.display = 'none';
      hide.style.display = 'none';
    }
    if (t.classList.contains('btn-dests')) {
      const open = destList.style.display !== 'none';
      destList.style.display = open ? 'none' : 'block';
      destsBtn.textContent = open ? 'where?' : 'hide';
    }
  });
})();
</script>
</body></html>`;
}

// ── Rotation view ────────────────────────────────────────────────────────────
// Renders ROTATION-CHECKLIST.md (the authoritative "items we need to rotate"
// list) as status cards. Read-only; the doc holds NO secret values (key names +
// mint URLs + route-back commands only), so this page is safe to display.
function mdInlineEsc(s) {
  // input is raw markdown; escape HTML first, then apply a few inline rules.
  let h = htmlEsc(s);
  // protect inline code spans
  const codes = [];
  h = h.replace(/`([^`]+)`/g, (_, c) => { codes.push(c); return '' + (codes.length - 1) + ''; });
  h = h.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
  h = h.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
  // bare URLs (not already inside an anchor)
  h = h.replace(/(^|[\s(])(https?:\/\/[^\s)<]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
  h = h.replace(/(\d+)/g, (_, i) => '<code>' + codes[+i] + '</code>');
  return h;
}
function mdBlock(lines) {
  const out = [];
  let i = 0, inList = false;
  const closeList = () => { if (inList) { out.push('</ul>'); inList = false; } };
  while (i < lines.length) {
    const ln = lines[i];
    if (/^```/.test(ln)) {                       // fenced code block
      closeList();
      const buf = []; i++;
      while (i < lines.length && !/^```/.test(lines[i])) { buf.push(htmlEsc(lines[i])); i++; }
      i++; // skip closing fence
      out.push('<pre><code>' + buf.join('\n') + '</code></pre>');
      continue;
    }
    if (/^---\s*$/.test(ln)) { closeList(); i++; continue; }
    const li = ln.match(/^\s*-\s+(.*)$/);
    if (li) { if (!inList) { out.push('<ul>'); inList = true; } out.push('<li>' + mdInlineEsc(li[1]) + '</li>'); i++; continue; }
    if (!ln.trim()) { closeList(); i++; continue; }
    closeList();
    out.push('<p>' + mdInlineEsc(ln) + '</p>');
    i++;
  }
  closeList();
  return out.join('\n');
}
function renderRotation() {
  let raw;
  try { raw = fs.readFileSync(ROTATION_DOC, 'utf8'); }
  catch { return '<!doctype html><meta charset=utf-8><body style="font:14px system-ui;padding:24px">ROTATION-CHECKLIST.md not found.</body>'; }
  const lines = raw.split(/\r?\n/);
  const secs = []; let cur = { head: null, body: [] };
  for (const ln of lines) {
    const m = ln.match(/^##\s+(.*)$/);
    if (m) { secs.push(cur); cur = { head: m[1].trim(), body: [] }; }
    else cur.body.push(ln);
  }
  secs.push(cur);
  const classify = (head) => {
    if (/✅|☑|✓\s*done|\bDONE\b|\bROTATED\b/i.test(head)) return 'done';
    if (/⬜|\bOUTSTANDING\b|\bTODO\b|\bPENDING\b/i.test(head)) return 'out';
    return 'neutral';
  };
  let nOut = 0, nDone = 0;
  const cards = [];
  let preamble = '';
  for (const s of secs) {
    if (s.head === null) { preamble = mdBlock(s.body); continue; }
    const cls = classify(s.head);
    if (cls === 'out') nOut++; if (cls === 'done') nDone++;
    const title = s.head.replace(/⬜|✅|☑/g, '').trim();
    const pill = cls === 'done' ? '<span class="pill done">✅ done</span>'
               : cls === 'out' ? '<span class="pill out">⬜ outstanding</span>'
               : '<span class="pill neutral">note</span>';
    cards.push('<section class="card ' + cls + '"><h2>' + pill + ' ' + mdInlineEsc(title) + '</h2>' + mdBlock(s.body) + '</section>');
  }
  return `<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Rotation — items we need to rotate</title>
<style>
  :root{--ink:#0f0e0c;--soft:#555;--faint:#999;--line:#ddd;--bg:#faf8f3;--card:#fff;
        --out:#7f1d1d;--outBg:#fee2e2;--done:#14532d;--doneBg:#d1fae5;--neu:#374151;--neuBg:#e5e7eb;}
  *{box-sizing:border-box} body{font:14px/1.55 -apple-system,system-ui,sans-serif;color:var(--ink);background:var(--bg);margin:0;padding:24px 28px;max-width:920px}
  h1{font:300 28px/1.1 Georgia,serif;margin:0 0 6px}
  .sub{color:var(--soft);margin:0 0 8px;font-size:13px}
  .sub code{background:#e7e3d8;padding:2px 6px;border-radius:3px;font-size:12px}
  .nav{margin:0 0 16px;font-size:13px} .nav a{color:#1e40af;text-decoration:none} .nav a:hover{text-decoration:underline}
  .summary{display:flex;gap:10px;margin:0 0 18px}
  .chip{padding:6px 12px;border-radius:8px;font-size:13px;font-weight:600}
  .chip.out{background:var(--outBg);color:var(--out)} .chip.done{background:var(--doneBg);color:var(--done)}
  .preamble{background:#fffbeb;border:1px solid #fcd34d;border-radius:8px;padding:10px 14px;font-size:12.5px;color:#5b4a16;margin:0 0 18px}
  .preamble p{margin:6px 0} .preamble code{background:#fdf2c8;padding:1px 5px;border-radius:3px}
  .card{background:var(--card);border:1px solid var(--line);border-left-width:4px;border-radius:8px;padding:14px 18px;margin:0 0 14px}
  .card.out{border-left-color:var(--out)} .card.done{border-left-color:var(--done);opacity:.72} .card.neutral{border-left-color:#cbd5e1}
  .card h2{font:600 16px/1.3 system-ui;margin:0 0 8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
  .pill{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11px;font-weight:700}
  .pill.out{background:var(--outBg);color:var(--out)} .pill.done{background:var(--doneBg);color:var(--done)} .pill.neutral{background:var(--neuBg);color:var(--neu)}
  .card p{margin:6px 0} .card ul{margin:6px 0 6px 2px;padding-left:18px} .card li{margin:3px 0}
  code{font:12px ui-monospace,Menlo,monospace} :not(pre)>code{background:#f1ede2;padding:1px 5px;border-radius:3px;word-break:break-word}
  pre{background:#0f1117;color:#e6edf3;padding:10px 12px;border-radius:6px;overflow:auto;margin:8px 0} pre code{background:none;color:inherit}
  a{color:#1e40af} footer{color:var(--faint);font-size:11px;margin-top:18px}
</style></head><body>
  <h1>Items we need to rotate</h1>
  <p class="sub">Read-only render of <code>ROTATION-CHECKLIST.md</code> · no secret values on this page (key names + mint URLs + route-back commands only).</p>
  <p class="nav">← <a href="/">Secrets viewer</a></p>
  <div class="summary"><span class="chip out">${nOut} outstanding</span><span class="chip done">${nDone} done</span></div>
  ${preamble ? '<div class="preamble">' + preamble + '</div>' : ''}
  ${cards.join('\n')}
  <footer>Loopback-only · binds 127.0.0.1:${PORT} · console-only actions (Claude can't mint keys or run prod credential changes) — route each new value back via <code>cli.js import-paste</code>.</footer>
</body></html>`;
}

function send(res, status, ct, body) {
  res.writeHead(status, { 'Content-Type': ct, 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex' });
  res.end(body);
}

const server = http.createServer((req, res) => {
  if (!isLoopback(req)) { send(res, 403, 'text/plain', 'loopback only'); return; }
  const parsed = url.parse(req.url, true);
  const p = parsed.pathname || '/';

  if (req.method === 'GET' && (p === '/' || p === '/index.html')) {
    return send(res, 200, 'text/html; charset=utf-8', renderPage());
  }

  if (req.method === 'GET' && (p === '/rotation' || p === '/rotate')) {
    return send(res, 200, 'text/html; charset=utf-8', renderRotation());
  }

  if (req.method === 'GET' && p === '/api/registry') {
    return send(res, 200, 'application/json', JSON.stringify(readRegistry(), null, 2));
  }

  if (req.method === 'POST' && p.startsWith('/api/reveal/')) {
    if (req.headers['x-confirm-reveal'] !== 'yes') { send(res, 400, 'application/json', '{"error":"missing X-Confirm-Reveal header"}'); return; }
    const key = decodeURIComponent(p.slice('/api/reveal/'.length));
    if (!/^[A-Z0-9_]{2,64}$/.test(key)) { send(res, 400, 'application/json', '{"error":"invalid key name"}'); return; }
    const env = parseEnv(MASTER_ENV);
    const value = env[key];
    try {
      fs.appendFileSync(REVEAL_LOG, new Date().toISOString() + ' reveal ' + key + ' (' + (value ? 'found' : 'missing') + ')\n');
    } catch {}
    return send(res, 200, 'application/json', JSON.stringify({ key, value: value || null }));
  }

  send(res, 404, 'text/plain', 'not found');
});

server.listen(PORT, '127.0.0.1', () => {
  console.log('Secrets viewer on http://127.0.0.1:' + PORT + '/');
});