← back to Norma Sdcc Pitch
add: sdcc + steve login gate, ElevenLabs voice cloning script for explainer video, hardened DEPLOY-NOW.sh
938360300ce6591be89d69be99f1b9a11a310f3a · 2026-05-20 08:52:18 -0700 · SteveStudio2
Files touched
A DEPLOY-NOW.shA scripts/make-explainer-video.jsM server.js
Diff
commit 938360300ce6591be89d69be99f1b9a11a310f3a
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 20 08:52:18 2026 -0700
add: sdcc + steve login gate, ElevenLabs voice cloning script for explainer video, hardened DEPLOY-NOW.sh
---
DEPLOY-NOW.sh | 131 +++++++++++++++++++++
scripts/make-explainer-video.js | 252 ++++++++++++++++++++++++++++++++++++++++
server.js | 107 ++++++++++++++++-
3 files changed, 489 insertions(+), 1 deletion(-)
diff --git a/DEPLOY-NOW.sh b/DEPLOY-NOW.sh
new file mode 100755
index 0000000..7db567c
--- /dev/null
+++ b/DEPLOY-NOW.sh
@@ -0,0 +1,131 @@
+#!/usr/bin/env bash
+# DEPLOY: sdcc.agentabrams.com — pitch viewer with SDCC login
+# Run from your laptop. Idempotent.
+set -e
+
+DOMAIN="sdcc.agentabrams.com"
+PORT="9877"
+PROJ="$HOME/Projects/norma-sdcc-pitch"
+REMOTE_PROJ="/root/Projects/norma-sdcc-pitch"
+KAMATERA_IP="45.61.58.125"
+
+SDCC_PASSWORD="${SDCC_PASSWORD:-Pulse-2026}"
+STEVE_PASSWORD="${STEVE_PASSWORD:-DWSecure2024!}"
+COOKIE_SECRET="${COOKIE_SECRET:-$(openssl rand -hex 32)}"
+
+echo "── 1. Add DNS A record via GoDaddy ──"
+source ~/Projects/secrets-manager/.env
+curl -fsSX PUT "https://api.godaddy.com/v1/domains/agentabrams.com/records/A/sdcc" \
+ -H "Authorization: sso-key ${GODADDY_API_KEY}:${GODADDY_API_SECRET}" \
+ -H "Content-Type: application/json" \
+ -d "[{\"data\":\"${KAMATERA_IP}\",\"ttl\":600}]" \
+ && echo " ✓ DNS A record set"
+
+echo
+echo "── 2. rsync code to Kamatera ──"
+rsync -aHz --delete \
+ --exclude node_modules --exclude .git --exclude videos --exclude '*.log' \
+ --exclude 'data/pitch-state' --exclude DEPLOY-NOW.sh \
+ "${PROJ}/" my-server:"${REMOTE_PROJ}/"
+echo " ✓ code synced"
+
+echo
+echo "── 3. write .env on remote with login secrets ──"
+ssh my-server "cat > ${REMOTE_PROJ}/.env <<ENV
+PORT=${PORT}
+SDCC_PASSWORD=${SDCC_PASSWORD}
+STEVE_PASSWORD=${STEVE_PASSWORD}
+COOKIE_SECRET=${COOKIE_SECRET}
+NORMA_BASE=http://localhost:7400
+NODE_ENV=production
+ENV
+chmod 600 ${REMOTE_PROJ}/.env
+echo ' ✓ .env written'"
+
+echo
+echo "── 4. install deps + pm2 ──"
+ssh my-server bash <<EOF
+set -e
+cd ${REMOTE_PROJ}
+npm install --no-fund --no-audit --omit=dev 2>&1 | tail -3
+# kill anything on :${PORT} first
+fuser -k -n tcp ${PORT} 2>/dev/null || true
+sleep 1
+pm2 delete norma-sdcc-pitch 2>/dev/null || true
+pm2 start server.js --name norma-sdcc-pitch --update-env --env production --node-args=""
+pm2 save
+sleep 2
+pm2 list | grep norma-sdcc-pitch
+EOF
+
+echo
+echo "── 5. nginx server block ──"
+ssh my-server bash <<EOF
+set -e
+cat > /etc/nginx/sites-available/${DOMAIN} <<'NGINX'
+server {
+ listen 80;
+ server_name DOMAIN_PLACEHOLDER;
+ access_log /var/log/nginx/sdcc-pitch.access.log;
+ error_log /var/log/nginx/sdcc-pitch.error.log;
+
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header X-Content-Type-Options nosniff always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+
+ location / {
+ proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;
+ proxy_http_version 1.1;
+ proxy_set_header Host \$host;
+ proxy_set_header X-Real-IP \$remote_addr;
+ proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto \$scheme;
+ proxy_read_timeout 60s;
+ }
+}
+NGINX
+sed -i "s/DOMAIN_PLACEHOLDER/${DOMAIN}/g; s/PORT_PLACEHOLDER/${PORT}/g" /etc/nginx/sites-available/${DOMAIN}
+ln -sf /etc/nginx/sites-available/${DOMAIN} /etc/nginx/sites-enabled/${DOMAIN}
+nginx -t && systemctl reload nginx
+echo " ✓ nginx serving ${DOMAIN}"
+EOF
+
+echo
+echo "── 6. wait for DNS propagation ──"
+for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
+ if dig +short ${DOMAIN} @1.1.1.1 | grep -q "${KAMATERA_IP}"; then
+ echo " ✓ DNS propagated"
+ break
+ fi
+ if [ "$i" = "20" ]; then
+ echo " ! DNS still hasn't propagated to 1.1.1.1 — proceeding anyway, certbot may retry"
+ fi
+ printf " …waiting (%d/20)\r" "$i"
+ sleep 5
+done
+
+echo
+echo "── 7. Let's Encrypt SSL ──"
+ssh my-server "certbot --nginx -d ${DOMAIN} --non-interactive --agree-tos --register-unsafely-without-email --redirect 2>&1 | tail -8"
+
+echo
+echo "── 8. verify ──"
+sleep 2
+curl -sI --max-time 8 "https://${DOMAIN}/" 2>&1 | head -3
+
+cat <<DONE
+
+═══════════════════════════════════════════════════════════════
+ LIVE → https://${DOMAIN}
+
+ SDCC LOGIN:
+ Username: sdcc
+ Password: ${SDCC_PASSWORD}
+
+ Steve LOGIN (full access):
+ Username: steve
+ Password: ${STEVE_PASSWORD}
+
+═══════════════════════════════════════════════════════════════
+
+DONE
diff --git a/scripts/make-explainer-video.js b/scripts/make-explainer-video.js
new file mode 100755
index 0000000..5e62599
--- /dev/null
+++ b/scripts/make-explainer-video.js
@@ -0,0 +1,252 @@
+#!/usr/bin/env node
+/**
+ * make-explainer-video.js — record a narrated explainer walkthrough of the pitch viewer.
+ *
+ * Walks through every section of the lecture-style viewer, scrolls + interacts
+ * (clicks 1 agent modal, takes 1 quiz, opens the questions cheat-sheet), narrating
+ * each segment via macOS `say` (default) or Microsoft Azure Neural TTS (if AZURE_TTS_KEY set).
+ *
+ * Output: ~/Videos/norma-sdcc-pitch/explainer-<ISO>.mp4
+ *
+ * Run: node scripts/make-explainer-video.js
+ * Env: TARGET_URL default http://localhost:9876
+ * LOGIN_USER default sdcc
+ * LOGIN_PASS default Pulse-2026
+ * VOICE default 'Samantha' (macOS say) or any installed voice
+ * OUT_DIR default ~/Videos/norma-sdcc-pitch
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const { execSync, spawnSync } = require('child_process');
+const { chromium } = require('playwright');
+
+const TARGET = process.env.TARGET_URL || 'http://localhost:9876';
+const USER = process.env.LOGIN_USER || 'sdcc';
+const PASS = process.env.LOGIN_PASS || 'Pulse-2026';
+const VOICE = process.env.VOICE || 'Samantha'; // macOS fallback
+const ELEVEN_KEY = process.env.ELEVENLABS_API_KEY || (() => {
+ try {
+ const env = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf-8');
+ const m = env.match(/^ELEVENLABS_API_KEY=(.*)$/m);
+ return m ? m[1].trim() : null;
+ } catch { return null; }
+})();
+const STEVE_VOICE_ID = (() => {
+ try {
+ const j = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude/skills/clone-voice/active.json'), 'utf-8'));
+ return j.engines?.elevenlabs?.voice_id || null;
+ } catch { return null; }
+})();
+const OUT_DIR = process.env.OUT_DIR || path.join(os.homedir(), 'Videos', 'norma-sdcc-pitch');
+fs.mkdirSync(OUT_DIR, { recursive: true });
+
+const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+const tmpDir = path.join(OUT_DIR, '_tmp-' + stamp);
+fs.mkdirSync(tmpDir, { recursive: true });
+
+// ── narration script — section, scroll target, narration text, pause-after ms
+const SEGMENTS = [
+ {
+ sel: '.hero',
+ say: "Welcome to Norma. This is a proposal for the Student Debt Crisis Center. A custom CRM, built to fit SDCC's work — not the other way around.",
+ pause: 1500,
+ },
+ {
+ sel: '#priorities',
+ say: "Here are the five priorities. Email coordination across the team. Inbound email triage. Graphics for campaigns. Your Wix website. And building UX and UI capability directly into Wix.",
+ pause: 1500,
+ },
+ {
+ sel: '#priorities .priority-card:first-child',
+ say: "Priority one: email coordination. Three staffers, one info inbox. With Norma, every conversation has a single owner, every reply is logged, and the whole team can see who's handling what.",
+ pause: 2500,
+ },
+ {
+ sel: '#reluctant',
+ say: "If you're skeptical of AI, this section is for you. Your Tuesday at two PM, with and without Norma. Three hundred eighty seven unread becomes twenty two priority items. Five hours becomes five minutes.",
+ pause: 2500,
+ },
+ {
+ sel: '#db-shift',
+ say: "The big mental shift. Every organization is now a database — your knowledge, your stories, your coalition map. Not a website. Not a Google Doc. A queryable structured database that humans and AI agents can act on.",
+ pause: 2500,
+ },
+ {
+ sel: '#wix',
+ say: "Wix is fine. It's where SDCC's site lives. Here are five concrete integration paths — forms to Norma webhook, live data via Velo, embedded Pulse dashboard, structured borrower-story intake, and CMS write-back.",
+ pause: 2500,
+ },
+ {
+ sel: '#why-custom',
+ say: "Why custom CRM, instead of Salesforce or EveryAction? Because off-the-shelf CRMs model sales pipelines. SDCC's work is borrower in crisis, story tagged, policy lever, press hit, coalition activation. Norma was built for that shape.",
+ pause: 3000,
+ },
+ {
+ sel: '#why',
+ say: "Why AI for a nonprofit? Smaller staff, bigger reach, faster cycles. AI absorbs the time-consuming drafting and research work, so each staffer's hour goes further. Humans approve every outbound message.",
+ pause: 2500,
+ },
+ {
+ sel: '#loops',
+ say: "Loops keep Norma working while you sleep. Inbox loop every five minutes. News scan every thirty. Petition velocity every fifteen. Coalition watch daily. Every loop has a kill switch and logs every action.",
+ pause: 2500,
+ },
+ {
+ sel: '#cron',
+ say: "Cron jobs run on a calendar. Monday seven AM, the weekly intelligence brief. First of the month, donor renewal radar. Tuesday ten AM, grants deadline sweep. Quarterly, the board impact report.",
+ pause: 2500,
+ },
+ {
+ sel: '#agents',
+ say: "Twelve agents SDCC would actually use. Petition generator, advocacy statement drafter, grants matcher, inbox triage, news intelligence, borrower story classifier, audit trail, and more. Each one click-runnable.",
+ pause: 2500,
+ },
+ {
+ sel: '#example',
+ say: "A real example, using SDCC's data. The Department of Education announces one million PSLF discharges. Norma drafts the response in ninety seconds. Total time, news drop to press-ready statement, about seven minutes. Without Norma, three to five hours.",
+ pause: 3000,
+ },
+ {
+ sel: '#glossary',
+ say: "A full onboarding glossary. Eighty terms across ten categories — terminal, Claude install, MCP, LLM, the SDCC LLM demystified. For anyone new to the stack.",
+ pause: 2000,
+ },
+ {
+ sel: 'footer',
+ say: "That's Norma. Built to fit SDCC's work. The decision is whether to bend the work to fit a generic tool, or build the tool to fit the work. Thank you.",
+ pause: 2500,
+ },
+];
+
+async function ttsEleven(text, outFile) {
+ // ElevenLabs streaming TTS with Steve's cloned voice.
+ const url = `https://api.elevenlabs.io/v1/text-to-speech/${STEVE_VOICE_ID}`;
+ const r = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'xi-api-key': ELEVEN_KEY,
+ 'Content-Type': 'application/json',
+ 'Accept': 'audio/mpeg',
+ },
+ body: JSON.stringify({
+ text,
+ model_id: 'eleven_turbo_v2_5',
+ voice_settings: { stability: 0.45, similarity_boost: 0.85, style: 0.25, use_speaker_boost: true },
+ }),
+ });
+ if (!r.ok) throw new Error(`ElevenLabs ${r.status}: ${await r.text()}`);
+ const buf = Buffer.from(await r.arrayBuffer());
+ const mp3 = outFile.replace(/\.wav$/, '.mp3');
+ fs.writeFileSync(mp3, buf);
+ spawnSync('ffmpeg', ['-y', '-i', mp3, '-ar', '44100', '-ac', '2', outFile], { stdio: 'pipe' });
+ fs.unlinkSync(mp3);
+}
+
+function ttsSayFallback(text, outFile) {
+ const aiff = outFile.replace(/\.wav$/, '.aiff');
+ spawnSync('say', ['-v', VOICE, '-o', aiff, text], { stdio: 'inherit' });
+ spawnSync('ffmpeg', ['-y', '-i', aiff, '-ar', '44100', '-ac', '2', outFile], { stdio: 'pipe' });
+ fs.unlinkSync(aiff);
+}
+
+async function tts(text, outFile) {
+ if (ELEVEN_KEY && STEVE_VOICE_ID) {
+ try {
+ await ttsEleven(text, outFile);
+ const probe = spawnSync('ffprobe', ['-i', outFile, '-show_entries', 'format=duration', '-v', 'quiet', '-of', 'csv=p=0']);
+ return parseFloat(probe.stdout.toString().trim()) || 3;
+ } catch (err) {
+ console.error(' ! ElevenLabs failed:', err.message, '— falling back to macOS say');
+ }
+ }
+ ttsSayFallback(text, outFile);
+ const probe = spawnSync('ffprobe', ['-i', outFile, '-show_entries', 'format=duration', '-v', 'quiet', '-of', 'csv=p=0']);
+ return parseFloat(probe.stdout.toString().trim()) || 3;
+}
+
+(async () => {
+ console.log('[explainer] target:', TARGET);
+ console.log('[explainer] tmp:', tmpDir);
+
+ // 1. record narration audio per segment + measure durations
+ console.log('[explainer] narration engine:', ELEVEN_KEY && STEVE_VOICE_ID ? `ElevenLabs (Steve Abrams, ${STEVE_VOICE_ID})` : `macOS say (${VOICE})`);
+ console.log('[explainer] generating narration audio…');
+ const durations = [];
+ const audioFiles = [];
+ for (let i = 0; i < SEGMENTS.length; i++) {
+ const wav = path.join(tmpDir, `narr-${String(i).padStart(2, '0')}.wav`);
+ const d = await tts(SEGMENTS[i].say, wav);
+ durations.push(d + (SEGMENTS[i].pause || 1500) / 1000);
+ audioFiles.push(wav);
+ console.log(` [${i}] ${d.toFixed(1)}s — "${SEGMENTS[i].say.slice(0, 70)}…"`);
+ }
+
+ // 2. concat narration into one wav
+ const concatList = path.join(tmpDir, 'concat.txt');
+ fs.writeFileSync(concatList, audioFiles.map((f) => `file '${f}'`).join('\n'));
+ const narrationWav = path.join(tmpDir, 'narration.wav');
+ spawnSync('ffmpeg', ['-y', '-f', 'concat', '-safe', '0', '-i', concatList, '-c', 'copy', narrationWav], { stdio: 'pipe' });
+
+ const totalDur = durations.reduce((a, b) => a + b, 0);
+ console.log(`[explainer] total narration: ${totalDur.toFixed(1)}s`);
+
+ // 3. record screen with Playwright at the right pace
+ console.log('[explainer] launching browser + recording…');
+ const browser = await chromium.launch({ headless: false });
+ const ctx = await browser.newContext({
+ viewport: { width: 1440, height: 900 },
+ recordVideo: { dir: tmpDir, size: { width: 1440, height: 900 } },
+ });
+ const page = await ctx.newPage();
+
+ // login
+ await page.goto(TARGET + '/login');
+ await page.fill('input[name="username"]', USER);
+ await page.fill('input[name="password"]', PASS);
+ await page.click('button[type="submit"]');
+ await page.waitForLoadState('networkidle');
+
+ // walk segments
+ for (let i = 0; i < SEGMENTS.length; i++) {
+ const s = SEGMENTS[i];
+ try {
+ await page.evaluate((sel) => {
+ const el = document.querySelector(sel);
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }, s.sel);
+ } catch (err) { /* selector missing — skip */ }
+ await page.waitForTimeout(Math.max(500, durations[i] * 1000));
+ }
+
+ await page.close();
+ const videoPath = await ctx._currentPage?.video?.()?.path() || null;
+ await ctx.close();
+ await browser.close();
+
+ // find the .webm just recorded
+ const webms = fs.readdirSync(tmpDir).filter((f) => f.endsWith('.webm')).map((f) => path.join(tmpDir, f));
+ if (!webms.length) {
+ console.error('[explainer] no .webm recorded — abort');
+ process.exit(1);
+ }
+ const webm = webms[0];
+ console.log('[explainer] webm:', webm);
+
+ // 4. mux video + audio → mp4
+ const outMp4 = path.join(OUT_DIR, `explainer-${stamp}.mp4`);
+ spawnSync('ffmpeg', [
+ '-y', '-i', webm, '-i', narrationWav,
+ '-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
+ '-c:a', 'aac', '-b:a', '160k',
+ '-shortest', outMp4,
+ ], { stdio: 'inherit' });
+
+ // cleanup tmp
+ try { execSync(`rm -rf "${tmpDir}"`); } catch {}
+
+ console.log('\n══════════════════════════════════════════════════════');
+ console.log(' DONE → ' + outMp4);
+ console.log('══════════════════════════════════════════════════════');
+})();
diff --git a/server.js b/server.js
index bd6c265..1b55b3b 100644
--- a/server.js
+++ b/server.js
@@ -17,17 +17,122 @@ const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
-const PORT = 9876;
+const PORT = parseInt(process.env.PORT || '9876', 10);
const ROOT = __dirname;
const PITCH_DIR = path.join(ROOT, 'data', 'pitch-state');
const VIDEO_DIR = path.join(ROOT, 'videos');
const NORMA_BASE = process.env.NORMA_BASE || 'http://localhost:7400';
+// Login gate (one credential pair per role)
+const LOGINS = {
+ sdcc: process.env.SDCC_PASSWORD || 'Pulse-2026',
+ steve: process.env.STEVE_PASSWORD || 'DWSecure2024!',
+};
+const COOKIE_SECRET = process.env.COOKIE_SECRET || 'norma-sdcc-pitch-dev-secret-change-in-prod';
+const COOKIE_NAME = 'sdcc-pitch-auth';
+const crypto = require('crypto');
+
+function sign(value) {
+ return value + '.' + crypto.createHmac('sha256', COOKIE_SECRET).update(value).digest('hex');
+}
+function verify(signed) {
+ if (!signed) return null;
+ const idx = signed.lastIndexOf('.');
+ if (idx === -1) return null;
+ const value = signed.slice(0, idx);
+ const sig = signed.slice(idx + 1);
+ const expect = crypto.createHmac('sha256', COOKIE_SECRET).update(value).digest('hex');
+ if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null;
+ return value;
+}
+function parseCookies(req) {
+ const out = {};
+ const raw = req.headers.cookie || '';
+ raw.split(';').forEach((c) => {
+ const i = c.indexOf('=');
+ if (i > -1) out[c.slice(0, i).trim()] = decodeURIComponent(c.slice(i + 1).trim());
+ });
+ return out;
+}
fs.mkdirSync(PITCH_DIR, { recursive: true });
fs.mkdirSync(VIDEO_DIR, { recursive: true });
const app = express();
app.use(express.json({ limit: '2mb' }));
+app.use(express.urlencoded({ extended: true }));
+
+// ── auth middleware ──
+const PUBLIC_PATHS = new Set(['/login', '/logout', '/health']);
+app.use((req, res, next) => {
+ if (PUBLIC_PATHS.has(req.path) || req.path.startsWith('/login')) return next();
+ const cookies = parseCookies(req);
+ const who = verify(cookies[COOKIE_NAME]);
+ if (!who) {
+ if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'login required', login_url: '/login' });
+ return res.redirect('/login?next=' + encodeURIComponent(req.originalUrl));
+ }
+ req.user = who;
+ next();
+});
+
+// ── login UI ──
+app.get('/login', (req, res) => {
+ const next = req.query.next && /^\/[^/]/.test(req.query.next) ? req.query.next : '/';
+ const err = req.query.err ? '<div class="lerr">Invalid username or password.</div>' : '';
+ res.set('Content-Type', 'text/html').send(`<!doctype html><html><head><meta charset="utf-8">
+<title>Norma × SDCC — Sign in</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+:root{--blue:#1b4d7a;--blue-deep:#0d2e4d;--amber:#f59e0b;--ink:#0e1729;--muted:#5d6b85;--bg:#fafafa;--surface:#fff;--border:#e1e6ef;}
+*{box-sizing:border-box}html,body{margin:0;padding:0;font-family:ui-sans-serif,system-ui,-apple-system,'Inter',sans-serif;color:var(--ink);background:radial-gradient(900px 500px at 50% -10%,rgba(245,158,11,.12),transparent 60%),linear-gradient(180deg,#fff,var(--bg));min-height:100vh}
+.wrap{max-width:420px;margin:80px auto;padding:0 24px}
+.brand{display:flex;align-items:center;gap:10px;margin-bottom:24px;justify-content:center}
+.mark{width:42px;height:42px;border-radius:11px;background:linear-gradient(135deg,var(--blue),var(--blue-deep));color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:15px}
+.title{font-family:Georgia,serif;font-size:30px;color:var(--blue-deep);margin:0 0 8px;letter-spacing:-.5px;text-align:center}
+.sub{color:var(--muted);text-align:center;margin:0 0 28px;font-size:15px}
+.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:28px;box-shadow:0 4px 20px rgba(15,29,53,.06)}
+label{display:block;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin:0 0 6px;font-weight:600}
+input{width:100%;padding:11px 13px;font-size:15px;border:1px solid var(--border);border-radius:8px;font-family:inherit;background:var(--bg)}
+input:focus{outline:none;border-color:var(--blue);background:#fff}
+.row{margin-bottom:18px}
+button{width:100%;padding:12px;font-size:15px;font-weight:600;color:#fff;background:var(--blue);border:0;border-radius:8px;cursor:pointer;font-family:inherit}
+button:hover{background:var(--blue-deep)}
+.foot{color:var(--muted);font-size:12px;text-align:center;margin-top:20px;line-height:1.55}
+.lerr{background:#fef2f2;border:1px solid #fecaca;border-left:3px solid #b91c1c;color:#7f1d1d;padding:10px 14px;border-radius:8px;font-size:13px;margin-bottom:16px}
+</style></head><body>
+<div class="wrap">
+ <div class="brand"><div class="mark">N×</div><strong>Norma × SDCC</strong></div>
+ <h1 class="title">Welcome to the pitch</h1>
+ <p class="sub">Sign in with the credentials your contact at SDCC shared.</p>
+ <div class="card">
+ ${err}
+ <form method="POST" action="/login">
+ <input type="hidden" name="next" value="${next.replace(/"/g, '"')}">
+ <div class="row"><label for="u">Username</label><input id="u" name="username" autocomplete="username" required autofocus></div>
+ <div class="row"><label for="p">Password</label><input id="p" name="password" type="password" autocomplete="current-password" required></div>
+ <button type="submit">Sign in</button>
+ </form>
+ </div>
+ <p class="foot">Trouble signing in? Contact your SDCC representative or steve@agentabrams.com.</p>
+</div></body></html>`);
+});
+
+app.post('/login', (req, res) => {
+ const { username = '', password = '', next = '/' } = req.body || {};
+ const u = String(username).toLowerCase().trim();
+ if (LOGINS[u] && password === LOGINS[u]) {
+ const value = u + '|' + Date.now();
+ res.set('Set-Cookie', `${COOKIE_NAME}=${encodeURIComponent(sign(value))}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 86400}`);
+ return res.redirect(/^\/[^/]/.test(next) ? next : '/');
+ }
+ res.redirect('/login?err=1&next=' + encodeURIComponent(next));
+});
+
+app.get('/logout', (_req, res) => {
+ res.set('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; Max-Age=0`);
+ res.redirect('/login');
+});
+
app.use(express.static(path.join(ROOT, 'public')));
// ── feature catalog ────────────────────────────────────────────────────────
← 502c282 hero: lead with 'A CRM that fits SDCC's work. Not the other
·
back to Norma Sdcc Pitch
·
fix: live demo mode — canned realistic mock responses for /a c03fe2c →