← back to Debate Ring Viewer

src/server.js

196 lines

// Debate Ring Viewer — renders the claude-codex 8-way debate as a boxing match.
// Reads runs from ~/.claude/skills/claude-codex/runs/, exposes JSON API,
// serves the boxing-ring SPA on :9730.
import express from 'express';
import helmet from 'helmet';
import fs from 'fs/promises';
import fsync from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';

const PORT  = Number(process.env.PORT || 9730);
const HOST  = process.env.HOST || '127.0.0.1';
const RUNS  = process.env.CC_RUNS_DIR ||
              path.join(os.homedir(), '.claude/skills/claude-codex/runs');

const FIGHTERS = ['claude','codex','deepseek','gptoss','kimi','mistral','phi4','qwen'];
const FINAL_REPORT_MAX_BYTES = 8000;

// Reject `.`, `..`, separators, and anything containing `..` segments. The
// previous regex `^[A-Za-z0-9_.\-]+$` matched `..`, which let `GET /api/runs/..`
// resolve to the parent of RUNS and exfiltrate any `final_report.md` /
// `cross_exam.md` sitting there.
function isSafeRunName(name) {
  if (typeof name !== 'string' || name.length === 0) return false;
  if (name === '.' || name === '..') return false;
  if (name.includes('..')) return false;
  if (name.includes('/') || name.includes('\\') || name.includes('\0')) return false;
  return /^[A-Za-z0-9_\-][A-Za-z0-9_.\-]*$/.test(name);
}

// Read at most `maxBytes` bytes from `fp` without slurping the whole file.
// When we hit the cap, trim back to the last whitespace/newline so we don't
// cut a multibyte UTF-8 grapheme (emoji/CJK) and emit a replacement char.
async function readHead(fp, maxBytes) {
  let fh;
  try {
    fh = await fs.open(fp, 'r');
    const buf = Buffer.allocUnsafe(maxBytes);
    const { bytesRead } = await fh.read(buf, 0, maxBytes, 0);
    if (bytesRead === 0) return '';
    let end = bytesRead;
    if (bytesRead === maxBytes) {
      const half = Math.floor(maxBytes / 2);
      while (end > half) {
        const c = buf[end - 1];
        if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) break;
        end--;
      }
      if (end <= half) end = bytesRead;
    }
    return buf.slice(0, end).toString('utf8');
  } catch {
    return '';
  } finally {
    if (fh) await fh.close().catch(() => {});
  }
}

const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
app.use(express.json());
const __dirname = path.dirname(fileURLToPath(import.meta.url));
app.use(express.static(path.join(__dirname, '..', 'public')));

// ---- helpers ---------------------------------------------------------------
async function listRuns() {
  let dirs;
  try {
    dirs = await fs.readdir(RUNS, { withFileTypes: true });
  } catch {
    return [];
  }
  const runs = [];
  for (const d of dirs) {
    if (!d.isDirectory()) continue;
    const full = path.join(RUNS, d.name);
    let st;
    try {
      st = await fs.stat(full);
    } catch {
      continue;
    }
    let rounds = 0;
    try {
      const items = await fs.readdir(full);
      rounds = items.filter(n => /^round_\d+$/.test(n)).length;
    } catch {}
    runs.push({ name: d.name, mtime: st.mtimeMs, rounds });
  }
  runs.sort((a, b) => b.mtime - a.mtime);
  return runs;
}

async function readRound(runName, n) {
  const dir = path.join(RUNS, runName, `round_${n}`);
  try {
    await fs.access(dir);
  } catch {
    return null;
  }
  const out = {};
  for (const f of FIGHTERS) {
    const fp = path.join(dir, `${f}.txt`);
    try {
      const buf = await fs.readFile(fp, 'utf8');
      out[f] = (buf || '').trim();
    } catch {
      out[f] = '';
    }
  }
  return out;
}

