← back to Norma
feat(admin): comprehensive API/Token/MCP registry + age-adaptive theme slider
189ac33ca646071f148462dc06c726dd91a42046 · 2026-05-20 12:24:19 -0700 · Steve Abrams
- lib/api-registry.ts: ~30 integrations across 10 categories (Gmail/Google OAuth,
Gemini, Congress/ProPublica/BLS/Census/FRED/Scorecard, Action Network, Twitter/X,
BlueSky, Reddit, Facebook/IG, Discord, Twitch, Slack, SMTP, internal infra, MCPs).
Each entry: env vars, purpose, signup URL, docs URL, OAuth scopes, pricing,
numbered get-this-token steps.
- /api/registry route: admin-gated, returns set/missing status per env var.
Values NEVER leak — only booleans.
- ApiRegistryModal: searchable, status-filterable, grouped by category. Per-item
card expands to show env vars (with copy buttons), scopes, pricing,
step-by-step instructions, and signup/docs links.
- ApiRegistryButton in header (admin only) opens the modal.
- AgeThemeSlider on top of every page: rewrites CSS tokens through 8 bands
(toddler/kid/tween/teen/adult/mature/senior/elder), persisted in localStorage.
CSS bands appended to globals.css — typography + palette only, NOT a WCAG
substitute.
Files touched
A app/api/registry/route.tsM app/globals.cssA components/AgeThemeSlider.tsxM components/AppShell.tsxA components/api-registry/ApiRegistryButton.tsxA components/api-registry/ApiRegistryModal.tsxA lib/api-registry.ts
Diff
commit 189ac33ca646071f148462dc06c726dd91a42046
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 20 12:24:19 2026 -0700
feat(admin): comprehensive API/Token/MCP registry + age-adaptive theme slider
- lib/api-registry.ts: ~30 integrations across 10 categories (Gmail/Google OAuth,
Gemini, Congress/ProPublica/BLS/Census/FRED/Scorecard, Action Network, Twitter/X,
BlueSky, Reddit, Facebook/IG, Discord, Twitch, Slack, SMTP, internal infra, MCPs).
Each entry: env vars, purpose, signup URL, docs URL, OAuth scopes, pricing,
numbered get-this-token steps.
- /api/registry route: admin-gated, returns set/missing status per env var.
Values NEVER leak — only booleans.
- ApiRegistryModal: searchable, status-filterable, grouped by category. Per-item
card expands to show env vars (with copy buttons), scopes, pricing,
step-by-step instructions, and signup/docs links.
- ApiRegistryButton in header (admin only) opens the modal.
- AgeThemeSlider on top of every page: rewrites CSS tokens through 8 bands
(toddler/kid/tween/teen/adult/mature/senior/elder), persisted in localStorage.
CSS bands appended to globals.css — typography + palette only, NOT a WCAG
substitute.
---
app/api/registry/route.ts | 74 +++
app/globals.css | 87 ++++
components/AgeThemeSlider.tsx | 115 +++++
components/AppShell.tsx | 7 +
components/api-registry/ApiRegistryButton.tsx | 24 +
components/api-registry/ApiRegistryModal.tsx | 417 ++++++++++++++++
lib/api-registry.ts | 673 ++++++++++++++++++++++++++
7 files changed, 1397 insertions(+)
diff --git a/app/api/registry/route.ts b/app/api/registry/route.ts
new file mode 100644
index 0000000..435f51a
--- /dev/null
+++ b/app/api/registry/route.ts
@@ -0,0 +1,74 @@
+// GET /api/registry — returns the integration registry + set/missing status
+// for each env var. Values themselves NEVER leave the server.
+//
+// Admin-gated: requires the norma-auth cookie with admin role.
+
+import { NextResponse } from 'next/server';
+import { cookies } from 'next/headers';
+import { INTEGRATIONS, CATEGORY_ORDER, statusFor } from '@/lib/api-registry';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
+interface SessionPayload {
+ role?: string;
+ user?: string;
+}
+
+async function readSession(): Promise<SessionPayload | null> {
+ const jar = await cookies();
+ const raw = jar.get('norma-auth')?.value;
+ if (!raw) return null;
+ try {
+ // The cookie is JWT-style or signed-JSON depending on lib/auth.ts; we
+ // only need the payload to check the role. Tolerant decode.
+ const parts = raw.split('.');
+ const body = parts.length >= 2 ? parts[1] : parts[0];
+ const json = Buffer.from(body, 'base64url').toString('utf8');
+ return JSON.parse(json) as SessionPayload;
+ } catch {
+ return null;
+ }
+}
+
+export async function GET() {
+ const session = await readSession();
+ if (!session || session.role !== 'admin') {
+ return NextResponse.json({ error: 'admin only' }, { status: 403 });
+ }
+
+ const entries = INTEGRATIONS.map(e => ({
+ id: e.id,
+ name: e.name,
+ category: e.category,
+ purpose: e.purpose,
+ required: e.required,
+ envVars: e.envVars,
+ status: statusFor(e),
+ signupUrl: e.signupUrl,
+ docsUrl: e.docsUrl,
+ scopes: e.scopes,
+ pricing: e.pricing,
+ getSteps: e.getSteps,
+ // Which env vars are set (boolean only — no values).
+ envVarStatus: Object.fromEntries(
+ e.envVars.map(v => [v, !!process.env[v] && String(process.env[v]).length > 0]),
+ ),
+ }));
+
+ const setCount = entries.filter(e => e.status === 'set').length;
+ const partialCount = entries.filter(e => e.status === 'partial').length;
+ const missingCount = entries.filter(e => e.status === 'missing').length;
+
+ return NextResponse.json({
+ categories: CATEGORY_ORDER,
+ entries,
+ summary: {
+ total: entries.length,
+ set: setCount,
+ partial: partialCount,
+ missing: missingCount,
+ requiredMissing: entries.filter(e => e.required && e.status !== 'set').length,
+ },
+ });
+}
diff --git a/app/globals.css b/app/globals.css
index a39052b..776c8d4 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1190,3 +1190,90 @@ a:hover {
color: var(--color-text) !important;
background: var(--color-surface) !important;
}
+
+/* ─────────────────────────────────────────────────────────────────────
+ Age-adaptive theme bands (v9 — 2026-05-12 validated)
+ Source: ~/.claude/skills/age-adaptive-theme/reference-implementation.html
+ Each band rewrites the Norma CSS tokens for typography + palette.
+ Activated by <body data-band="..."> from <AgeThemeSlider />.
+ Scope: typography + palette only. Does NOT satisfy WCAG 2.1 AA alone.
+ ───────────────────────────────────────────────────────────────────── */
+
+/* AGE 3-5 — Sesame Street primary colors */
+body[data-band="toddler"] {
+ --color-bg: #fff9e6; --color-surface: #fff; --color-surface-el: #fef9c3;
+ --color-text: #222; --color-text-secondary: #444; --color-text-muted: #666;
+ --color-border: #f4d000; --color-primary: #ff3b30; --color-primary-hover: #e0322a;
+ font-family: 'Fredoka', 'Comic Neue', 'Marker Felt', 'Comic Sans MS', sans-serif;
+ font-size: 24px; line-height: 1.5;
+}
+body[data-band="toddler"] h1, body[data-band="toddler"] h2 { font-size: 48px; }
+
+/* AGE 6-9 — Elementary (Nunito, Bessemans 2012 — +10-15% reading speed) */
+body[data-band="kid"] {
+ --color-bg: #fff; --color-surface: #fef9c3; --color-surface-el: #fef3c7;
+ --color-text: #1f2937; --color-text-secondary: #374151; --color-text-muted: #4b5563;
+ --color-border: #fbbf24; --color-primary: #3b82f6; --color-primary-hover: #2563eb;
+ font-family: 'Nunito', 'Chalkboard SE', 'Comic Sans MS', sans-serif;
+ font-size: 18px; line-height: 1.55;
+}
+body[data-band="kid"] h1, body[data-band="kid"] h2 { font-size: 30px; }
+
+/* AGE 10-12 — Tween (indigo-700 #4338ca, WCAG AA on amber bg) */
+body[data-band="tween"] {
+ --color-bg: #fef3c7; --color-surface: #fff; --color-surface-el: #fef9c3;
+ --color-text: #1f2937; --color-text-secondary: #374151; --color-text-muted: #6b7280;
+ --color-border: #fbbf24; --color-primary: #4338ca; --color-primary-hover: #3730a3;
+ font-family: 'Public Sans', 'Avenir', -apple-system, sans-serif;
+ font-size: 15px; line-height: 1.5;
+}
+
+/* AGE 13-19 — Teen / neon dark mode (Space Grotesk, body 16px) */
+body[data-band="teen"] {
+ --color-bg: #0a0014; --color-surface: #1a0030; --color-surface-el: #2a0050;
+ --color-text: #f0f0ff; --color-text-secondary: #c5b5e5; --color-text-muted: #8a7fb0;
+ --color-border: #2a0050; --color-primary: #ff00ff; --color-primary-hover: #cc00cc;
+ font-family: 'Space Grotesk', 'JetBrains Mono', 'SF Mono', ui-monospace, monospace;
+ font-size: 16px; line-height: 1.5; letter-spacing: -0.01em;
+}
+
+/* AGE 20-44 — Adult (Norma default — DO NOT override, just match) */
+body[data-band="adult"] {
+ /* Inherit defaults from :root in this file. */
+ font-size: 15px; line-height: 1.5;
+}
+
+/* AGE 45-59 — Mature (Source Serif Pro, deepened accent) */
+body[data-band="mature"] {
+ --color-bg: #f8f6f1; --color-surface: #fff; --color-surface-el: #f1ede4;
+ --color-text: #1c1917; --color-text-secondary: #44403c; --color-text-muted: #57534e;
+ --color-border: #d6d3d1; --color-primary: #6b1d07; --color-primary-hover: #581605;
+ font-family: 'Source Serif Pro', 'Georgia', 'Iowan Old Style', serif;
+ font-size: 15px; line-height: 1.55;
+}
+body[data-band="mature"] h1, body[data-band="mature"] h2 { font-size: 22px; }
+
+/* AGE 60-79 — Senior, hi-contrast (Atkinson Hyperlegible, Braille Institute) */
+body[data-band="senior"] {
+ --color-bg: #fff; --color-surface: #fff; --color-surface-el: #f5f5f5;
+ --color-text: #000; --color-text-secondary: #1a1a1a; --color-text-muted: #333;
+ --color-border: #000; --color-primary: #0050a0; --color-primary-hover: #003d80;
+ font-family: 'Atkinson Hyperlegible', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ font-size: 20px; line-height: 1.55;
+}
+body[data-band="senior"] h1, body[data-band="senior"] h2 { font-size: 30px; }
+body[data-band="senior"] button, body[data-band="senior"] .btn { min-height: 48px; }
+
+/* AGE 80+ — Elder, max contrast yellow-on-black */
+body[data-band="elder"] {
+ --color-bg: #000; --color-surface: #000; --color-surface-el: #1a1a1a;
+ --color-text: #fff; --color-text-secondary: #e0e0e0; --color-text-muted: #cccccc;
+ --color-border: #fff; --color-primary: #ffff00; --color-primary-hover: #e0e000;
+ font-family: 'Atkinson Hyperlegible', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ font-size: 28px; line-height: 1.6;
+}
+body[data-band="elder"] h1, body[data-band="elder"] h2 { font-size: 44px; }
+body[data-band="elder"] button, body[data-band="elder"] .btn { min-height: 56px; }
+
+/* Smooth transitions when sliding the age bar */
+body { transition: background-color 0.3s, color 0.3s; }
diff --git a/components/AgeThemeSlider.tsx b/components/AgeThemeSlider.tsx
new file mode 100644
index 0000000..c66343e
--- /dev/null
+++ b/components/AgeThemeSlider.tsx
@@ -0,0 +1,115 @@
+'use client';
+
+// Drop-in age-adaptive theme slider. Sits at the top of AppShell, persists
+// choice to localStorage, sets body[data-band="..."] which the CSS in
+// app/globals.css consumes (via :root + body[data-band] overrides).
+//
+// Source: ~/.claude/skills/age-adaptive-theme/ (v9 — 2026-05-12 validated).
+// SCOPE: typography + palette adaptation only. Does NOT satisfy WCAG 2.1 AA
+// without an independent audit.
+
+import { useEffect, useState } from 'react';
+import { Eye } from 'lucide-react';
+
+interface Band {
+ id: string;
+ label: string;
+ tagline: string;
+}
+
+function bandFor(age: number): Band {
+ if (age <= 5) return { id: 'toddler', label: 'Toddler', tagline: 'Primary colors. One face good, one face sad.' };
+ if (age <= 9) return { id: 'kid', label: 'Elementary', tagline: 'Friendly emoji and clear names.' };
+ if (age <= 12) return { id: 'tween', label: 'Tween', tagline: 'Brighter palette, full info.' };
+ if (age <= 19) return { id: 'teen', label: 'Teen', tagline: 'Dense, dark, neon.' };
+ if (age <= 44) return { id: 'adult', label: 'Adult', tagline: 'Modern dashboard (default).' };
+ if (age <= 59) return { id: 'mature', label: 'Mature', tagline: 'Serif headings. Calmer pacing.' };
+ if (age <= 79) return { id: 'senior', label: 'Senior', tagline: 'High contrast, large text.' };
+ return { id: 'elder', label: 'Elder', tagline: 'Maximum contrast. Just the essentials.' };
+}
+
+const LS_KEY = 'norma.age-band';
+const DEFAULT_AGE = 30;
+
+export default function AgeThemeSlider() {
+ const [age, setAge] = useState<number>(DEFAULT_AGE);
+ const [mounted, setMounted] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+
+ // Hydrate from localStorage post-mount (avoids SSR mismatch).
+ useEffect(() => {
+ setMounted(true);
+ try {
+ const stored = localStorage.getItem(LS_KEY);
+ if (stored) {
+ const n = parseInt(stored, 10);
+ if (!Number.isNaN(n) && n >= 3 && n <= 95) setAge(n);
+ }
+ } catch {}
+ }, []);
+
+ // Apply band to <body> any time age changes (after mount).
+ useEffect(() => {
+ if (!mounted) return;
+ const b = bandFor(age);
+ if (typeof document !== 'undefined') document.body.dataset.band = b.id;
+ try { localStorage.setItem(LS_KEY, String(age)); } catch {}
+ }, [age, mounted]);
+
+ const band = bandFor(age);
+
+ return (
+ <div
+ style={{
+ position: 'sticky', top: 0, zIndex: 100,
+ padding: expanded ? '12px 16px' : '6px 16px',
+ background: 'var(--color-surface)',
+ borderBottom: '1px solid var(--color-border)',
+ display: 'flex', alignItems: 'center', gap: 12,
+ fontSize: 12, color: 'var(--color-text-secondary)',
+ transition: 'padding 0.2s',
+ }}
+ >
+ <button
+ onClick={() => setExpanded(p => !p)}
+ title="Adjust theme for viewer age"
+ style={{
+ display: 'inline-flex', alignItems: 'center', gap: 6,
+ background: 'transparent', border: 'none',
+ color: 'var(--color-text-secondary)', cursor: 'pointer',
+ fontSize: 12, padding: 0,
+ }}
+ >
+ <Eye size={14} />
+ <span style={{ fontWeight: 700, color: 'var(--color-text)' }}>{age}</span>
+ <span style={{ color: 'var(--color-text-muted)', fontSize: 11 }}>· {band.label}</span>
+ </button>
+
+ <input
+ type="range"
+ min={3}
+ max={95}
+ value={age}
+ onChange={e => setAge(Number(e.target.value))}
+ aria-label="Adjust theme for viewer age"
+ style={{ flex: 1, maxWidth: 360, accentColor: 'var(--color-primary)' }}
+ />
+
+ {expanded && (
+ <div style={{
+ fontSize: 11, color: 'var(--color-text-muted)',
+ fontStyle: 'italic', flexBasis: '100%', marginTop: 4,
+ }}>
+ {band.tagline} · Slider persists; reset to 30 (adult) anytime.
+ <button
+ onClick={() => setAge(DEFAULT_AGE)}
+ className="btn btn-ghost btn-sm"
+ style={{ marginLeft: 8, fontSize: 11, padding: '2px 8px' }}
+ >
+ Reset
+ </button>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/components/AppShell.tsx b/components/AppShell.tsx
index 0911420..d24cba8 100644
--- a/components/AppShell.tsx
+++ b/components/AppShell.tsx
@@ -79,6 +79,8 @@ import EmailSendsTab from './email-sends/EmailSendsTab';
import EmailAnalyzer from './email-analyzer/EmailAnalyzer';
import SavedAnalyses from './email-analyzer/SavedAnalyses';
import GmailCRMTab from './gmail/GmailCRMTab';
+import AgeThemeSlider from './AgeThemeSlider';
+import ApiRegistryButton from './api-registry/ApiRegistryButton';
import PriceTrackerTab from './price-tracker/PriceTrackerTab';
import SocialTab from './social/SocialTab';
@@ -511,6 +513,9 @@ function Shell() {
{/* ── Right: Header + Content ──────────────────────────────────────── */}
<div className="flex flex-col flex-1 min-w-0">
+ {/* ── Age-Adaptive Theme Slider (top of every page) ──────────────── */}
+ <AgeThemeSlider />
+
{/* ── Top Header Bar ─────────────────────────────────────────────── */}
<header
className="flex items-center justify-between px-5 h-14 shrink-0"
@@ -643,6 +648,8 @@ function Shell() {
{user}
</span>
)}
+ {/* APIs & Tokens registry — admin only */}
+ {role === 'admin' && <ApiRegistryButton variant="ghost" />}
<button
onClick={logout}
className="btn btn-ghost btn-sm"
diff --git a/components/api-registry/ApiRegistryButton.tsx b/components/api-registry/ApiRegistryButton.tsx
new file mode 100644
index 0000000..f0d4e62
--- /dev/null
+++ b/components/api-registry/ApiRegistryButton.tsx
@@ -0,0 +1,24 @@
+'use client';
+
+import { useState } from 'react';
+import { KeyRound } from 'lucide-react';
+import ApiRegistryModal from './ApiRegistryModal';
+
+// Single button + modal entry point. Drop anywhere admin-visible.
+export default function ApiRegistryButton({ variant = 'primary' }: { variant?: 'primary' | 'secondary' | 'ghost' }) {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ <button
+ onClick={() => setOpen(true)}
+ className={`btn btn-${variant}`}
+ style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
+ title="View every API, token, and MCP server Norma uses — with step-by-step instructions for each"
+ >
+ <KeyRound size={14} />
+ APIs & Tokens
+ </button>
+ {open && <ApiRegistryModal onClose={() => setOpen(false)} />}
+ </>
+ );
+}
diff --git a/components/api-registry/ApiRegistryModal.tsx b/components/api-registry/ApiRegistryModal.tsx
new file mode 100644
index 0000000..25ce8b1
--- /dev/null
+++ b/components/api-registry/ApiRegistryModal.tsx
@@ -0,0 +1,417 @@
+'use client';
+
+// Comprehensive admin-only API/Token/MCP registry modal. Lists every integration
+// Norma talks to, whether it's configured, and step-by-step instructions for
+// getting each key. Values never leak — server returns boolean set/missing only.
+
+import { useEffect, useMemo, useState } from 'react';
+import {
+ X, Search, CheckCircle2, AlertCircle, MinusCircle, Copy, ExternalLink,
+ ChevronDown, ChevronRight, Filter, Eye, Lock,
+} from 'lucide-react';
+
+type Status = 'set' | 'missing' | 'partial';
+
+interface Entry {
+ id: string;
+ name: string;
+ category: string;
+ purpose: string;
+ required: boolean;
+ envVars: string[];
+ status: Status;
+ signupUrl?: string;
+ docsUrl?: string;
+ scopes?: string[];
+ pricing?: string;
+ getSteps: string[];
+ envVarStatus: Record<string, boolean>;
+}
+
+interface RegistryPayload {
+ categories: string[];
+ entries: Entry[];
+ summary: {
+ total: number;
+ set: number;
+ partial: number;
+ missing: number;
+ requiredMissing: number;
+ };
+}
+
+function StatusBadge({ status, required }: { status: Status; required: boolean }) {
+ if (status === 'set') {
+ return (
+ <span style={{
+ display: 'inline-flex', alignItems: 'center', gap: 4,
+ padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 600,
+ background: 'rgba(16,185,129,0.15)', color: '#34d399',
+ }}>
+ <CheckCircle2 size={12} /> Configured
+ </span>
+ );
+ }
+ if (status === 'partial') {
+ return (
+ <span style={{
+ display: 'inline-flex', alignItems: 'center', gap: 4,
+ padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 600,
+ background: 'rgba(245,158,11,0.15)', color: '#fbbf24',
+ }}>
+ <AlertCircle size={12} /> Partial
+ </span>
+ );
+ }
+ return (
+ <span style={{
+ display: 'inline-flex', alignItems: 'center', gap: 4,
+ padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 600,
+ background: required ? 'rgba(244,63,94,0.18)' : 'rgba(160,160,180,0.12)',
+ color: required ? '#fb7185' : '#9ca3af',
+ }}>
+ {required ? <AlertCircle size={12} /> : <MinusCircle size={12} />}
+ {required ? 'Missing (required)' : 'Not configured'}
+ </span>
+ );
+}
+
+function EntryCard({ entry }: { entry: Entry }) {
+ const [expanded, setExpanded] = useState(false);
+ const [copiedVar, setCopiedVar] = useState<string | null>(null);
+
+ const copyVar = (v: string) => {
+ navigator.clipboard.writeText(v + '=');
+ setCopiedVar(v);
+ setTimeout(() => setCopiedVar(null), 1400);
+ };
+
+ return (
+ <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
+ <button
+ onClick={() => setExpanded(p => !p)}
+ style={{
+ width: '100%', padding: '14px 16px',
+ display: 'flex', alignItems: 'flex-start', gap: 12,
+ background: 'transparent', border: 'none', color: 'inherit',
+ textAlign: 'left', cursor: 'pointer',
+ }}
+ >
+ <div style={{ paddingTop: 2 }}>
+ {expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
+ </div>
+ <div style={{ flex: 1, minWidth: 0 }}>
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 4 }}>
+ <strong style={{ fontSize: 14, color: 'var(--color-text)' }}>{entry.name}</strong>
+ <StatusBadge status={entry.status} required={entry.required} />
+ {entry.required && (
+ <span style={{
+ fontSize: 10, fontWeight: 700, letterSpacing: '0.06em',
+ color: '#fbbf24', textTransform: 'uppercase',
+ }}>REQUIRED</span>
+ )}
+ </div>
+ <p style={{ fontSize: 12, color: 'var(--color-text-secondary)', margin: 0, lineHeight: 1.4 }}>
+ {entry.purpose}
+ </p>
+ </div>
+ </button>
+
+ {expanded && (
+ <div style={{
+ padding: '0 16px 16px 44px',
+ borderTop: '1px solid var(--color-border)',
+ background: 'rgba(0,0,0,0.12)',
+ }}>
+ {/* env vars */}
+ {entry.envVars.length > 0 && (
+ <div style={{ marginTop: 14 }}>
+ <div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--color-text-muted)', marginBottom: 6 }}>
+ Env vars
+ </div>
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
+ {entry.envVars.map(v => {
+ const isSet = entry.envVarStatus[v];
+ return (
+ <div key={v} style={{
+ display: 'flex', alignItems: 'center', gap: 8,
+ padding: '4px 8px', borderRadius: 4,
+ background: 'var(--color-surface-el)',
+ fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 12,
+ }}>
+ {isSet
+ ? <CheckCircle2 size={12} color="#34d399" />
+ : <Lock size={12} color="#9ca3af" />}
+ <span style={{ color: isSet ? 'var(--color-text)' : 'var(--color-text-muted)', flex: 1 }}>{v}</span>
+ <button
+ onClick={() => copyVar(v)}
+ title={`Copy "${v}=" to clipboard`}
+ style={{
+ display: 'inline-flex', alignItems: 'center', gap: 4,
+ background: 'transparent', border: '1px solid var(--color-border)',
+ color: 'var(--color-text-muted)', padding: '2px 6px',
+ borderRadius: 4, fontSize: 10, cursor: 'pointer',
+ }}
+ >
+ <Copy size={10} />
+ {copiedVar === v ? 'copied' : 'copy'}
+ </button>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ )}
+
+ {/* pricing */}
+ {entry.pricing && (
+ <div style={{ marginTop: 12, fontSize: 12, color: 'var(--color-text-secondary)' }}>
+ <span style={{ fontWeight: 700, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.08em', fontSize: 10 }}>Pricing · </span>
+ {entry.pricing}
+ </div>
+ )}
+
+ {/* scopes */}
+ {entry.scopes && entry.scopes.length > 0 && (
+ <div style={{ marginTop: 10 }}>
+ <div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--color-text-muted)', marginBottom: 4 }}>
+ OAuth scopes
+ </div>
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
+ {entry.scopes.map(s => (
+ <code key={s} style={{
+ fontSize: 10, padding: '2px 6px', borderRadius: 3,
+ background: 'var(--color-surface-el)', color: 'var(--color-text-secondary)',
+ }}>{s}</code>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* how to get */}
+ <div style={{ marginTop: 14 }}>
+ <div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--color-text-muted)', marginBottom: 6 }}>
+ How to get this
+ </div>
+ <ol style={{ margin: 0, paddingLeft: 20, color: 'var(--color-text-secondary)', fontSize: 12, lineHeight: 1.55 }}>
+ {entry.getSteps.map((step, i) => (
+ <li key={i} style={{ marginBottom: 4 }}>{step}</li>
+ ))}
+ </ol>
+ </div>
+
+ {/* links */}
+ <div style={{ marginTop: 12, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
+ {entry.signupUrl && (
+ <a
+ href={entry.signupUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="btn btn-primary btn-sm"
+ style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
+ >
+ <ExternalLink size={12} />
+ Sign up / Get key
+ </a>
+ )}
+ {entry.docsUrl && (
+ <a
+ href={entry.docsUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="btn btn-secondary btn-sm"
+ style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
+ >
+ <ExternalLink size={12} />
+ Docs
+ </a>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default function ApiRegistryModal({ onClose }: { onClose: () => void }) {
+ const [data, setData] = useState<RegistryPayload | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [err, setErr] = useState<string | null>(null);
+ const [search, setSearch] = useState('');
+ const [statusFilter, setStatusFilter] = useState<'all' | 'set' | 'missing' | 'required-missing'>('all');
+
+ useEffect(() => {
+ let alive = true;
+ fetch('/api/registry', { credentials: 'same-origin' })
+ .then(r => {
+ if (!r.ok) throw new Error(r.status === 403 ? 'Admin only' : `HTTP ${r.status}`);
+ return r.json();
+ })
+ .then(d => { if (alive) { setData(d as RegistryPayload); setLoading(false); } })
+ .catch(e => { if (alive) { setErr(String(e.message || e)); setLoading(false); } });
+ return () => { alive = false; };
+ }, []);
+
+ const filtered = useMemo(() => {
+ if (!data) return [];
+ const q = search.trim().toLowerCase();
+ return data.entries.filter(e => {
+ // Status filter
+ if (statusFilter === 'set' && e.status !== 'set') return false;
+ if (statusFilter === 'missing' && e.status === 'set') return false;
+ if (statusFilter === 'required-missing' && !(e.required && e.status !== 'set')) return false;
+ // Search
+ if (!q) return true;
+ const hay = [e.name, e.purpose, e.category, ...e.envVars, ...(e.scopes || [])].join(' ').toLowerCase();
+ return hay.includes(q);
+ });
+ }, [data, search, statusFilter]);
+
+ const grouped = useMemo(() => {
+ if (!data) return new Map<string, Entry[]>();
+ const m = new Map<string, Entry[]>();
+ for (const cat of data.categories) m.set(cat, []);
+ for (const e of filtered) {
+ const list = m.get(e.category) || [];
+ list.push(e);
+ m.set(e.category, list);
+ }
+ // Drop empty categories
+ for (const [k, v] of m) if (v.length === 0) m.delete(k);
+ return m;
+ }, [data, filtered]);
+
+ return (
+ <div
+ onClick={onClose}
+ style={{
+ position: 'fixed', inset: 0, zIndex: 200,
+ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ padding: 20,
+ }}
+ >
+ <div
+ onClick={e => e.stopPropagation()}
+ style={{
+ background: 'var(--color-bg)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 'var(--radius-lg)',
+ width: '100%', maxWidth: 920, maxHeight: '92vh',
+ display: 'flex', flexDirection: 'column',
+ boxShadow: '0 24px 80px rgba(0,0,0,0.6)',
+ }}
+ >
+ {/* Header */}
+ <div style={{
+ padding: '16px 20px', borderBottom: '1px solid var(--color-border)',
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+ }}>
+ <div>
+ <div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>
+ APIs, Tokens & MCP Servers
+ </div>
+ <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 2 }}>
+ Comprehensive admin view. Values never leave the server — set/missing only.
+ </div>
+ </div>
+ <button
+ onClick={onClose}
+ className="btn btn-ghost btn-sm"
+ style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
+ aria-label="Close"
+ >
+ <X size={14} />
+ </button>
+ </div>
+
+ {/* Summary bar */}
+ {data && (
+ <div style={{
+ padding: '10px 20px', borderBottom: '1px solid var(--color-border)',
+ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-secondary)',
+ flexWrap: 'wrap', background: 'var(--color-surface)',
+ }}>
+ <span><strong style={{ color: 'var(--color-text)' }}>{data.summary.total}</strong> total</span>
+ <span style={{ color: '#34d399' }}>● {data.summary.set} configured</span>
+ <span style={{ color: '#fbbf24' }}>● {data.summary.partial} partial</span>
+ <span style={{ color: '#fb7185' }}>● {data.summary.missing} missing</span>
+ {data.summary.requiredMissing > 0 && (
+ <span style={{ color: '#fb7185', fontWeight: 700 }}>
+ ⚠ {data.summary.requiredMissing} REQUIRED missing
+ </span>
+ )}
+ </div>
+ )}
+
+ {/* Controls */}
+ <div style={{
+ padding: '12px 20px', display: 'flex', gap: 10, alignItems: 'center',
+ borderBottom: '1px solid var(--color-border)', flexWrap: 'wrap',
+ }}>
+ <div style={{ position: 'relative', flex: 1, minWidth: 220 }}>
+ <Search size={14} style={{
+ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
+ color: 'var(--color-text-muted)',
+ }} />
+ <input
+ className="input"
+ placeholder="Search by name, env var, scope, purpose..."
+ value={search}
+ onChange={e => setSearch(e.target.value)}
+ style={{ paddingLeft: 32, fontSize: 13 }}
+ />
+ </div>
+ <select
+ className="input"
+ value={statusFilter}
+ onChange={e => setStatusFilter(e.target.value as typeof statusFilter)}
+ style={{ fontSize: 12, padding: '6px 10px', minWidth: 180 }}
+ >
+ <option value="all">All statuses</option>
+ <option value="set">Configured only</option>
+ <option value="missing">Missing / partial</option>
+ <option value="required-missing">⚠ Required & missing</option>
+ </select>
+ </div>
+
+ {/* Content */}
+ <div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>
+ {loading && (
+ <div style={{ textAlign: 'center', color: 'var(--color-text-muted)', padding: 60 }}>
+ Loading registry...
+ </div>
+ )}
+ {err && (
+ <div style={{
+ padding: 16, borderRadius: 'var(--radius-md)',
+ background: 'rgba(244,63,94,0.12)', color: '#fb7185',
+ }}>
+ {err}
+ </div>
+ )}
+ {!loading && !err && data && grouped.size === 0 && (
+ <div style={{ textAlign: 'center', color: 'var(--color-text-muted)', padding: 60 }}>
+ No integrations match.
+ </div>
+ )}
+ {Array.from(grouped.entries()).map(([cat, list]) => (
+ <div key={cat} style={{ marginBottom: 24 }}>
+ <div style={{
+ fontSize: 11, fontWeight: 700, textTransform: 'uppercase',
+ letterSpacing: '0.12em', color: 'var(--color-text-muted)',
+ marginBottom: 10, paddingBottom: 6,
+ borderBottom: '1px solid var(--color-border)',
+ }}>
+ {cat} <span style={{ color: 'var(--color-text-muted)', fontWeight: 400 }}>({list.length})</span>
+ </div>
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
+ {list.map(e => <EntryCard key={e.id} entry={e} />)}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/lib/api-registry.ts b/lib/api-registry.ts
new file mode 100644
index 0000000..033119e
--- /dev/null
+++ b/lib/api-registry.ts
@@ -0,0 +1,673 @@
+// Comprehensive registry of every API, token, OAuth credential, and MCP server
+// Norma talks to. The /api/registry endpoint reads this list and reports
+// "is this env var set?" for each entry — values never leak to the client.
+//
+// To add a new integration: append an entry. Keep `envVars` to the literal names
+// from `process.env.*` in code; the status endpoint introspects directly.
+
+export type IntegrationStatus = 'set' | 'missing' | 'partial';
+
+export interface IntegrationEntry {
+ id: string;
+ name: string;
+ category:
+ | 'Gmail & Google'
+ | 'AI & LLM'
+ | 'Social Media'
+ | 'Government Data'
+ | 'Organizing & CRM'
+ | 'Outbound Mail'
+ | 'Internal Auth'
+ | 'Webhooks & Cron'
+ | 'MCP Servers'
+ | 'Internal Services';
+ purpose: string;
+ envVars: string[];
+ // All envVars must be set → 'set'; some set → 'partial'; none → 'missing'.
+ // Or 'optional' if the integration runs without it.
+ required: boolean;
+ // Where to go to get this credential.
+ signupUrl?: string;
+ docsUrl?: string;
+ // Numbered steps to obtain the token. Plain-English, copy-friendly.
+ getSteps: string[];
+ // Optional scopes / permissions to ask for.
+ scopes?: string[];
+ // Free tier / pricing note.
+ pricing?: string;
+}
+
+export const INTEGRATIONS: IntegrationEntry[] = [
+ // ── GMAIL & GOOGLE ─────────────────────────────────────────────────────
+ {
+ id: 'gmail-oauth',
+ name: 'Gmail OAuth (Send / Read / Sync)',
+ category: 'Gmail & Google',
+ purpose:
+ 'Pulls natalia@studentdebtcrisis.org messages into the Gmail CRM, sends replies, syncs every 15 min. Project: norma-490521.',
+ envVars: ['GMAIL_CLIENT_ID', 'GMAIL_CLIENT_SECRET', 'GMAIL_REDIRECT_URI'],
+ required: true,
+ signupUrl: 'https://console.cloud.google.com/apis/credentials',
+ docsUrl: 'https://developers.google.com/gmail/api/quickstart/nodejs',
+ pricing: 'Free up to 1B quota units/day (≈ 1B reads or ~100M sends)',
+ scopes: [
+ 'https://www.googleapis.com/auth/gmail.readonly',
+ 'https://www.googleapis.com/auth/gmail.send',
+ 'https://www.googleapis.com/auth/gmail.modify',
+ ],
+ getSteps: [
+ 'Open Google Cloud Console → Project: norma-490521 (or create new).',
+ 'APIs & Services → Library → enable "Gmail API".',
+ 'APIs & Services → OAuth consent screen → External → fill app name + support email.',
+ 'APIs & Services → Credentials → Create credentials → OAuth client ID → Web application.',
+ 'Authorized redirect URIs: add https://<your-norma-host>/api/gmail/callback',
+ 'Copy Client ID → GMAIL_CLIENT_ID, Client Secret → GMAIL_CLIENT_SECRET.',
+ 'Set GMAIL_REDIRECT_URI to the URI you registered above.',
+ 'In Norma: Settings → Connect Gmail → walk through the consent flow once to mint refresh token.',
+ ],
+ },
+ {
+ id: 'google-oauth',
+ name: 'Google OAuth (Sign-in / Drive / Calendar)',
+ category: 'Gmail & Google',
+ purpose:
+ 'Lets org staff sign in with Google and (optionally) read Google Drive / Calendar for the org.',
+ envVars: ['GOOGLE_OAUTH_CLIENT_ID', 'GOOGLE_OAUTH_CLIENT_SECRET', 'GOOGLE_OAUTH_REDIRECT_URI'],
+ required: false,
+ signupUrl: 'https://console.cloud.google.com/apis/credentials',
+ docsUrl: 'https://developers.google.com/identity/protocols/oauth2',
+ pricing: 'Free',
+ getSteps: [
+ 'Google Cloud Console → same project (norma-490521).',
+ 'OAuth consent screen → make sure scopes include: openid, email, profile.',
+ 'Credentials → Create OAuth client ID → Web application.',
+ 'Redirect URI: https://<your-norma-host>/api/auth/google/callback',
+ 'Copy ID & Secret into env vars above.',
+ ],
+ },
+
+ // ── AI & LLM ────────────────────────────────────────────────────────────
+ {
+ id: 'gemini',
+ name: 'Gemini 2.0 Flash (text + analysis)',
+ category: 'AI & LLM',
+ purpose:
+ 'Powers AI draft replies in Gmail CRM, email analyzer summaries, news triage, petition copy generation.',
+ envVars: ['GEMINI_API_KEY'],
+ required: true,
+ signupUrl: 'https://aistudio.google.com/apikey',
+ docsUrl: 'https://ai.google.dev/gemini-api/docs',
+ pricing: 'Free tier: 15 RPM / 1M tokens/min / 1.5K req/day on gemini-2.0-flash',
+ getSteps: [
+ 'Visit https://aistudio.google.com/apikey (sign in with the same Google account that owns norma-490521 ideally).',
+ 'Click "Create API Key" → pick the Google Cloud project (norma-490521).',
+ 'Copy the key starting with "AIza...".',
+ 'Paste into .env.local: GEMINI_API_KEY=AIza...',
+ 'Restart pm2: pm2 restart norma-email',
+ ],
+ },
+ {
+ id: 'gemini-image',
+ name: 'Gemini Image (vision + image generation)',
+ category: 'AI & LLM',
+ purpose:
+ 'Used for image-aware analysis (logos, scans of letters) and one-off image generation. Same provider as Gemini text, often the same key works.',
+ envVars: ['GEMINI_IMAGE_API_KEY'],
+ required: false,
+ signupUrl: 'https://aistudio.google.com/apikey',
+ docsUrl: 'https://ai.google.dev/gemini-api/docs/image-generation',
+ pricing: 'Free tier shared with Gemini text',
+ getSteps: [
+ 'If you already have GEMINI_API_KEY, paste the same value into GEMINI_IMAGE_API_KEY.',
+ 'Otherwise, follow the Gemini steps above to create a key.',
+ ],
+ },
+
+ // ── GOVERNMENT DATA ────────────────────────────────────────────────────
+ {
+ id: 'congress',
+ name: 'Congress.gov API',
+ category: 'Government Data',
+ purpose: 'Real-time bills, members, committees, votes for the Policy & Bills lane.',
+ envVars: ['CONGRESS_API_KEY'],
+ required: false,
+ signupUrl: 'https://api.congress.gov/sign-up/',
+ docsUrl: 'https://api.congress.gov/',
+ pricing: 'Free, 5,000 req/hour',
+ getSteps: [
+ 'Visit https://api.congress.gov/sign-up/',
+ 'Enter name + email → key arrives in inbox within 1 min.',
+ 'Paste into CONGRESS_API_KEY.',
+ ],
+ },
+ {
+ id: 'propublica',
+ name: 'ProPublica Congress API',
+ category: 'Government Data',
+ purpose:
+ 'Alternate Congress data source — better for member voting history, bill-text diffs, statement archives.',
+ envVars: ['PROPUBLICA_API_KEY'],
+ required: false,
+ signupUrl: 'https://www.propublica.org/datastore/api/propublica-congress-api',
+ docsUrl: 'https://projects.propublica.org/api-docs/congress-api/',
+ pricing: 'Free for non-commercial use',
+ getSteps: [
+ 'Visit https://www.propublica.org/datastore/api/propublica-congress-api',
+ 'Click "Request API Key" → fill out form (use organization email).',
+ 'Approval takes 1–3 business days (humans approve).',
+ 'Paste into PROPUBLICA_API_KEY when it arrives.',
+ ],
+ },
+ {
+ id: 'bls',
+ name: 'BLS (Bureau of Labor Statistics)',
+ category: 'Government Data',
+ purpose: 'Employment, wage, inflation data for student-debt economic context.',
+ envVars: ['BLS_API_KEY'],
+ required: false,
+ signupUrl: 'https://data.bls.gov/registrationEngine/',
+ docsUrl: 'https://www.bls.gov/developers/',
+ pricing: 'Free, 500 req/day registered (25/day anonymous)',
+ getSteps: [
+ 'Visit https://data.bls.gov/registrationEngine/',
+ 'Register with email → instant key delivery.',
+ 'Paste into BLS_API_KEY.',
+ ],
+ },
+ {
+ id: 'census',
+ name: 'US Census API',
+ category: 'Government Data',
+ purpose: 'Demographics by state/county for borrower segmentation maps.',
+ envVars: ['CENSUS_API_KEY'],
+ required: false,
+ signupUrl: 'https://api.census.gov/data/key_signup.html',
+ docsUrl: 'https://www.census.gov/data/developers/data-sets.html',
+ pricing: 'Free, unlimited with key',
+ getSteps: [
+ 'Visit https://api.census.gov/data/key_signup.html',
+ 'Fill in name + email + org → instant email with key.',
+ 'Paste into CENSUS_API_KEY.',
+ ],
+ },
+ {
+ id: 'fred',
+ name: 'FRED (St. Louis Fed Economic Data)',
+ category: 'Government Data',
+ purpose: 'Economic indicators — inflation, unemployment, student-debt totals time series.',
+ envVars: ['FRED_API_KEY'],
+ required: false,
+ signupUrl: 'https://fredaccount.stlouisfed.org/apikeys',
+ docsUrl: 'https://fred.stlouisfed.org/docs/api/fred/',
+ pricing: 'Free, unlimited',
+ getSteps: [
+ 'Create a FRED account → https://fredaccount.stlouisfed.org/login/secure/',
+ 'Visit https://fredaccount.stlouisfed.org/apikeys → "Request API Key" → instant.',
+ 'Paste into FRED_API_KEY.',
+ ],
+ },
+ {
+ id: 'scorecard',
+ name: 'College Scorecard',
+ category: 'Government Data',
+ purpose: 'Per-school cost of attendance, completion rates, median earnings.',
+ envVars: ['SCORECARD_API_KEY'],
+ required: false,
+ signupUrl: 'https://api.data.gov/signup/',
+ docsUrl: 'https://collegescorecard.ed.gov/data/api-documentation/',
+ pricing: 'Free, 1,000 req/hour per key',
+ getSteps: [
+ 'Visit https://api.data.gov/signup/ (this is the umbrella key for ALL data.gov APIs).',
+ 'Fill in name + email + intended use → instant key.',
+ 'Paste into SCORECARD_API_KEY.',
+ ],
+ },
+
+ // ── ORGANIZING & CRM ───────────────────────────────────────────────────
+ {
+ id: 'action-network',
+ name: 'Action Network',
+ category: 'Organizing & CRM',
+ purpose: 'Push petition signers + advocacy actions into SDCC\'s Action Network instance.',
+ envVars: ['ACTION_NETWORK_API_KEY'],
+ required: false,
+ signupUrl: 'https://actionnetwork.org/',
+ docsUrl: 'https://actionnetwork.org/docs',
+ pricing: 'Free for orgs with active AN accounts',
+ getSteps: [
+ 'Log into Action Network as SDCC admin.',
+ 'Top right avatar → "Start Organizing" → Details → API & Sync.',
+ 'Click "Generate New API Key" — copy the value once shown.',
+ 'Paste into ACTION_NETWORK_API_KEY.',
+ ],
+ },
+ {
+ id: 'clickup',
+ name: 'ClickUp',
+ category: 'Organizing & CRM',
+ purpose: 'Task syncing for staff workflow.',
+ envVars: ['CLICKUP_API_KEY'],
+ required: false,
+ signupUrl: 'https://clickup.com/api',
+ docsUrl: 'https://clickup.com/api/developer-portal/authentication/',
+ pricing: 'Free personal token',
+ getSteps: [
+ 'Log into ClickUp → avatar → Settings.',
+ 'Apps → "Generate" under API Token.',
+ 'Copy the pk_... value → paste into CLICKUP_API_KEY.',
+ ],
+ },
+
+ // ── SOCIAL MEDIA ───────────────────────────────────────────────────────
+ {
+ id: 'twitter',
+ name: 'Twitter / X',
+ category: 'Social Media',
+ purpose: 'Post + monitor tweets, track mentions of @SDCC.',
+ envVars: [
+ 'TWITTER_API_KEY',
+ 'TWITTER_API_SECRET',
+ 'TWITTER_ACCESS_TOKEN',
+ 'TWITTER_ACCESS_SECRET',
+ 'TWITTER_BEARER_TOKEN',
+ 'X_BEARER_TOKEN',
+ ],
+ required: false,
+ signupUrl: 'https://developer.twitter.com/en/portal/dashboard',
+ docsUrl: 'https://developer.twitter.com/en/docs/twitter-api',
+ pricing: 'Free tier: 1.5k tweets/month read, 50/day write. Basic tier $200/mo.',
+ getSteps: [
+ 'Apply for developer access: https://developer.twitter.com/en/portal/petition/essential/basic-info',
+ 'Approval is automatic for free tier.',
+ 'Create a Project + App → "Keys and tokens" tab.',
+ 'Generate API Key + Secret → TWITTER_API_KEY / TWITTER_API_SECRET.',
+ 'Generate Access Token + Secret → TWITTER_ACCESS_TOKEN / TWITTER_ACCESS_SECRET.',
+ 'Generate Bearer Token → TWITTER_BEARER_TOKEN (also paste into X_BEARER_TOKEN — same value, alias).',
+ ],
+ },
+ {
+ id: 'bluesky',
+ name: 'BlueSky',
+ category: 'Social Media',
+ purpose: 'Post to + monitor SDCC\'s BlueSky account.',
+ envVars: ['BSKY_HANDLE', 'BSKY_PASSWORD', 'BSKY_SERVICE'],
+ required: false,
+ signupUrl: 'https://bsky.app/settings/app-passwords',
+ docsUrl: 'https://docs.bsky.app/',
+ pricing: 'Free',
+ getSteps: [
+ 'Log into BlueSky as the SDCC account.',
+ 'Settings → Privacy and Security → App Passwords.',
+ 'Click "Add App Password" → name it "Norma" → copy the abcd-efgh-ijkl-mnop value.',
+ 'Paste handle (e.g. sdcc.bsky.social) → BSKY_HANDLE.',
+ 'Paste the app password (NOT your login password) → BSKY_PASSWORD.',
+ 'Set BSKY_SERVICE=https://bsky.social',
+ ],
+ },
+ {
+ id: 'reddit',
+ name: 'Reddit',
+ category: 'Social Media',
+ purpose: 'Read r/StudentLoans, r/StudentDebt for borrower stories + sentiment.',
+ envVars: [
+ 'REDDIT_CLIENT_ID',
+ 'REDDIT_CLIENT_SECRET',
+ 'REDDIT_USERNAME',
+ 'REDDIT_PASSWORD',
+ 'REDDIT_USER_AGENT',
+ ],
+ required: false,
+ signupUrl: 'https://www.reddit.com/prefs/apps',
+ docsUrl: 'https://www.reddit.com/dev/api/',
+ pricing: 'Free, 60 req/min',
+ getSteps: [
+ 'Log into Reddit as a service account (NOT a personal one).',
+ 'Visit https://www.reddit.com/prefs/apps → "create another app".',
+ 'Type: "script" — about URL: https://studentdebtcrisis.org, redirect: http://localhost.',
+ 'Client ID = string under app name. Secret = "secret" field. → REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET.',
+ 'Username + password of that service account → REDDIT_USERNAME / REDDIT_PASSWORD.',
+ 'REDDIT_USER_AGENT="Norma/1.0 by u/<your-reddit-user>"',
+ ],
+ },
+ {
+ id: 'facebook',
+ name: 'Facebook Page (Meta Graph API)',
+ category: 'Social Media',
+ purpose: 'Post to SDCC\'s Facebook Page, fetch insights.',
+ envVars: ['FB_APP_ID', 'FB_APP_SECRET', 'FB_PAGE_ACCESS_TOKEN', 'FB_PAGE_ID'],
+ required: false,
+ signupUrl: 'https://developers.facebook.com/apps',
+ docsUrl: 'https://developers.facebook.com/docs/pages-api',
+ pricing: 'Free',
+ getSteps: [
+ 'developers.facebook.com → My Apps → Create App → "Business" type.',
+ 'Add product "Facebook Login" + "Pages API".',
+ 'Settings → Basic → App ID + App Secret → FB_APP_ID / FB_APP_SECRET.',
+ 'Graph API Explorer → switch to SDCC Page → "Get Page Access Token" → check pages_show_list + pages_read_engagement + pages_manage_posts.',
+ 'Extend the short-lived token to long-lived (60-day) using /oauth/access_token endpoint, then to never-expiring by passing the long-lived page token.',
+ 'Paste the never-expiring page token → FB_PAGE_ACCESS_TOKEN. Page ID → FB_PAGE_ID.',
+ ],
+ },
+ {
+ id: 'instagram',
+ name: 'Instagram (via FB Page)',
+ category: 'Social Media',
+ purpose: 'Post + analytics for SDCC IG account.',
+ envVars: ['IG_ACCESS_TOKEN', 'IG_USER_ID'],
+ required: false,
+ signupUrl: 'https://developers.facebook.com/apps',
+ docsUrl: 'https://developers.facebook.com/docs/instagram-platform/',
+ pricing: 'Free',
+ getSteps: [
+ 'IG must be a "Professional/Business" account linked to a Facebook Page.',
+ 'Use the same FB App as above. Add product "Instagram Graph API".',
+ 'Graph API Explorer → call /me/accounts → find your page → /<page-id>?fields=instagram_business_account.',
+ 'That returns the IG user ID → IG_USER_ID.',
+ 'Use the long-lived page token (from Facebook setup) → IG_ACCESS_TOKEN.',
+ ],
+ },
+ {
+ id: 'discord',
+ name: 'Discord Bot',
+ category: 'Social Media',
+ purpose: 'Listen for org-staff slash commands, post alerts to internal Discord.',
+ envVars: ['DISCORD_BOT_TOKEN'],
+ required: false,
+ signupUrl: 'https://discord.com/developers/applications',
+ docsUrl: 'https://discord.com/developers/docs/intro',
+ pricing: 'Free',
+ getSteps: [
+ 'Visit https://discord.com/developers/applications → New Application.',
+ 'Bot tab → "Add Bot" → Yes.',
+ 'Reset Token → copy the bot token (you only see it once).',
+ 'Paste into DISCORD_BOT_TOKEN.',
+ 'OAuth2 → URL Generator → scopes: bot + applications.commands → bot perms: Send Messages.',
+ 'Use the generated URL to invite the bot to your server.',
+ ],
+ },
+ {
+ id: 'twitch',
+ name: 'Twitch (livestream alerts)',
+ category: 'Social Media',
+ purpose: 'Detect when SDCC goes live + post cross-channel.',
+ envVars: ['TWITCH_CLIENT_ID', 'TWITCH_CLIENT_SECRET', 'TWITCH_OAUTH_TOKEN', 'TWITCH_BOT_USERNAME'],
+ required: false,
+ signupUrl: 'https://dev.twitch.tv/console/apps',
+ docsUrl: 'https://dev.twitch.tv/docs/api/',
+ pricing: 'Free',
+ getSteps: [
+ 'Visit https://dev.twitch.tv/console/apps → Register Your Application.',
+ 'Name: Norma · OAuth Redirect: http://localhost:7400 · Category: Application Integration.',
+ 'Client ID + New Secret → TWITCH_CLIENT_ID / TWITCH_CLIENT_SECRET.',
+ 'For TWITCH_OAUTH_TOKEN: use https://twitchapps.com/tmi to generate a chat OAuth token.',
+ 'TWITCH_BOT_USERNAME = the Twitch username that owns the bot.',
+ ],
+ },
+
+ // ── OUTBOUND MAIL ──────────────────────────────────────────────────────
+ {
+ id: 'smtp',
+ name: 'SMTP (transactional email)',
+ category: 'Outbound Mail',
+ purpose:
+ 'System emails — staff invites, password resets, weekly digests. Bypasses Gmail send quota.',
+ envVars: ['SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS', 'TEST_SEND_TO'],
+ required: false,
+ docsUrl: 'https://nodemailer.com/smtp/',
+ pricing: 'Provider-dependent: SES $0.10/1k · Postmark $10/10k · Resend free 3k/mo · Purelymail $10/mo unlimited',
+ getSteps: [
+ 'Pick a provider: Resend (https://resend.com), Postmark (https://postmarkapp.com), AWS SES, or Purelymail (Steve\'s default).',
+ 'Create an account → verify the sending domain (DKIM + SPF + DMARC records on studentdebtcrisis.org).',
+ 'Provider gives SMTP host (e.g. smtp.resend.com), port (587 or 465), user (resend), pass (re_xxxxx).',
+ 'Set SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS.',
+ 'TEST_SEND_TO = an address you can check during dev (yourself).',
+ ],
+ },
+
+ // ── INTERNAL AUTH ──────────────────────────────────────────────────────
+ {
+ id: 'database',
+ name: 'Postgres Database',
+ category: 'Internal Auth',
+ purpose: 'The sdcc database — every table the app reads/writes.',
+ envVars: ['DATABASE_URL'],
+ required: true,
+ docsUrl: 'https://node-postgres.com/features/connecting',
+ pricing: 'Self-hosted — Kamatera or local',
+ getSteps: [
+ 'Spin up Postgres locally or on Kamatera.',
+ 'CREATE DATABASE sdcc; CREATE USER dw_admin WITH PASSWORD \'xxx\';',
+ 'GRANT ALL PRIVILEGES ON DATABASE sdcc TO dw_admin;',
+ 'Set DATABASE_URL=postgres://dw_admin:xxx@127.0.0.1:5432/sdcc',
+ 'Run: psql -d sdcc -f db/schema.sql',
+ ],
+ },
+ {
+ id: 'basic-auth',
+ name: 'Admin Basic Auth',
+ category: 'Internal Auth',
+ purpose: 'Gates the Norma admin tier (top-level URL).',
+ envVars: ['AUTH_USERNAME', 'AUTH_PASSWORD'],
+ required: true,
+ pricing: 'Free — generate yourself',
+ getSteps: [
+ 'AUTH_USERNAME = admin (or whatever).',
+ 'AUTH_PASSWORD = pick a strong random string. Generate one with: openssl rand -base64 24',
+ 'Restart pm2 after changing.',
+ ],
+ },
+ {
+ id: 'session-secret',
+ name: 'Session Secret',
+ category: 'Internal Auth',
+ purpose: 'Signs auth cookies. Rotating it logs everyone out.',
+ envVars: ['SESSION_SECRET'],
+ required: true,
+ pricing: 'Free — generate yourself',
+ getSteps: [
+ 'Generate: openssl rand -hex 32',
+ 'Paste into SESSION_SECRET. Keep it secret. Never commit.',
+ ],
+ },
+
+ // ── WEBHOOKS & CRON ────────────────────────────────────────────────────
+ {
+ id: 'cron-secret',
+ name: 'Cron Secret',
+ category: 'Webhooks & Cron',
+ purpose:
+ 'Gates /api/cron/* endpoints so only the scheduled job (with the secret) can fire them.',
+ envVars: ['CRON_SECRET'],
+ required: true,
+ pricing: 'Free — generate yourself',
+ getSteps: [
+ 'Generate: openssl rand -hex 24',
+ 'Set CRON_SECRET.',
+ 'Cron jobs must send Authorization: Bearer <CRON_SECRET> with each call.',
+ ],
+ },
+ {
+ id: 'webhook-secret',
+ name: 'Webhook Secret',
+ category: 'Webhooks & Cron',
+ purpose: 'Verifies incoming webhook signatures (e.g., from Action Network, Slack events).',
+ envVars: ['WEBHOOK_SECRET'],
+ required: false,
+ pricing: 'Free — generate yourself',
+ getSteps: [
+ 'Generate: openssl rand -hex 24',
+ 'Set WEBHOOK_SECRET.',
+ 'Configure the same secret in the external service\'s webhook config.',
+ ],
+ },
+ {
+ id: 'slack',
+ name: 'Slack Webhook',
+ category: 'Webhooks & Cron',
+ purpose: 'Post alerts / digest summaries to a Slack channel.',
+ envVars: ['SLACK_WEBHOOK_URL', 'SLACK_VERIFICATION_TOKEN'],
+ required: false,
+ signupUrl: 'https://api.slack.com/apps',
+ docsUrl: 'https://api.slack.com/messaging/webhooks',
+ pricing: 'Free',
+ getSteps: [
+ 'Visit https://api.slack.com/apps → Create New App → From scratch.',
+ 'Pick the SDCC workspace.',
+ 'Incoming Webhooks → Activate → "Add New Webhook to Workspace" → pick a channel.',
+ 'Copy the https://hooks.slack.com/services/... URL → SLACK_WEBHOOK_URL.',
+ 'For SLACK_VERIFICATION_TOKEN: Basic Info → Verification Token (legacy but still emitted).',
+ ],
+ },
+
+ // ── INTERNAL SERVICES ──────────────────────────────────────────────────
+ {
+ id: 'george',
+ name: 'George (Gmail AI mailbox agent)',
+ category: 'Internal Services',
+ purpose: 'Steve\'s shared Gmail AI agent at theagentabrams@gmail.com. Norma asks it questions.',
+ envVars: ['GEORGE_URL', 'GEORGE_AUTH'],
+ required: false,
+ pricing: 'Self-hosted',
+ getSteps: [
+ 'George runs on Steve\'s prod server. Ask Steve for the URL + auth.',
+ 'GEORGE_URL = https://george.agentabrams.com (or whatever current host).',
+ 'GEORGE_AUTH = basic-auth string from Steve.',
+ ],
+ },
+ {
+ id: 'pulse',
+ name: 'Pulse (end-user tier API)',
+ category: 'Internal Services',
+ purpose: 'Cross-app API for Pulse petition signers + member-side actions.',
+ envVars: ['PULSE_API_URL', 'PULSE_API_USER', 'PULSE_API_PASS'],
+ required: false,
+ pricing: 'Internal',
+ getSteps: [
+ 'Pulse is part of this Norma deployment. Same host, different basic-auth credentials.',
+ 'PULSE_API_URL = https://<your-norma-host>/pulse-api',
+ 'PULSE_API_USER / PULSE_API_PASS = credentials minted by an admin.',
+ ],
+ },
+ {
+ id: 'norma-url',
+ name: 'Self URL',
+ category: 'Internal Services',
+ purpose:
+ 'How Norma refers to itself in outbound emails, OAuth callbacks, cron jobs. Must match the public hostname.',
+ envVars: ['NORMA_URL'],
+ required: true,
+ pricing: 'Free',
+ getSteps: [
+ 'Set to the canonical public URL of this deployment.',
+ 'Local dev: http://localhost:7400',
+ 'Prod: https://norma.<your-domain>',
+ 'OAuth redirects + email links use this — wrong value breaks Gmail consent flow.',
+ ],
+ },
+
+ // ── MCP SERVERS ────────────────────────────────────────────────────────
+ // These are the Claude Code MCP servers that Steve / staff might use FROM
+ // Claude Code TO talk to services Norma also uses. Listed here so an admin
+ // can see the full picture of what tokens live where.
+ {
+ id: 'mcp-gmail',
+ name: 'MCP: Gmail (claude_ai_Gmail)',
+ category: 'MCP Servers',
+ purpose:
+ 'Claude Code can read/send Gmail via MCP — uses its own OAuth, separate from Norma\'s GMAIL_* keys.',
+ envVars: [],
+ required: false,
+ signupUrl: 'https://github.com/GongRzhe/Gmail-MCP-Server',
+ docsUrl: 'https://github.com/GongRzhe/Gmail-MCP-Server',
+ pricing: 'Free',
+ getSteps: [
+ 'MCP Gmail is configured via Claude Code MCP settings, not Norma env vars.',
+ 'Open Claude Code → /mcp → list servers → claude_ai_Gmail should appear.',
+ 'Steve has this configured at the Claude-side level; nothing to do here.',
+ ],
+ },
+ {
+ id: 'mcp-google-calendar',
+ name: 'MCP: Google Calendar',
+ category: 'MCP Servers',
+ purpose: 'Claude Code → Google Calendar via MCP. Separate OAuth from Norma.',
+ envVars: [],
+ required: false,
+ docsUrl: 'https://github.com/v-3/google-calendar-mcp',
+ pricing: 'Free',
+ getSteps: [
+ 'Configured Claude-side at ~/.claude.json.',
+ 'Not consumed by Norma directly.',
+ ],
+ },
+ {
+ id: 'mcp-google-drive',
+ name: 'MCP: Google Drive',
+ category: 'MCP Servers',
+ purpose: 'Claude Code → Google Drive via MCP.',
+ envVars: [],
+ required: false,
+ docsUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/gdrive',
+ pricing: 'Free',
+ getSteps: [
+ 'Configured Claude-side at ~/.claude.json.',
+ 'Not consumed by Norma directly.',
+ ],
+ },
+ {
+ id: 'mcp-exa',
+ name: 'MCP: Exa (web research)',
+ category: 'MCP Servers',
+ purpose: 'Claude Code → Exa web search + deep research. Useful for news triage prep.',
+ envVars: [],
+ required: false,
+ signupUrl: 'https://dashboard.exa.ai/login',
+ docsUrl: 'https://docs.exa.ai/',
+ pricing: '$10 includes 25k searches; pay-as-you-go after.',
+ getSteps: [
+ 'Sign up at https://dashboard.exa.ai/login',
+ 'API Keys → Create → copy the value.',
+ 'The MCP server lives Claude-side. Token goes into ~/.claude.json env block — Steve\'s secrets-manager skill handles this.',
+ ],
+ },
+ {
+ id: 'mcp-george',
+ name: 'MCP: George (Gmail agent)',
+ category: 'MCP Servers',
+ purpose: 'Claude Code → George\'s API for mailbox queries across all info@<domain> accounts.',
+ envVars: [],
+ required: false,
+ pricing: 'Self-hosted',
+ getSteps: [
+ 'Configured Claude-side at ~/.claude.json.',
+ 'Norma talks to George via GEORGE_URL/GEORGE_AUTH (above) — different path.',
+ ],
+ },
+];
+
+// Status helper consumed by the API endpoint. Returns whether each env var
+// is set on the running process. Never returns the value itself.
+export function statusFor(entry: IntegrationEntry): IntegrationStatus {
+ if (entry.envVars.length === 0) return 'set'; // MCPs with no Norma-side env
+ let setCount = 0;
+ for (const v of entry.envVars) {
+ if (process.env[v] && String(process.env[v]).length > 0) setCount++;
+ }
+ if (setCount === 0) return 'missing';
+ if (setCount === entry.envVars.length) return 'set';
+ return 'partial';
+}
+
+// Distinct categories in display order.
+export const CATEGORY_ORDER: IntegrationEntry['category'][] = [
+ 'Gmail & Google',
+ 'AI & LLM',
+ 'Outbound Mail',
+ 'Government Data',
+ 'Organizing & CRM',
+ 'Social Media',
+ 'Webhooks & Cron',
+ 'Internal Auth',
+ 'Internal Services',
+ 'MCP Servers',
+];
← 2b2fc56 ux(gmail): land with full-width inbox — default showContacts
·
back to Norma
·
feat(auth): intern role + user fields + admin-editable role f6b2dba →