[object Object]

← back to Secrets Manager

snapshot: 3 file(s) changed, +1 new, ~2 modified

4adfd0aab6b4f946a8221790d32a5abc818c30ec · 2026-05-13 08:58:00 -0700 · Steve

Files touched

Diff

commit 4adfd0aab6b4f946a8221790d32a5abc818c30ec
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed May 13 08:58:00 2026 -0700

    snapshot: 3 file(s) changed, +1 new, ~2 modified
---
 registry.json |  33 +++++++
 routes.json   |  60 ++++++++-----
 viewer.js     | 284 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 354 insertions(+), 23 deletions(-)

diff --git a/registry.json b/registry.json
index 644ee55..6b54f79 100644
--- a/registry.json
+++ b/registry.json
@@ -692,6 +692,39 @@
         "/Users/stevestudio2/Projects/secrets-manager/.env",
         "/Users/stevestudio2/Desktop/site-factory.env"
       ]
+    },
+    "BUTLR_OWNER_TOKEN": {
+      "digest": "da8c:693bb5f9",
+      "label": "BUTLR_OWNER_TOKEN",
+      "last_updated": "2026-05-12T23:03:48.872Z",
+      "validated": false,
+      "verify_status": null,
+      "written_to": [
+        "/Users/stevestudio2/Projects/secrets-manager/.env",
+        "/Users/stevestudio2/Desktop/site-factory.env"
+      ]
+    },
+    "VAPI_API_KEY": {
+      "digest": "4da9:a1d8d0df",
+      "label": "VAPI_API_KEY",
+      "last_updated": "2026-05-13T15:26:33.358Z",
+      "validated": false,
+      "verify_status": null,
+      "written_to": [
+        "/Users/stevestudio2/Projects/secrets-manager/.env",
+        "/Users/stevestudio2/Desktop/site-factory.env"
+      ]
+    },
+    "VAPI_PUBLIC_KEY": {
+      "digest": "4df0:c8ace254",
+      "label": "VAPI_PUBLIC_KEY",
+      "last_updated": "2026-05-13T15:26:33.360Z",
+      "validated": false,
+      "verify_status": null,
+      "written_to": [
+        "/Users/stevestudio2/Projects/secrets-manager/.env",
+        "/Users/stevestudio2/Desktop/site-factory.env"
+      ]
     }
   }
 }
