[object Object]

← back to Norma Sdcc Pitch

add: section 11 Token Vault — 6 categories (AI / Email / Advocacy / Productivity / Data / Norma internals), 21 token slots, password-typed inputs with show/hide, last4 masking on read, delete + bulk save

948a92410ee07033aab3071ebf58013add2fc8ce · 2026-05-20 10:00:52 -0700 · SteveStudio2

Files touched

Diff

commit 948a92410ee07033aab3071ebf58013add2fc8ce
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 20 10:00:52 2026 -0700

    add: section 11 Token Vault — 6 categories (AI / Email / Advocacy / Productivity / Data / Norma internals), 21 token slots, password-typed inputs with show/hide, last4 masking on read, delete + bulk save
---
 data/features.json |  75 +++++++++++++++++++++++++++++++++
 public/app.js      | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 public/index.html  |  20 +++++++++
 public/styles.css  |  88 ++++++++++++++++++++++++++++++++++++++
 server.js          |  48 +++++++++++++++++++++
 5 files changed, 352 insertions(+)

diff --git a/data/features.json b/data/features.json
index 851e6b7..d5629fe 100644
--- a/data/features.json
+++ b/data/features.json
@@ -1337,6 +1337,81 @@
     ]
   },
 
+  "token_vault": {
+    "headline": "Token Vault — every API key in one place",
+    "intro": "SDCC needs ~10 keys to fully light up Norma. Store them here once; Norma reads from this vault. Last 4 digits only echo back — full values stay encrypted on the server.",
+    "categories": [
+      {
+        "id": "ai",
+        "label": "AI providers",
+        "icon": "🤖",
+        "tokens": [
+          { "key": "ANTHROPIC_API_KEY", "label": "Anthropic (Claude)", "placeholder": "sk-ant-api03-...", "where": "console.anthropic.com → API Keys", "required": true, "notes": "Primary LLM for all drafts, statements, classifications." },
+          { "key": "GEMINI_API_KEY", "label": "Gemini (Google)", "placeholder": "AIza...", "where": "ai.studio.google.com → Get API key", "required": false, "notes": "Vision tasks + cheap classification. Optional fallback." },
+          { "key": "ELEVENLABS_API_KEY", "label": "ElevenLabs (voice)", "placeholder": "sk_...", "where": "elevenlabs.io → Profile → API Key", "required": false, "notes": "For audio narration in case studies, social audio, video voiceovers." }
+        ]
+      },
+      {
+        "id": "email",
+        "label": "Email + Comms",
+        "icon": "📬",
+        "tokens": [
+          { "key": "GMAIL_OAUTH_CLIENT_ID", "label": "Gmail OAuth Client ID", "placeholder": "....apps.googleusercontent.com", "where": "console.cloud.google.com → APIs & Services → Credentials", "required": true, "notes": "For info@studentdebtcrisis.org inbox triage + draft replies." },
+          { "key": "GMAIL_OAUTH_CLIENT_SECRET", "label": "Gmail OAuth Client Secret", "placeholder": "GOCSPX-...", "where": "Same as above, click client → secret", "required": true },
+          { "key": "GMAIL_REFRESH_TOKEN", "label": "Gmail Refresh Token", "placeholder": "1//...", "where": "Generated by Norma after first OAuth flow", "required": false, "notes": "Auto-populates after first sign-in. Don't set manually." },
+          { "key": "MAILGUN_API_KEY", "label": "Mailgun (bulk send)", "placeholder": "key-...", "where": "mailgun.com → Settings → API Keys", "required": false, "notes": "Only if you want Norma to send instead of EveryAction." }
+        ]
+      },
+      {
+        "id": "advocacy",
+        "label": "Advocacy + Donations",
+        "icon": "🏛️",
+        "tokens": [
+          { "key": "EVERYACTION_API_KEY", "label": "EveryAction API", "placeholder": "your-app:your-key", "where": "EveryAction → Settings → API Integrations", "required": false, "notes": "Sync supporters bidirectionally — Norma writes back to EveryAction so your CRM of record stays canonical." },
+          { "key": "ACTBLUE_API_KEY", "label": "ActBlue (donations)", "placeholder": "Bearer ...", "where": "ActBlue → Account → API", "required": false, "notes": "Pull donation data for donor radar + renewal cron." },
+          { "key": "ACTION_NETWORK_API_KEY", "label": "Action Network", "placeholder": "OSDI-...", "where": "actionnetwork.org → Settings → API & Sync", "required": false }
+        ]
+      },
+      {
+        "id": "productivity",
+        "label": "Productivity + Workspace",
+        "icon": "🗂️",
+        "tokens": [
+          { "key": "SLACK_WEBHOOK_URL", "label": "Slack incoming webhook", "placeholder": "https://hooks.slack.com/services/...", "where": "Slack admin → Apps → Incoming Webhooks", "required": false, "notes": "Where high-urgency triage routes land." },
+          { "key": "SLACK_BOT_TOKEN", "label": "Slack Bot Token", "placeholder": "xoxb-...", "where": "api.slack.com/apps → OAuth & Permissions", "required": false, "notes": "Read DM channels + post messages programmatically." },
+          { "key": "CLICKUP_API_TOKEN", "label": "ClickUp", "placeholder": "pk_...", "where": "ClickUp → Settings → Apps", "required": false, "notes": "If SDCC uses ClickUp for project tracking." }
+        ]
+      },
+      {
+        "id": "data",
+        "label": "Data + Intelligence",
+        "icon": "📡",
+        "tokens": [
+          { "key": "PROPUBLICA_CONGRESS_KEY", "label": "ProPublica Congress API", "placeholder": "registration-key", "where": "projects.propublica.org/api-docs", "required": false, "notes": "Free key. Powers congress-by-zip + bill-tracking." },
+          { "key": "GOOGLE_CIVIC_API_KEY", "label": "Google Civic Information", "placeholder": "AIza...", "where": "console.cloud.google.com → enable Civic API", "required": false, "notes": "Backup for ZIP → reps lookup." },
+          { "key": "NEWSAPI_KEY", "label": "NewsAPI (or alt)", "placeholder": "your-newsapi-key", "where": "newsapi.org → Get API key", "required": false, "notes": "Backup feed source for the news-intelligence loop." },
+          { "key": "OPEN_STATES_API_KEY", "label": "Open States API", "placeholder": "your-key", "where": "openstates.org/data", "required": false, "notes": "State-legislature tracking. Free up to 500/day." }
+        ]
+      },
+      {
+        "id": "norma_internal",
+        "label": "Norma internals (auto-managed)",
+        "icon": "🔐",
+        "tokens": [
+          { "key": "DATABASE_URL", "label": "Postgres connection", "placeholder": "postgresql://user:pass@host:5432/db", "where": "Your DB provider (Supabase, Railway, RDS, etc)", "required": true, "notes": "Where SDCC's data lives." },
+          { "key": "COOKIE_SECRET", "label": "Session cookie secret", "placeholder": "(generated for you)", "where": "Auto-generated on first install", "required": true, "notes": "Don't change — invalidates all logins." },
+          { "key": "SDCC_ADMIN_PASSWORD", "label": "Norma admin password", "placeholder": "your-strong-password", "where": "You set this", "required": true, "notes": "Initial admin login. Rotate quarterly." }
+        ]
+      }
+    ],
+    "instructions": [
+      "Never commit these to git. The .env file is in .gitignore — leave it that way.",
+      "Rotate every 90 days for the AI provider keys (Anthropic, Gemini).",
+      "If a key leaks, revoke it at the provider first, then update here, then restart Norma.",
+      "The vault shows only the last 4 chars on read — full values stay server-side."
+    ]
+  },
+
   "gamify": {
     "xp_per_quiz": 25,
     "xp_per_correct": 10,
diff --git a/public/app.js b/public/app.js
index 6f8ee21..82b0df7 100644
--- a/public/app.js
+++ b/public/app.js
@@ -60,12 +60,14 @@
     renderWalkthrough();
     renderPitch();
     renderGlossary();
+    renderVault();
     renderHud();
     wireFilters();
     wirePitchActions();
     wirePrepCards();
     wireModalCloseHandlers();
     wireGlossarySearch();
+    wireVaultActions();
     setupScrollProgress();
   }
 
