← back to Ventura Claw

relay/server.js

264 lines

#!/usr/bin/env node
// VenturaClaw — local Mac relay (loopback only, never exposed to LAN/Internet).
// Lets Steve paste KEY=value lines ONCE; writes to ~/Projects/secrets-manager/.env
// and triggers immediate VenturaClaw sync via launchctl.

const http = require("http");
const fs = require("fs");
const path = require("path");
const os = require("os");
const { spawn } = require("child_process");

const PORT = 9790;
const HOST = "127.0.0.1";
const SECRETS_ENV = path.join(os.homedir(), "Projects", "secrets-manager", ".env");
const ROOT = path.resolve(__dirname);
const PUB = path.join(ROOT, "public");

if (!fs.existsSync(PUB)) fs.mkdirSync(PUB, { recursive: true });

function send(res, code, body, headers = {}) {
  res.writeHead(code, {
    "Content-Type": typeof body === "string" ? "text/html; charset=utf-8" : "application/json",
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "POST, GET, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type",
    ...headers
  });
  res.end(typeof body === "string" ? body : JSON.stringify(body));
}

function parseEnvBlob(text) {
  const out = {};
  for (const line of (text || "").split("\n")) {
    const m = line.trim().match(/^(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.+?)\s*$/);
    if (!m) continue;
    let v = m[2];
    if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
    out[m[1]] = v;
  }
  return out;
}

function readEnvFile(p) {
  if (!fs.existsSync(p)) return { lines: [], map: {} };
  const lines = fs.readFileSync(p, "utf8").split("\n");
  const map = {};
  lines.forEach((ln, i) => {
    const m = ln.match(/^(?:export\s+)?([A-Z0-9_]+)\s*=/);
    if (m) map[m[1]] = i;
  });
  return { lines, map };
}

function writeEnvMerge(p, kv) {
  const { lines, map } = readEnvFile(p);
  let added = 0, updated = 0;
  for (const [k, v] of Object.entries(kv)) {
    if (!v) continue;
    if (k in map) {
      lines[map[k]] = `${k}=${v}`;
      updated++;
    } else {
      lines.push(`${k}=${v}`);
      added++;
    }
  }
  if (added || updated) {
    fs.writeFileSync(p, lines.join("\n"));
  }
  return { added, updated };
}

function triggerVenturaClawSync() {
  return new Promise((resolve) => {
    const p = spawn("launchctl", ["start", "com.steve.venturaclaw-sync"]);
    p.on("close", (code) => resolve({ code }));
    p.on("error", (err) => resolve({ error: err.message }));
  });
}