\ No newline at end of file
diff --git a/routes.json b/routes.json
index e913dcc..0e35b28 100644
--- a/routes.json
+++ b/routes.json
@@ -221,7 +221,7 @@
       ]
     },
     "STRIPE_VCL_WEBHOOK_SECRET": {
-      "label": "Stripe webhook signing secret — VCL endpoint we_1TUF1Y63uNmiRsMbNr1kLu52",
+      "label": "Stripe webhook signing secret \u2014 VCL endpoint we_1TUF1Y63uNmiRsMbNr1kLu52",
       "destinations": [
         {
           "type": "project",
@@ -236,7 +236,7 @@
       ]
     },
     "STRIPE_PRICE_VCL_STARTER_MONTH": {
-      "label": "Stripe Price ID — VCL Starter $49/mo",
+      "label": "Stripe Price ID \u2014 VCL Starter $49/mo",
       "destinations": [
         {
           "type": "project",
@@ -251,7 +251,7 @@
       ]
     },
     "STRIPE_PRICE_VCL_STANDARD_MONTH": {
-      "label": "Stripe Price ID — VCL Standard $99/mo",
+      "label": "Stripe Price ID \u2014 VCL Standard $99/mo",
       "destinations": [
         {
           "type": "project",
@@ -266,7 +266,7 @@
       ]
     },
     "STRIPE_PRICE_VCL_PREMIER_MONTH": {
-      "label": "Stripe Price ID — VCL Premier $199/mo",
+      "label": "Stripe Price ID \u2014 VCL Premier $199/mo",
       "destinations": [
         {
           "type": "project",
@@ -281,7 +281,7 @@
       ]
     },
     "HEYGEN_API_KEY": {
-      "label": "HeyGen API key — talking-head avatar video render (used by session-debrief skill)",
+      "label": "HeyGen API key \u2014 talking-head avatar video render (used by session-debrief skill)",
       "verify": {
         "method": "GET",
         "url": "https://api.heygen.com/v1/voice.list",
@@ -300,7 +300,7 @@
       ]
     },
     "INFO_VENTURACLAW_COM_PASSWORD": {
-      "label": "Purelymail mailbox password — info@venturaclaw.com (used by Ventura Claw Leads + the existing ventura-claw app)",
+      "label": "Purelymail mailbox password \u2014 info@venturaclaw.com (used by Ventura Claw Leads + the existing ventura-claw app)",
       "destinations": [
         {
           "type": "project",
@@ -325,7 +325,7 @@
       ]
     },
     "INFO_NATIONALPAPERHANGERS_COM_PASSWORD": {
-      "label": "Purelymail mailbox password — info@nationalpaperhangers.com",
+      "label": "Purelymail mailbox password \u2014 info@nationalpaperhangers.com",
       "destinations": [
         {
           "type": "project",
@@ -340,7 +340,7 @@
       ]
     },
     "STRIPE_NPH_WEBHOOK_SECRET": {
-      "label": "Stripe Webhook Signing Secret — NPH endpoint we_1TUDHV63uNmiRsMbRJea7EqU",
+      "label": "Stripe Webhook Signing Secret \u2014 NPH endpoint we_1TUDHV63uNmiRsMbRJea7EqU",
       "destinations": [
         {
           "type": "project",
@@ -355,7 +355,7 @@
       ]
     },
     "STRIPE_PRICE_PRO_MONTH": {
-      "label": "Stripe Price ID — NPH Pro $39/mo",
+      "label": "Stripe Price ID \u2014 NPH Pro $39/mo",
       "destinations": [
         {
           "type": "project",
@@ -370,7 +370,7 @@
       ]
     },
     "STRIPE_PRICE_PRO_YEAR": {
-      "label": "Stripe Price ID — NPH Pro $399/yr",
+      "label": "Stripe Price ID \u2014 NPH Pro $399/yr",
       "destinations": [
         {
           "type": "project",
@@ -385,7 +385,7 @@
       ]
     },
     "STRIPE_PRICE_SIGNATURE_MONTH": {
-      "label": "Stripe Price ID — NPH Signature $149/mo",
+      "label": "Stripe Price ID \u2014 NPH Signature $149/mo",
       "destinations": [
         {
           "type": "project",
@@ -400,7 +400,7 @@
       ]
     },
     "STRIPE_PRICE_SIGNATURE_YEAR": {
-      "label": "Stripe Price ID — NPH Signature $1500/yr",
+      "label": "Stripe Price ID \u2014 NPH Signature $1500/yr",
       "destinations": [
         {
           "type": "project",
@@ -415,7 +415,7 @@
       ]
     },
     "STRIPE_PRICE_ENTERPRISE_MONTH": {
-      "label": "Stripe Price ID — NPH Enterprise $399/mo",
+      "label": "Stripe Price ID \u2014 NPH Enterprise $399/mo",
       "destinations": [
         {
           "type": "project",
@@ -773,8 +773,8 @@
       ]
     },
     "LINKEDIN_CLIENT_ID": {
-      "label": "LinkedIn OAuth Client ID — \"For Claude - 5-6-26\" app, bound to Designer Wallcoverings Page",
-      "_note": "No verify endpoint — LinkedIn requires the ID+SECRET pair to validate, and the CLI verifies one key at a time. Manual probe via POST /oauth/v2/accessToken with a fake code expects 'invalid_request: authorization code not found' = creds OK.",
+      "label": "LinkedIn OAuth Client ID \u2014 \"For Claude - 5-6-26\" app, bound to Designer Wallcoverings Page",
+      "_note": "No verify endpoint \u2014 LinkedIn requires the ID+SECRET pair to validate, and the CLI verifies one key at a time. Manual probe via POST /oauth/v2/accessToken with a fake code expects 'invalid_request: authorization code not found' = creds OK.",
       "destinations": [
         {
           "type": "project",
@@ -788,7 +788,7 @@
       ]
     },
     "LINKEDIN_CLIENT_SECRET": {
-      "label": "LinkedIn OAuth Client Secret — \"For Claude - 5-6-26\" app, bound to Designer Wallcoverings Page",
+      "label": "LinkedIn OAuth Client Secret \u2014 \"For Claude - 5-6-26\" app, bound to Designer Wallcoverings Page",
       "destinations": [
         {
           "type": "project",
@@ -802,7 +802,7 @@
       ]
     },
     "NPH_GOOGLE_OAUTH_CLIENT_ID": {
-      "label": "Google OAuth Client ID — buyer sign-in on National Paper Hangers",
+      "label": "Google OAuth Client ID \u2014 buyer sign-in on National Paper Hangers",
       "destinations": [
         {
           "type": "project",
@@ -816,7 +816,7 @@
       ]
     },
     "NPH_GOOGLE_OAUTH_CLIENT_SECRET": {
-      "label": "Google OAuth Client Secret — buyer sign-in on National Paper Hangers",
+      "label": "Google OAuth Client Secret \u2014 buyer sign-in on National Paper Hangers",
       "destinations": [
         {
           "type": "project",
@@ -1049,7 +1049,7 @@
     ]
   },
   "GOOGLE_DRIVE_CLIENT_ID": {
-    "description": "Google Drive OAuth Client ID — TSD photo-import-cli desktop app",
+    "description": "Google Drive OAuth Client ID \u2014 TSD photo-import-cli desktop app",
     "destinations": [
       {
         "type": "env-file",
@@ -1058,7 +1058,7 @@
     ]
   },
   "GOOGLE_DRIVE_CLIENT_SECRET": {
-    "description": "Google Drive OAuth Client Secret — TSD photo-import-cli",
+    "description": "Google Drive OAuth Client Secret \u2014 TSD photo-import-cli",
     "destinations": [
       {
         "type": "env-file",
@@ -1067,7 +1067,7 @@
     ]
   },
   "GOOGLE_DRIVE_REFRESH_TOKEN": {
-    "description": "Google Drive OAuth refresh token — read-only scope, owned by steveabramsdesigns@gmail.com",
+    "description": "Google Drive OAuth refresh token \u2014 read-only scope, owned by steveabramsdesigns@gmail.com",
     "destinations": [
       {
         "type": "env-file",
@@ -1106,7 +1106,7 @@
     ]
   },
   "TMDB_API_KEY": {
-    "label": "TMDB v3 API key — asseeninmovies Phase B drain (6.46M-stub queue)",
+    "label": "TMDB v3 API key \u2014 asseeninmovies Phase B drain (6.46M-stub queue)",
     "verify": {
       "method": "GET",
       "url": "https://api.themoviedb.org/3/authentication?api_key=__VALUE__",
@@ -1118,7 +1118,7 @@
     ]
   },
   "TMDB_READ_ACCESS_TOKEN": {
-    "label": "TMDB v4 read-access token (bearer) — alt to v3 api_key",
+    "label": "TMDB v4 read-access token (bearer) \u2014 alt to v3 api_key",
     "verify": {
       "method": "GET",
       "url": "https://api.themoviedb.org/3/authentication",
@@ -1128,5 +1128,19 @@
     "destinations": [
       "/Users/stevestudio2/Projects/asseeninmovies/.env"
     ]
+  },
+  "VAPI_API_KEY": {
+    "label": "VAPI_API_KEY",
+    "destinations": [
+      "/Users/stevestudio2/Projects/holdforme/.env",
+      "remote:kamatera:/root/public-projects/butlr/.env"
+    ]
+  },
+  "VAPI_PUBLIC_KEY": {
+    "label": "VAPI_PUBLIC_KEY",
+    "destinations": [
+      "/Users/stevestudio2/Projects/holdforme/.env",
+      "remote:kamatera:/root/public-projects/butlr/.env"
+    ]
   }
 }
\ No newline at end of file
diff --git a/viewer.js b/viewer.js
new file mode 100644
index 0000000..1b831ca
--- /dev/null
+++ b/viewer.js
@@ -0,0 +1,284 @@
+#!/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');
+
+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>
+
+  <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>`;
+}
+
+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 === '/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 + '/');
+});

← 6033408 feat(secrets): TMDB_API_KEY + TMDB_READ_ACCESS_TOKEN routes  ·  back to Secrets Manager  ·  snapshot: backup uncommitted work (1 files) d6a1fe6 →