@@ -795,6 +797,125 @@ ${items.map((a) => `
     });
   }
 
+  /* ─────────────── token vault ─────────────── */
+  async function renderVault() {
+    const tv = state.data.token_vault;
+    if (!tv) return;
+    const list = document.getElementById('vault-list');
+    if (!list) return;
+
+    // Fetch current state
+    let serverState = {};
+    try {
+      const r = await fetch('/api/vault');
+      const data = await r.json();
+      serverState = data.vault || {};
+    } catch { /* ignore */ }
+
+    list.innerHTML = tv.categories.map((c) => {
+      const setCount = c.tokens.filter((t) => serverState[t.key]).length;
+      const total = c.tokens.length;
+      return `
+        <div class="vault-category">
+          <div class="vault-cat-head">
+            <span class="icon">${c.icon}</span>
+            <h3>${escapeHtml(c.label)}</h3>
+            <span class="set-count ${setCount === 0 ? 'empty' : ''}">${setCount} / ${total} set</span>
+          </div>
+          <div class="vault-cat-body">
+            ${c.tokens.map((t) => {
+              const ss = serverState[t.key];
+              const setClass = ss ? 'set' : '';
+              const reqClass = t.required ? 'required' : '';
+              return `
+                <div class="vault-row ${setClass} ${reqClass}">
+                  <div class="vault-row-label">
+                    <span class="name">${escapeHtml(t.label)}${t.required ? '<span class="req">REQ</span>' : ''}</span>
+                    <span class="key">${escapeHtml(t.key)}</span>
+                    ${t.where ? `<span class="where">${escapeHtml(t.where)}</span>` : ''}
+                    ${t.notes ? `<span class="notes">${escapeHtml(t.notes)}</span>` : ''}
+                  </div>
+                  <div class="vault-input-wrap">
+                    <input type="password"
+                           placeholder="${escapeHtml(t.placeholder || '')}"
+                           data-vault-key="${escapeHtml(t.key)}"
+                           autocomplete="off"
+                           spellcheck="false">
+                    <span class="vault-status ${setClass}">
+                      ${ss ? `Set <span class="last4">${escapeHtml(ss.last4 || '')}</span> ${ss.set_at ? ' · ' + new Date(ss.set_at).toLocaleDateString() : ''}` : 'Not set'}
+                    </span>
+                  </div>
+                  <div class="vault-row-actions">
+                    <button class="show-btn" type="button" data-toggle="${escapeHtml(t.key)}">Show</button>
+                    ${ss ? `<button class="delete-btn" type="button" data-del="${escapeHtml(t.key)}">Delete</button>` : ''}
+                  </div>
+                </div>`;
+            }).join('')}
+          </div>
+        </div>`;
+    }).join('');
+
+    // toggle show/hide
+    list.querySelectorAll('[data-toggle]').forEach((b) => {
+      b.addEventListener('click', (e) => {
+        const key = b.getAttribute('data-toggle');
+        const inp = list.querySelector(`input[data-vault-key="${CSS.escape(key)}"]`);
+        if (inp) {
+          inp.type = inp.type === 'password' ? 'text' : 'password';
+          b.textContent = inp.type === 'password' ? 'Show' : 'Hide';
+        }
+      });
+    });
+
+    // delete
+    list.querySelectorAll('[data-del]').forEach((b) => {
+      b.addEventListener('click', async (e) => {
+        const key = b.getAttribute('data-del');
+        if (!confirm(`Delete ${key}? Norma will lose access to that service.`)) return;
+        try {
+          await fetch('/api/vault/' + encodeURIComponent(key), { method: 'DELETE' });
+          renderVault();
+        } catch (err) { alert('Delete failed: ' + err.message); }
+      });
+    });
+  }
+
+  async function saveVault() {
+    const updates = {};
+    document.querySelectorAll('[data-vault-key]').forEach((inp) => {
+      if (inp.value && inp.value.trim()) {
+        updates[inp.getAttribute('data-vault-key')] = inp.value.trim();
+      }
+    });
+    if (Object.keys(updates).length === 0) {
+      toast('xp', 'No new values to save');
+      return;
+    }
+    try {
+      const r = await fetch('/api/vault', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(updates),
+      });
+      const data = await r.json();
+      // clear inputs
+      document.querySelectorAll('[data-vault-key]').forEach((inp) => { inp.value = ''; });
+      const ind = document.getElementById('vault-saved');
+      if (ind) { ind.hidden = false; setTimeout(() => { ind.hidden = true; }, 3000); }
+      toast('badge', `🔐 Saved ${data.saved} key${data.saved === 1 ? '' : 's'}`);
+      await renderVault();
+    } catch (err) {
+      alert('Save failed: ' + err.message);
+    }
+  }
+
+  function wireVaultActions() {
+    const saveBtn = document.getElementById('vault-save-all');
+    const refBtn = document.getElementById('vault-refresh');
+    if (saveBtn) saveBtn.addEventListener('click', saveVault);
+    if (refBtn) refBtn.addEventListener('click', renderVault);
+  }
+
   /* ─────────────── refresh ─────────────── */
   function refreshAll() {
     save();
diff --git a/public/index.html b/public/index.html
index 01460f3..c3ca7bf 100644
--- a/public/index.html
+++ b/public/index.html
@@ -29,6 +29,7 @@
       <a href="#example">Demo</a>
       <a href="#prep">Pitch</a>
       <a href="#glossary">Glossary</a>
+      <a href="#vault">🔐 Vault</a>
     </nav>
     <div class="player-hud">
       <div class="hud-xp" title="Earn XP by taking quizzes + running examples">
@@ -374,6 +375,25 @@
   </div>
 </section>
 
+<!-- ─────────── 11. token vault ─────────── -->
+<section id="vault" class="lecture" data-section="token_vault">
+  <div class="lecture-inner">
+    <span class="section-num" style="background:#047857;">🔐</span>
+    <h2 data-bind="token_vault.headline"></h2>
+    <p class="sub" data-bind="token_vault.intro"></p>
+    <div class="callout callout-honest">
+      <h3>Vault rules</h3>
+      <ul data-bind-list="token_vault.instructions"></ul>
+    </div>
+    <div class="vault-actions">
+      <button id="vault-save-all" class="btn-primary">Save all changes</button>
+      <button id="vault-refresh" class="btn-secondary">Refresh status</button>
+      <span class="vault-saved-indicator" id="vault-saved" hidden>✓ Saved</span>
+    </div>
+    <div class="vault-list" id="vault-list"></div>
+  </div>
+</section>
+
 <footer>
   <div class="footer-inner">
     <div>Norma × SDCC pitch viewer · localhost:9876 · backing Norma at <code>localhost:7400</code></div>
diff --git a/public/styles.css b/public/styles.css
index 806c2d9..f5bde3a 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -830,6 +830,94 @@ code { font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: .85em; bac
 .gloss-term.learned .mark-learned { background: var(--good); border-color: var(--good); }
 .gloss-empty { padding: 32px; text-align: center; color: var(--muted); font-style: italic; }
 
+/* ────── token vault ────── */
+.vault-actions {
+  display: flex; gap: 10px; align-items: center; margin: 18px 0 22px;
+  flex-wrap: wrap;
+}
+.vault-saved-indicator {
+  display: inline-flex; align-items: center;
+  background: var(--good-soft); color: var(--good);
+  padding: 6px 14px; border-radius: 999px;
+  font-size: 13px; font-weight: 600;
+  animation: pop-in .3s ease;
+}
+.vault-list { display: grid; gap: 16px; }
+.vault-category {
+  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
+  overflow: hidden;
+}
+.vault-cat-head {
+  padding: 14px 20px; background: linear-gradient(180deg, #f8fafc, #fff);
+  border-bottom: 1px solid var(--border-soft);
+  display: flex; align-items: center; gap: 12px;
+}
+.vault-cat-head .icon { font-size: 22px; }
+.vault-cat-head h3 { margin: 0; font-size: 16px; color: var(--sdcc-blue-deep); flex: 1; }
+.vault-cat-head .set-count {
+  background: var(--good-soft); color: var(--good);
+  padding: 3px 10px; border-radius: 999px;
+  font-size: 11px; font-weight: 700;
+}
+.vault-cat-head .set-count.empty { background: var(--border-soft); color: var(--muted); }
+.vault-cat-body { padding: 16px 20px; display: grid; gap: 14px; }
+
+.vault-row {
+  display: grid; grid-template-columns: 220px 1fr 110px; gap: 12px;
+  align-items: start;
+  padding: 12px 14px; background: var(--bg); border: 1px solid var(--border-soft);
+  border-radius: 8px;
+}
+.vault-row.set { background: var(--good-soft); border-color: #a7f3d0; }
+.vault-row.required:not(.set) { border-left: 3px solid var(--sdcc-amber); }
+@media (max-width: 700px) { .vault-row { grid-template-columns: 1fr; } }
+
+.vault-row-label { display: flex; flex-direction: column; gap: 2px; }
+.vault-row-label .name { font-weight: 700; font-size: 14px; color: var(--ink); display: flex; align-items: center; gap: 6px; }
+.vault-row-label .name .req {
+  background: var(--sdcc-amber); color: #fff;
+  padding: 0 6px; border-radius: 3px;
+  font-size: 9px; font-weight: 700; letter-spacing: 1px;
+}
+.vault-row-label .key {
+  font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: 11px;
+  color: var(--muted); padding: 0;
+}
+.vault-row-label .where { font-size: 11.5px; color: var(--muted); margin-top: 2px; }
+.vault-row-label .where a { color: var(--sdcc-blue); text-decoration: none; }
+.vault-row-label .where a:hover { text-decoration: underline; }
+.vault-row-label .notes {
+  font-size: 11.5px; color: var(--ink-2); font-style: italic;
+  margin-top: 4px; line-height: 1.45;
+}
+
+.vault-input-wrap { display: flex; flex-direction: column; gap: 4px; }
+.vault-row input[type="text"], .vault-row input[type="password"] {
+  width: 100%; padding: 8px 12px; font-size: 13px;
+  border: 1px solid var(--border); border-radius: 6px;
+  font-family: 'SF Mono', Menlo, Consolas, monospace; background: #fff;
+}
+.vault-row input:focus { outline: none; border-color: var(--sdcc-blue); }
+.vault-status {
+  font-size: 11px; color: var(--muted); padding: 2px 0;
+}
+.vault-status.set { color: var(--good); font-weight: 600; }
+.vault-status .last4 {
+  background: var(--good); color: #fff; padding: 1px 6px; border-radius: 3px;
+  font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: 10px;
+}
+
+.vault-row-actions { display: flex; gap: 6px; align-items: flex-start; }
+.vault-row-actions button {
+  padding: 6px 10px; font-size: 12px; border-radius: 5px;
+  cursor: pointer; border: 1px solid var(--border);
+  background: var(--surface); color: var(--ink-2);
+  font-family: inherit;
+}
+.vault-row-actions button.show-btn:hover { background: var(--info-soft); color: var(--info); }
+.vault-row-actions button.delete-btn { color: var(--danger); }
+.vault-row-actions button.delete-btn:hover { background: var(--danger-soft); border-color: var(--danger); }
+
 /* ────── footer ────── */
 footer { border-top: 1px solid var(--border); background: var(--surface); padding: 26px 32px; margin-top: 40px; }
 .footer-inner { max-width: 1100px; margin: 0 auto; font-size: 13px; color: var(--muted); display: flex; flex-direction: column; gap: 6px; }
diff --git a/server.js b/server.js
index b73ff01..440d3a7 100644
--- a/server.js
+++ b/server.js
@@ -410,6 +410,54 @@ app.get('/videos/:filename', (req, res) => {
   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, () => {

← 51ce968 add: browser-playable explainer video banner at top of pitch  ·  back to Norma Sdcc Pitch  ·  add: dashboard modals — click priority cards / loop cards / c6eb87e →