async function readManifest(runName) {
  const dir = path.join(RUNS, runName);
  const items = await fs.readdir(dir).catch(() => []);
  const rounds = items
    .filter(n => /^round_\d+$/.test(n))
    .map(n => Number(n.replace('round_', '')))
    .sort((a, b) => a - b);

  let final = '';
  for (const cand of ['final_report.md','cross_exam.md']) {
    const fp = path.join(dir, cand);
    const head = await readHead(fp, FINAL_REPORT_MAX_BYTES);
    if (head) { final = head; break; }
  }
  return { name: runName, rounds, final };
}

// ---- API -------------------------------------------------------------------
app.get('/api/runs', async (_req, res) => {
  res.json(await listRuns());
});

app.get('/api/runs/:name', async (req, res) => {
  const { name } = req.params;
  if (!isSafeRunName(name)) return res.status(400).json({ error: 'bad name' });
  const m = await readManifest(name);
  if (!m.rounds.length) return res.status(404).json({ error: 'no rounds' });
  res.json(m);
});

app.get('/api/runs/:name/round/:n', async (req, res) => {
  const { name, n } = req.params;
  if (!isSafeRunName(name))              return res.status(400).json({ error: 'bad name' });
  if (!/^\d+$/.test(n))                  return res.status(400).json({ error: 'bad n' });
  const r = await readRound(name, n);
  if (!r) return res.status(404).json({ error: 'round not found' });
  res.json({ round: Number(n), texts: r });
});

// /demo.mp4 — stream the latest demo MP4 (with Steve avatar baked in).
// /demo     — small watch page so non-tech viewers (Steve's MacBook Pro) can
//             land on a URL and just hit play. Range-byte support is built
//             into express.sendFile, so QuickTime/Safari can scrub freely.
const DEMO_MP4 = path.join(os.homedir(),
  '.claude/skills/app-demo-video/output/debate-ring/debate-ring-demo.mp4');
const WEEKLY_DIR = path.join(os.homedir(),
  '.claude/skills/app-demo-video/output/debate-ring-weekly');

// Express's send() already sets ETag/Last-Modified, but the prior config
// didn't pass maxAge/cacheControl, so QuickTime + Safari kept re-downloading
// the (large) MP4 on every page refresh. Short maxAge + ETag lets conditional
// GETs short-circuit to 304.
app.get('/demo.mp4', (_req, res) => {
  res.sendFile(DEMO_MP4, {
    headers: { 'content-type': 'video/mp4' },
    cacheControl: true,
    maxAge: '5m',
    etag: true,
    lastModified: true,
  }, (err) => {
    if (err && !res.headersSent) res.status(404).send('no demo yet — run weekly-highlight-reel.sh');
  });
});

app.get('/demo', (_req, res) => {
  res.type('html').send(`<!doctype html><meta charset=utf-8><title>Debate Ring demo</title>
<style>html,body{margin:0;background:#0a0809;color:#f4ecd9;font-family:-apple-system,system-ui,sans-serif;height:100%}
.wrap{max-width:1100px;margin:0 auto;padding:32px 24px}h1{font-size:22px;margin:0 0 4px}
.sub{color:#9a958a;font-size:13px;margin-bottom:18px}video{width:100%;border-radius:8px;background:#000}
.actions{margin-top:14px;display:flex;gap:10px;flex-wrap:wrap}a{color:#cc785c;text-decoration:none;border:1px solid #2f3441;padding:6px 14px;border-radius:6px;font-size:13px}
a:hover{background:#1d2028}</style>
<div class=wrap><h1>Debate Ring — narrated walkthrough</h1>
<div class=sub>Steve avatar in the bottom-right corner cycles project details. Bake-into-frame, no overlay tricks.</div>
<video src="/demo.mp4" controls autoplay muted playsinline></video>
<div class=actions><a href="/demo.mp4" download>⬇ Download MP4</a><a href="/">▶ Open live ring</a></div></div>`);
});

app.listen(PORT, HOST, () => {
  console.log(`[debate-ring] listening on ${HOST}:${PORT}  runs=${RUNS}`);
});