← 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
M data/features.jsonM public/app.jsM public/index.htmlM public/styles.cssM server.js
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 →