const HTML = `<!doctype html>
<html lang="en"><head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
<title>VenturaClaw — Local Token Relay</title>
<style>
  :root {
    --ink: #f4f1ea;
    --ink-mute: rgba(244,241,234,0.62);
    --bg: #0e0e10;
    --rule: rgba(244,241,234,0.10);
    --gold: #d4a04a;
    --good: #8fb89a;
    --bad: #c9856e;
    --serif: "Cormorant Garamond", Georgia, serif;
    --mono: "JetBrains Mono", ui-monospace, monospace;
    --sans: "Inter", -apple-system, system-ui, sans-serif;
  }
  @import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,500;1,500&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400&display=swap");
  * { box-sizing: border-box; }
  html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); font-family: var(--sans); }
  body::before { content:""; position:fixed; inset:0; background: radial-gradient(ellipse 80% 60% at 18% -10%, rgba(212,160,74,0.06), transparent 60%); pointer-events:none; }
  main { max-width: 760px; margin: 40px auto; padding: 0 24px; position: relative; z-index: 1; }
  .card { background: rgba(244,241,234,0.04); border: 1px solid var(--rule); border-radius: 4px; padding: 24px; margin-bottom: 18px; backdrop-filter: blur(12px); }
  h1 { font-family: var(--serif); font-weight: 500; font-size: 28px; margin: 0 0 6px; letter-spacing: -.01em; }
  h1 em { color: var(--gold); }
  .sub { font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-mute); }
  textarea { width: 100%; background: rgba(244,241,234,0.04); border: 1px solid var(--rule); color: var(--ink); padding: 14px; border-radius: 2px; font-family: var(--mono); font-size: 12px; line-height: 1.55; min-height: 280px; outline: none; resize: vertical; }
  textarea:focus { border-color: var(--gold); }
  .row { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; gap: 14px; }
  button { font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase; background: var(--gold); border: 1px solid var(--gold); color: var(--bg); padding: 10px 18px; border-radius: 2px; cursor: pointer; transition: background 160ms; }
  button:hover { background: #e0b160; }
  button:disabled { opacity: 0.5; cursor: not-allowed; }
  .status { font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-mute); }
  .results { font-family: var(--mono); font-size: 12px; line-height: 1.7; margin-top: 14px; }
  .results .ok    { color: var(--good); }
  .results .err   { color: var(--bad); }
  .results .info  { color: var(--ink-mute); }
  small { font-family: var(--mono); font-size: 10px; letter-spacing: .08em; color: var(--ink-mute); }
  code { font-family: var(--mono); background: rgba(244,241,234,0.04); padding: 1px 6px; border-radius: 2px; }
</style>
</head><body>
<main>
  <div class="card">
    <div class="sub">LOCAL · 127.0.0.1:9790 · loopback only</div>
    <h1>Paste your <em>tokens</em> once</h1>
    <p style="font-size:13px;opacity:0.78;line-height:1.6;margin:8px 0 0">
      One paste writes <code>KEY=value</code> lines to <code>~/Projects/secrets-manager/.env</code>
      AND immediately triggers <code>com.steve.venturaclaw-sync</code> so the
      <a href="https://venturaclaw.com/admin/connectors" style="color:var(--gold)">VenturaClaw connectors page</a>
      flips tiles green within seconds. From this moment on, the launchd job keeps everything in sync — no further pasting ever.
    </p>
  </div>

  <div class="card">
    <textarea id="paste" placeholder="# example
SLACK_BOT_TOKEN=xoxb-1234-5678-...
MAILCHIMP_API_KEY=abcdef-us6
HUBSPOT_TOKEN=pat-na1-...
NOTION_TOKEN=ntn_...
AIRTABLE_PAT=pat...
TWILIO_ACCOUNT_SID=AC...
TWILIO_AUTH_TOKEN=...
FIGMA_TOKEN=figd_...
CANVA_TOKEN=...
ETSY_KEYSTRING=...
ETSY_ACCESS_TOKEN=..."></textarea>
    <div class="row">
      <span class="status" id="status">paste keys above</span>
      <button id="save">Save &amp; sync</button>
    </div>
    <div id="results" class="results"></div>
  </div>

  <small>This server runs only on <code>127.0.0.1</code>, never accessible from outside your Mac. Tokens travel: browser → loopback → secrets-manager file → launchd → venturaclaw.</small>
</main>
<script>
const paste = document.getElementById("paste");
const status = document.getElementById("status");
const save = document.getElementById("save");
const results = document.getElementById("results");

paste.addEventListener("input", () => {
  const lines = paste.value.split("\\n").filter(l => /^[A-Z0-9_]+=/.test(l.trim()));
  status.textContent = lines.length + " keys detected";
});

save.addEventListener("click", async () => {
  if (!paste.value.trim()) return;
  save.disabled = true; save.textContent = "Saving…";
  results.innerHTML = '<div class="info">writing to secrets-manager .env…</div>';
  try {
    const r = await fetch("/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ paste: paste.value }) });
    const d = await r.json();
    let html = "";
    html += '<div class="ok">✓ secrets-manager: ' + d.added + ' added · ' + d.updated + ' updated</div>';
    html += '<div class="info">→ launching com.steve.venturaclaw-sync…</div>';
    html += d.synced ? '<div class="ok">✓ launchd triggered</div>' : '<div class="err">✗ launchd: ' + (d.syncError || 'failed') + '</div>';
    html += '<div class="info" style="margin-top:8px">— now reload <a href="https://venturaclaw.com/admin/connectors" style="color:var(--gold)">/admin/connectors</a> in 5 sec —</div>';
    results.innerHTML = html;
    save.textContent = "Saved";
    setTimeout(() => { save.textContent = "Save & sync"; save.disabled = false; }, 2000);
  } catch (e) {
    results.innerHTML = '<div class="err">✗ ' + e.message + '</div>';
    save.textContent = "Save & sync"; save.disabled = false;
  }
});
</script>
</body></html>`;

