[object Object]

← 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

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 &amp; 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 →