const server = http.createServer(async (req, res) => {
  if (req.method === "OPTIONS") return send(res, 200, "ok");
  if (req.method === "GET" && (req.url === "/" || req.url === "/import")) return send(res, 200, HTML);
  if (req.method === "GET" && req.url === "/healthz") return send(res, 200, { ok: true });

  // Expose MCP server config from ~/.claude.json — names + env-key names only, NEVER values.
  // Used by VenturaClaw /api/anthropic/discover-mcps when running on the same machine.
  if (req.method === "GET" && req.url === "/api/mcp-config") {
    try {
      const j = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".claude.json"), "utf8"));
      const safe = {};
      for (const [name, def] of Object.entries(j.mcpServers || {})) {
        safe[name] = {
          command: def.command || null,
          env: Object.fromEntries(Object.keys(def.env || {}).map(k => [k, "[redacted]"]))
        };
      }
      return send(res, 200, { mcpServers: safe });
    } catch (e) { return send(res, 500, { error: e.message }); }
  }

  // PRIVILEGED: returns actual MCP env values + secrets-manager values.
  // Loopback-only by virtue of relay binding to 127.0.0.1.
  // Steve approved (auto-import flow) 2026-05-06.
  if (req.method === "GET" && req.url === "/api/mcp-tokens") {
    try {
      const out = { mcp: {}, env: {} };
      // ~/.claude.json mcpServers env values
      try {
        const j = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".claude.json"), "utf8"));
        for (const [name, def] of Object.entries(j.mcpServers || {})) {
          out.mcp[name] = { ...(def.env || {}) };
        }
      } catch {}
      // secrets-manager .env values
      try {
        const text = fs.readFileSync(path.join(os.homedir(), "Projects", "secrets-manager", ".env"), "utf8");
        for (const line of text.split("\n")) {
          const m = line.match(/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
          if (!m) continue;
          let v = m[2];
          if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
          if (v) out.env[m[1]] = v;
        }
      } catch {}
      return send(res, 200, out);
    } catch (e) { return send(res, 500, { error: e.message }); }
  }

  if (req.method === "POST" && req.url === "/save") {
    let body = "";
    req.on("data", c => body += c);
    req.on("end", async () => {
      try {
        const { paste } = JSON.parse(body || "{}");
        if (!paste) return send(res, 400, { error: "missing paste" });
        const kv = parseEnvBlob(paste);
        if (!Object.keys(kv).length) return send(res, 400, { error: "no recognized KEY=value lines" });
        const { added, updated } = writeEnvMerge(SECRETS_ENV, kv);
        const sync = await triggerVenturaClawSync();
        send(res, 200, { added, updated, keys: Object.keys(kv), synced: !sync.error, syncError: sync.error || null });
      } catch (e) {
        send(res, 500, { error: e.message });
      }
    });
    return;
  }
  send(res, 404, { error: "not found" });
});

server.listen(PORT, HOST, () => {
  console.log(`[bc-relay] listening on http://${HOST}:${PORT} (loopback only)`);
  console.log(`[bc-relay] open http://${HOST}:${PORT}/import to paste tokens`);
});