← back to Ventura Claw
server/server.js
2514 lines
#!/usr/bin/env node
// VenturaClaw — production Express server
// Routes: /login, /chat, /admin, /admin/users, /admin/connectors, /admin/approvals, /admin/audit
// API: /api/auth/login, /api/auth/logout, /api/me, /api/connectors, /api/chat,
// /api/approvals (list/create/decide), /api/audit, /api/admin/users (CRUD)
const express = require("express");
const cookieParser = require("cookie-parser");
const bcrypt = require("bcrypt");
const rateLimit = require("express-rate-limit");
// Allowlist-based env loader. Only keys actually used by VenturaClaw are imported
// — prevents the entire master secrets-manager .env (300+ keys) from spilling into process.env.
(function loadCanonicalSecrets() {
const fs = require('fs'), path = require('path'), os = require('os');
const ALLOW = new Set([
"CC_SECRET","PORT","HOST","NODE_ENV","PUBLIC_DOMAIN","PUBLIC_BASE","SUPPORT_EMAIL",
// Real-impl connector tokens
"STRIPE_SECRET_KEY","CLOUDFLARE_API_TOKEN","CF_API_TOKEN",
"SLACK_BOT_TOKEN","MAILCHIMP_API_KEY","HUBSPOT_TOKEN","HUBSPOT_ACCESS_TOKEN",
"NOTION_TOKEN","AIRTABLE_PAT","AIRTABLE_BASE_ID",
"TWILIO_ACCOUNT_SID","TWILIO_AUTH_TOKEN","TWILIO_FROM_NUMBER",
"FIGMA_TOKEN","CANVA_TOKEN","ETSY_KEYSTRING","ETSY_ACCESS_TOKEN","ETSY_SHOP_ID",
"GEORGE_URL","GEORGE_USER","GEORGE_PASS","GEORGE_BASIC_AUTH",
"PURELYMAIL_API_TOKEN","BROWSERBASE_API_KEY","BROWSERBASE_PROJECT_ID",
"SHOPIFY_ADMIN_TOKEN","SHOPIFY_ACCESS_TOKEN","SHOPIFY_STORE",
"ANTHROPIC_API_KEY","ELEVENLABS_API_KEY",
// LLM
"OLLAMA_URL","OLLAMA_MODEL","OLLAMA_FALLBACK_URL","OLLAMA_FALLBACK_MODEL","CC_KEY_ID"
]);
const candidates = [
os.homedir() + '/Projects/secrets-manager/.env',
path.join(__dirname, '..', '.env'),
path.join(__dirname, '.env'),
];
for (const p of candidates) {
try {
if (!fs.existsSync(p)) continue;
const text = fs.readFileSync(p, '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 || !ALLOW.has(m[1])) continue;
let val = m[2];
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
if (val && !process.env[m[1]]) process.env[m[1]] = val;
}
} catch {}
}
})();
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const PORT = parseInt(process.env.PORT || "9788", 10);
const HOST = process.env.HOST || "127.0.0.1";
const SECRET = process.env.CC_SECRET || "ventura-claw-dev-secret-please-rotate";
if (process.env.NODE_ENV === "production" && SECRET === "ventura-claw-dev-secret-please-rotate") {
console.error("FATAL: CC_SECRET is the dev default in production — refusing to boot. Set CC_SECRET in env (32+ random chars).");
process.exit(1);
}
const DOMAIN = process.env.PUBLIC_DOMAIN || "venturaclaw.com";
const SUPPORT_EMAIL = process.env.SUPPORT_EMAIL || "info@venturaclaw.com";
// HTML-escape — used in OAuth error pages where vendor responses are echoed
const esc = (s) => String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
// Safe redirect-after — only allow same-origin paths starting with single "/" (no protocol-relative "//x")
const safeRedirectPath = (p) => (typeof p === "string" && p.startsWith("/") && !p.startsWith("//") ? p : "/connections");
// AES-256-GCM at-rest encryption for sensitive blobs (credentials, oauth-state).
// Envelope: { _enc: 1, key_id: "v1", iv, tag, data } base64. The key_id field is
// the dual-read precondition from claude-codex finding #2 (project_commerce_claw.md):
// rotation works by adding v2 to KEYS, setting CURRENT_KEY_ID = "v2", and letting
// every save() lazily re-encrypt under the new key. Old envelopes with key_id="v1"
// still decrypt because their key stays in KEYS until all data has migrated.
const KEYS = {
v1: require("crypto").createHash("sha256").update("cc-aes-v1::" + SECRET).digest(),
// v2: derive from CC_SECRET_V2 here when rotating; remove v1 only after a full re-save sweep.
};
const CURRENT_KEY_ID = process.env.CC_KEY_ID || "v1";
function encryptBlob(obj) {
const iv = require("crypto").randomBytes(12);
const key = KEYS[CURRENT_KEY_ID];
if (!key) throw new Error(`encryption key '${CURRENT_KEY_ID}' not loaded`);
const cipher = require("crypto").createCipheriv("aes-256-gcm", key, iv);
const pt = Buffer.from(JSON.stringify(obj), "utf8");
const ct = Buffer.concat([cipher.update(pt), cipher.final()]);
const tag = cipher.getAuthTag();
return { _enc: 1, key_id: CURRENT_KEY_ID, iv: iv.toString("base64"), tag: tag.toString("base64"), data: ct.toString("base64") };
}
function decryptBlob(envelope) {
if (!envelope || envelope._enc !== 1) return envelope; // backward-compat with plaintext
const keyId = envelope.key_id || "v1"; // pre-key_id envelopes default to v1
const key = KEYS[keyId];
if (!key) throw new Error(`decryption key '${keyId}' not loaded — did you remove it before re-saving all data?`);
const iv = Buffer.from(envelope.iv, "base64");
const tag = Buffer.from(envelope.tag, "base64");
const ct = Buffer.from(envelope.data, "base64");
const decipher = require("crypto").createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
return JSON.parse(pt.toString("utf8"));
}
const ROOT = __dirname;
const DATA = path.join(ROOT, "data");
const PUB = path.join(ROOT, "public");
const _serverStartIso = new Date().toISOString();
[DATA].forEach(d => { if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); });
const CONNECTORS = JSON.parse(fs.readFileSync(path.join(ROOT, "connectors.json"), "utf8"));
const REAL_CONNECTORS = require("./connectors/index.js");
const LLM = require("./llm");
const COMMS = require("./lib/comms-compliance");
const REFRESH = require("./lib/oauth-refresh");
// ------------- tiny JSON store -------------
// load(): fail LOUDLY on parse error (claude-codex finding #5 — silent default
// → next save() overwrites real data with empty default = self-amplifying data loss).
function load(name, def) {
const f = path.join(DATA, `${name}.json`);
if (!fs.existsSync(f)) { fs.writeFileSync(f, JSON.stringify(def, null, 2)); return def; }
const text = fs.readFileSync(f, "utf8");
try {
return JSON.parse(text);
} catch (e) {
// Move corrupt file aside — never silently return default and clobber on next save.
const corrupted = f + ".corrupt." + Date.now();
try { fs.renameSync(f, corrupted); } catch {}
console.error(`FATAL: ${name}.json failed to parse (${e.message}). Moved to ${corrupted}. Refusing to boot.`);
process.exit(1);
}
}
// save(): atomic write with fsync (claude-codex finding #4 — rename without fsync
// loses writes on power loss; we already 200'd to the client by then).
function save(name, data) {
const f = path.join(DATA, `${name}.json`);
const tmp = f + ".tmp." + process.pid;
const fd = fs.openSync(tmp, "w", 0o600);
try {
fs.writeSync(fd, JSON.stringify(data, null, 2));
fs.fsyncSync(fd); // flush page cache to disk before rename
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmp, f);
}
// seed users — only on first boot when users.json doesn't exist; never overwrite an existing store
let users = load("users", [
{ id: 1, email: "admin@venturaclaw.com", password: "admin", role: "admin", name: "Admin" },
{ id: 2, email: "demo@venturaclaw.com", password: "demo", role: "user", name: "Demo User" }
]);
// (removed: unconditional save("users", users) — was wiping rotated passwords on every restart)
let approvals = load("approvals", []);
let audit = load("audit", []);
// Per-user chat history. Keyed by userId. Each entry: { ts, role: "user"|"assistant"|"tool", text, toolCalls? }.
// Capped at 200 messages per user; oldest dropped on overflow.
let chatHistory = load("chat-history", {});
function pushChat(userId, msg) {
if (!chatHistory[userId]) chatHistory[userId] = [];
chatHistory[userId].push({ ts: new Date().toISOString(), ...msg });
if (chatHistory[userId].length > 200) chatHistory[userId] = chatHistory[userId].slice(-200);
}
// Encrypted at rest. credentials.json stores envelope { _enc:1, iv, tag, data } — never plaintext from now on.
function loadCredentials() {
const raw = load("credentials", {});
try { return decryptBlob(raw) || {}; }
catch (e) { console.error("credentials.json decrypt failed:", e.message); return {}; }
}
function saveCredentials(obj) { save("credentials", encryptBlob(obj)); }
let credentials = loadCredentials();
// Migration: if file was previously plaintext, re-save it encrypted now.
if (Object.keys(credentials).length && !require("fs").readFileSync(path.join(DATA, "credentials.json"), "utf8").includes('"_enc": 1')) {
saveCredentials(credentials);
console.log("[migration] credentials.json encrypted at rest (AES-256-GCM)");
}
let oauthApps = load("oauth-apps", {}); // { vendor: { client_id, client_secret, registered_at, registered_by } }
let oauthState = load("oauth-state", {}); // { state: { userId, vendor, redirect_after, created_at } } — short-lived CSRF tokens
// Auto-refresh OAuth tokens on read. Returns possibly-refreshed creds for (userId, vendor).
// Persists refreshed tokens back to credentials.json. Logs successful refreshes + failures.
// Per-(userId,vendor) in-flight cache: coalesces parallel callers (Promise.all on health-all
// would otherwise double-fire the refresh + corrupt the rotated refresh_token).
const _refreshInFlight = new Map(); // key: `${userId}:${vendor}` → Promise
async function _doRefresh(userId, vendor, c) {
try {
const { creds, refreshed, error } = await REFRESH.refreshIfNeeded(
vendor, c, OAUTH_PROVIDERS[vendor], oauthApps[vendor]
);
if (refreshed) {
if (!credentials[userId]) credentials[userId] = {};
credentials[userId][vendor] = creds;
saveCredentials(credentials);
logEvent({ kind: "oauth_refreshed", userId, vendor });
} else if (error) {
// Persist the _refresh_failed marker so /admin/connectors can show the broken state.
// Without this, every health check recomputes the marker but it never reaches disk.
if (!credentials[userId]) credentials[userId] = {};
credentials[userId][vendor] = creds;
saveCredentials(credentials);
logEvent({ kind: "oauth_refresh_failed", userId, vendor, error });
}
return creds;
} catch (e) {
logEvent({ kind: "oauth_refresh_error", userId, vendor, error: e.message });
return c;
}
}
async function getFreshUserCred(userId, vendor) {
const c = (credentials[userId] || {})[vendor];
if (!c) return c;
const key = `${userId}:${vendor}`;
const inFlight = _refreshInFlight.get(key);
if (inFlight) return inFlight;
const p = _doRefresh(userId, vendor, c);
_refreshInFlight.set(key, p);
try { return await p; } finally { _refreshInFlight.delete(key); }
}
// OAuth provider directory — every vendor we can OAuth-connect a user to.
// `register_url`: where Steve goes once to create the OAuth app
// `auth_url`: where users get redirected to grant consent
// `token_url`: server-side code-for-token exchange
// `redirect_uri`: returned to vendors as the callback (Steve registers this in the vendor's dashboard)
const PUBLIC_BASE = process.env.PUBLIC_BASE || `https://${DOMAIN}`;
const OAUTH_PROVIDERS = {
anthropic: {
name: 'Anthropic (Claude API)', icon: 'anthropic', tint: '#D97757',
// Anthropic doesn't have a public OAuth flow as of 2026-05; fall back to API-key paste at console.
// Users mint a key at console.anthropic.com/settings/keys and we store it as ANTHROPIC_API_KEY.
auth_url: null, token_url: null, scope: '',
register_url: 'https://console.anthropic.com/settings/keys',
docs_url: 'https://docs.claude.com/en/api/getting-started',
note: 'API key only (Anthropic has no public OAuth yet). Console → Settings → API Keys → Create.',
api_key_only: true,
},
google: {
name: 'Google (Sheets / Drive / Gmail)', icon: 'google', tint: '#4285F4',
auth_url: 'https://accounts.google.com/o/oauth2/v2/auth',
token_url: 'https://oauth2.googleapis.com/token',
scope: 'https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/gmail.send',
register_url: 'https://console.cloud.google.com/apis/credentials/oauthclient',
docs_url: 'https://developers.google.com/identity/protocols/oauth2/web-server',
note: 'Create OAuth 2.0 Client ID · Application type: Web · Authorized redirect URI: paste below.',
extra_params: { access_type: 'offline', prompt: 'consent' },
},
slack: {
name: 'Slack', icon: 'slack', tint: '#4A154B',
auth_url: 'https://slack.com/oauth/v2/authorize',
token_url: 'https://slack.com/api/oauth.v2.access',
scope: 'chat:write,channels:read,channels:history,im:history,users:read',
register_url: 'https://api.slack.com/apps?new_app=1',
docs_url: 'https://api.slack.com/authentication/oauth-v2',
note: 'Create New App · From scratch · Add Bot Token Scopes (chat:write etc.) · Set Redirect URL to value below.',
},
stripe: {
name: 'Stripe Connect', icon: 'stripe', tint: '#635BFF',
auth_url: 'https://connect.stripe.com/oauth/authorize',
token_url: 'https://connect.stripe.com/oauth/token',
scope: 'read_write',
register_url: 'https://dashboard.stripe.com/settings/connect',
docs_url: 'https://docs.stripe.com/connect/oauth-reference',
note: 'Settings → Connect → Onboarding options → OAuth · Set redirect URI to value below.',
},
notion: {
name: 'Notion', icon: 'notion', tint: '#000000',
auth_url: 'https://api.notion.com/v1/oauth/authorize',
token_url: 'https://api.notion.com/v1/oauth/token',
scope: '',
register_url: 'https://www.notion.so/my-integrations',
docs_url: 'https://developers.notion.com/docs/authorization',
note: 'New integration · Public integration · Add the redirect URI below.',
extra_params: { owner: 'user', response_type: 'code' },
},
hubspot: {
name: 'HubSpot', icon: 'hubspot', tint: '#FF7A59',
auth_url: 'https://app.hubspot.com/oauth/authorize',
token_url: 'https://api.hubapi.com/oauth/v1/token',
scope: 'crm.objects.contacts.read crm.objects.contacts.write content',
register_url: 'https://developers.hubspot.com/get-started',
docs_url: 'https://developers.hubspot.com/docs/api/oauth-quickstart-guide',
note: 'Sign in to developers.hubspot.com → Apps → Create app · Auth tab · Add redirect URL below · Copy Client ID + Client Secret.',
},
mailchimp: {
name: 'Mailchimp', icon: 'mailchimp', tint: '#FFE01B',
auth_url: 'https://login.mailchimp.com/oauth2/authorize',
token_url: 'https://login.mailchimp.com/oauth2/token',
scope: '',
register_url: 'https://us1.admin.mailchimp.com/account/oauth2/',
docs_url: 'https://mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/',
note: 'Account → Extras → API keys → Register OAuth2 app · Set redirect URI to value below.',
},
discord: {
name: 'Discord', icon: 'discord', tint: '#5865F2',
auth_url: 'https://discord.com/oauth2/authorize',
token_url: 'https://discord.com/api/oauth2/token',
scope: 'identify guilds bot',
register_url: 'https://discord.com/developers/applications',
docs_url: 'https://discord.com/developers/docs/topics/oauth2',
note: 'New Application · OAuth2 → General · Add Redirect URI · Copy Client ID + Secret.',
},
};
function oauthRedirectUri(vendor) { return `${PUBLIC_BASE}/oauth/${vendor}/callback`; }
function newOAuthState() { return require('crypto').randomBytes(24).toString('hex'); }
function pruneStaleStates() {
const cutoff = Date.now() - 30 * 60 * 1000;
for (const k of Object.keys(oauthState)) if (oauthState[k].created_at < cutoff) delete oauthState[k];
save("oauth-state", oauthState);
}
// Coerce to string at the access seam — users.json mixes number `1` (seed admin)
// and `Date.now()` ids (created via /api/admin/users). JS object keys are strings,
// but `credentials[1]` and `credentials["1"]` access the same slot ONLY because the
// number gets coerced to string at indexing time. The bug surfaces if anyone ever
// switches storage to a Map/SQLite where number≠string. Pinning everything to
// string here means the code is correct under both storage substrates.
function userCreds(userId) { return credentials[String(userId)] || {}; }
function maskFields(c) {
if (!c) return c;
const out = {};
for (const k of Object.keys(c)) {
if (k.startsWith("_")) { out[k] = c[k]; continue; } // provenance fields pass through
const v = String(c[k] || "");
out[k] = v.length > 8 ? `${v.slice(0,4)}${"*".repeat(8)}${v.slice(-4)}` : "••••••••";
}
return out;
}
// ------------- auth -------------
function sign(payload) {
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
const sig = crypto.createHmac("sha256", SECRET).update(body).digest("base64url");
return `${body}.${sig}`;
}
function verify(token) {
if (!token || !token.includes(".")) return null;
const [body, sig] = token.split(".");
const want = crypto.createHmac("sha256", SECRET).update(body).digest("base64url");
if (sig !== want) return null;
try { return JSON.parse(Buffer.from(body, "base64url").toString("utf8")); } catch { return null; }
}
function requireAuth(req, res, next) {
const session = verify(req.cookies?.cc_session);
if (!session) return res.status(401).json({ error: "unauthorized" });
req.session = session; next();
}
function requireAdmin(req, res, next) {
requireAuth(req, res, () => {
if (req.session.role !== "admin") return res.status(403).json({ error: "forbidden" });
next();
});
}
function requireAuthPage(req, res, next) {
const session = verify(req.cookies?.cc_session);
if (!session) return res.redirect("/login");
req.session = session; next();
}
function requireAdminPage(req, res, next) {
requireAuthPage(req, res, () => {
if (req.session.role !== "admin") return res.status(403).send("Forbidden — admin only");
next();
});
}
function logEvent(ev) {
audit.unshift({ ts: new Date().toISOString(), ...ev });
audit = audit.slice(0, 500);
save("audit", audit);
}
// ------------- intent router (chat) -------------
function planChat(message, session) {
const m = (message || "").toLowerCase();
const calls = [];
const find = id => CONNECTORS.find(c => c.id === id);
// Slack — real impl. Trigger on: "slack", "#channel", "tell ...", "post to #...", "message the team"
const hasChannelHash = /#[a-z0-9_-]+/i.test(message);
if (/\bslack\b/i.test(m) || hasChannelHash || /\b(tell|message|notify|alert|ping)\b.*\b(team|channel|crew|everyone|#)/i.test(m)) {
const channelMatch = message.match(/#([a-z0-9_-]+)/i)
|| message.match(/\b(?:in|to|on)\s+#?([a-z0-9_-]{2,})\b/i)
|| message.match(/\bchannel\s+#?([a-z0-9_-]{2,})\b/i);
const channel = channelMatch ? channelMatch[1] : "general";
let text = "";
const quoted = message.match(/["']([^"']+)["']/);
if (quoted) text = quoted[1];
else {
// strip the leading verb + channel ref, keep the rest as message
text = message
.replace(/#[a-z0-9_-]+/ig, "")
.replace(/\b(in|on|to)\s+#?[a-z0-9_-]+/ig, "")
.replace(/^(slack|tell|post|message|notify|alert|ping|let)\s+/i, "")
.replace(/^(the\s+)?(team|channel|everyone|crew|us)\s*/i, "")
.replace(/^(saying|that|message)\s+/i, "")
.trim();
}
if (!text) text = message;
return { reply: `Slack message queued for approval → #${channel}: "${text.slice(0, 100)}"`,
toolCalls: [{ connector: "slack", name: "Slack", action: "chat.postMessage", queued: true,
input: { channel, text } }] };
}
if (/(all|every).*(social|channel|platform)/.test(m) || /post.*(everywhere|all)/.test(m)) {
["instagram","facebook","tiktok","pinterest","linkedin","youtube_shorts","x_twitter","threads","bluesky","reddit"]
.forEach(id => { const c = find(id); if (c) calls.push({ connector:id, name:c.name, action:"post.create", queued:true }); });
return { reply: `Drafting posts for all 10 social platforms. ${calls.length} actions queued for approval.`, toolCalls: calls };
}
// Stripe — refund / charge / customer
if (/refund/.test(m)) {
const ch = message.match(/\b(ch_[a-zA-Z0-9]+|pi_[a-zA-Z0-9]+|re_[a-zA-Z0-9]+)\b/);
const amt = message.match(/\$(\d+(?:\.\d{2})?)/);
const input = {};
if (ch) {
if (ch[1].startsWith("ch_")) input.charge = ch[1];
else if (ch[1].startsWith("pi_")) input.payment_intent = ch[1];
}
if (amt) input.amount = Math.round(parseFloat(amt[1]) * 100);
return { reply: `Stripe refund queued for approval${ch?` (${ch[1]})`:""}${amt?` — $${amt[1]}`:" — full amount"}.`,
toolCalls: [{ connector:"stripe", name:"Stripe", action:"refund.create", queued:true, input }] };
}
if (/(stripe|payment).*(balance|how much)/i.test(m)) {
return { reply: "Fetching Stripe balance.", toolCalls: [{ connector:"stripe", name:"Stripe", action:"balance.get", queued:false }] };
}
if (/recent.*(charge|payment)/i.test(m) || /list.*charge/i.test(m)) {
return { reply: "Listing recent Stripe charges.", toolCalls: [{ connector:"stripe", name:"Stripe", action:"charges.list", queued:false, input:{ limit:10 } }] };
}
// Cloudflare
if (/purge.*cache|cache.*purge|clear.*cache/i.test(m)) {
const dom = message.match(/\b([a-z0-9-]+\.[a-z]{2,})\b/i);
if (dom) return { reply: `Cloudflare cache purge queued for ${dom[1]}.`,
toolCalls: [{ connector:"cloudflare", name:"Cloudflare", action:"cache.purge_all", queued:true, input:{ zone: dom[1] }}] };
return { reply: "Which domain? Try: 'purge cache for example.com'.", toolCalls: [] };
}
if (/list.*(dns|records)/i.test(m) || /show.*dns/i.test(m)) {
const dom = message.match(/\b([a-z0-9-]+\.[a-z]{2,})\b/i);
if (dom) return { reply: `Listing DNS for ${dom[1]}.`,
toolCalls: [{ connector:"cloudflare", name:"Cloudflare", action:"dns.list", queued:false, input:{ zone: dom[1] }}] };
}
if (/list.*zones?|all.*domains?|cloudflare.*domains?/i.test(m)) {
return { reply: "Listing Cloudflare zones.", toolCalls: [{ connector:"cloudflare", name:"Cloudflare", action:"zone.list", queued:false }] };
}
if (/(mailchimp|email).*(campaign|send|draft)/.test(m)) return { reply: "Mailchimp campaign drafted, queued for approval.", toolCalls: [{ connector:"mailchimp", name:"Mailchimp", action:"campaign.send", queued:true }] };
if (/(clickup|asana).*task/.test(m) || /create.*task/.test(m)) {
const t = /asana/.test(m) ? "asana" : "clickup";
const c = find(t);
return { reply: `Created ${c.name} task (non-sensitive, executed).`, toolCalls: [{ connector:t, name:c.name, action:"task.create", queued:false }] };
}
if (/(shopify|order)/.test(m)) return { reply: "I can pull recent Shopify orders, fulfill, refund, or update tags. Tell me which.", toolCalls: [] };
return {
reply: `I'm wired to ${CONNECTORS.length} tools. Try: "post our newest product to all socials", "refund order #1234", "draft a Mailchimp campaign for new arrivals", or "create a ClickUp task for the design team".`,
toolCalls: []
};
}
// ------------- app -------------
const app = express();
app.set("trust proxy", 1); // single nginx hop; do not trust arbitrary X-Forwarded-* chains
app.use(express.json({ limit: "200kb" }));
app.use(cookieParser());
// Security headers — manual CSP/HSTS (no helmet dep). Allow simpleicons + jsdelivr for connector logos, Google Fonts.
app.use((req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
if (req.secure || req.headers["x-forwarded-proto"] === "https") {
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
}
res.setHeader(
"Content-Security-Policy",
[
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: https://cdn.simpleicons.org https://cdn.jsdelivr.net https://*.googleusercontent.com",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self' https://*.browserbase.com",
].join("; ")
);
next();
});
app.use("/static", express.static(PUB, { maxAge: "7d", etag: true, immutable: false }));
// SEO: robots + sitemap
app.get("/robots.txt", (req, res) => {
res.type("text/plain").send(
`User-agent: *
Allow: /
Disallow: /admin
Disallow: /api
Disallow: /oauth
Sitemap: https://${DOMAIN}/sitemap.xml
# RSS feed mirroring /changelog
# Most crawlers auto-discover via <link rel="alternate"> on every page;
# this is the explicit hint for the few that don't.
# Feed: https://${DOMAIN}/feed
`
);
});
app.get("/sitemap.xml", (req, res) => {
// Per-URL lastmod resolved from the underlying HTML file's mtime when available; falls back
// to server start time for dynamic routes. Helps Google decide which pages to recrawl after
// the post-pivot copy sweep.
const staticPages = [
{ url: "/", file: "homepage.html", priority: "1.0" },
{ url: "/login", file: "login.html", priority: "0.4" },
{ url: "/services", file: "services.html", priority: "0.9" },
{ url: "/legal-notice", file: "legal-notice.html", priority: "0.6" },
{ url: "/how-it-works", file: "how-it-works.html", priority: "0.8" },
{ url: "/connectors", file: null, priority: "0.7" },
{ url: "/about", file: null, priority: "0.5" },
{ url: "/faq", file: null, priority: "0.5" },
{ url: "/docs", file: null, priority: "0.5" },
{ url: "/pricing", file: null, priority: "0.7" },
{ url: "/changelog", file: null, priority: "0.4" },
{ url: "/privacy", file: null, priority: "0.3" },
{ url: "/terms", file: null, priority: "0.3" },
];
const fallbackIso = (typeof _serverStartIso !== "undefined") ? _serverStartIso : new Date().toISOString();
const fileLastmod = (filename) => {
if (!filename) return fallbackIso;
try { return new Date(require("fs").statSync(path.join(PUB, filename)).mtime).toISOString(); }
catch { return fallbackIso; }
};
const staticEntries = staticPages.map(p => {
const lm = fileLastmod(p.file);
const cf = p.url === '/' ? 'weekly' : 'monthly';
return ` <url><loc>https://${DOMAIN}${p.url}</loc><lastmod>${lm}</lastmod><changefreq>${cf}</changefreq><priority>${p.priority}</priority></url>`;
});
const connectorEntries = CONNECTORS.map(c => ` <url><loc>https://${DOMAIN}/connectors/${c.id}</loc><lastmod>${fallbackIso}</lastmod><changefreq>monthly</changefreq><priority>0.5</priority></url>`);
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${[...staticEntries, ...connectorEntries].join("\n")}
</urlset>`;
res.type("application/xml").send(xml);
});
// HTML pages
// Public homepage — interactive demo + landing. Authed users skip ahead.
app.get("/", (req, res) => {
const session = verify(req.cookies?.cc_session);
if (session) return res.redirect(session.role === "admin" ? "/admin" : "/chat");
res.sendFile(path.join(PUB, "homepage.html"));
});
app.get("/login", (req, res) => res.sendFile(path.join(PUB, "login.html")));
// Helper: serve a static HTML file with a real Last-Modified header (CDN/proxy cache hint).
// Express's sendFile sets Last-Modified by default when serving static files via res.sendFile +
// `lastModified: true`, but only when the file is served as a static asset; explicit routes
// don't get it automatically. This helper closes that gap.
function _sendHtmlWithLM(res, filename) {
const fp = path.join(PUB, filename);
try {
const lm = fs.statSync(fp).mtime.toUTCString();
res.setHeader("Last-Modified", lm);
res.setHeader("Cache-Control", "public, max-age=300, must-revalidate");
} catch {}
return res.sendFile(fp);
}
app.get("/services", (req, res) => _sendHtmlWithLM(res, "services.html"));
app.get("/legal-notice", (req, res) => _sendHtmlWithLM(res, "legal-notice.html"));
app.get("/how-it-works", (req, res) => _sendHtmlWithLM(res, "how-it-works.html"));
app.get("/chat", requireAuthPage, (req, res) => res.sendFile(path.join(PUB, "chat.html")));
app.get("/connections", requireAuthPage, (req, res) => res.sendFile(path.join(PUB, "connections.html")));
app.get("/connections/import", requireAuthPage, (req, res) => res.sendFile(path.join(PUB, "connections-import.html")));
app.get("/brand", requireAuthPage, (req, res) => res.sendFile(path.join(PUB, "brand.html")));
app.get("/fill", requireAuthPage, (req, res) => res.sendFile(path.join(PUB, "fill.html")));
app.get("/admin", requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin.html")));
app.get("/admin/users", requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin-users.html")));
app.get("/admin/connectors", requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin-connectors.html")));
app.get("/admin/wizard", requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin-wizard.html")));
// Wizard helper — peek at the secrets-manager vault for a connector's likely keys.
// Local-only convenience: returns a per-field map of values found, or {found:false}.
// SECURITY: gated to admin + only reads the canonical .env path; never proxies arbitrary paths.
app.get("/api/wizard/vault-search", requireAdmin, (req, res) => {
const cid = String(req.query.connector || "").replace(/[^a-z0-9_-]/gi, "");
if (!cid) return res.status(400).json({ error: "connector required" });
// Reuse the same map the import endpoint uses — invert it: { connectorId: { field: [keys...] } }
const inv = {};
for (const [envKey, m] of Object.entries(ENV_KEY_TO_CONNECTOR || {})) {
if (!inv[m.connector]) inv[m.connector] = {};
if (!inv[m.connector][m.field]) inv[m.connector][m.field] = [];
inv[m.connector][m.field].push(envKey);
}
const wanted = inv[cid];
if (!wanted) return res.json({ found: false, message: "no env-key map for " + cid });
// Read the vault
const vaultPath = (process.env.VC_VAULT_PATH || (process.env.HOME || "/root") + "/Projects/secrets-manager/.env");
let vault;
try { vault = fs.readFileSync(vaultPath, "utf8"); }
catch (e) { return res.json({ found: false, message: "vault not readable on this server (expected on Kamatera; works on Mac dev)" }); }
const env = {};
for (const line of vault.split("\n")) {
const m = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.+?)\s*$/);
if (m) env[m[1]] = m[2];
}
// Match each field
const fields = {};
for (const [field, candidates] of Object.entries(wanted)) {
for (const k of candidates) {
if (env[k]) { fields[field] = env[k]; break; }
}
}
if (!Object.keys(fields).length) return res.json({ found: false, message: "no values in vault for those fields" });
res.json({ found: true, fields });
});
app.get("/admin/approvals", requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin-approvals.html")));
app.get("/admin/audit", requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin-audit.html")));
app.get("/admin/services-leads", requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin-services-leads.html")));
// public endpoints
// Admin: force a re-fire of the preset cache prime. Useful when Mac1 evicts qwen3:14b
// (concurrent codex run, etc.) and visitors are eating cold-loads. Authenticated admins
// can hit this and the next 5 preset queries become instant cache hits.
app.post("/api/admin/cache/prime", requireAdmin, async (req, res) => {
const presets = [
"post in #launch on slack saying we shipped",
"refund order 1234 on shopify",
"purge cloudflare cache for venturaclaw.com",
"add a row to my hubspot deals pipeline",
"send a message to the team",
];
const userConnected = new Set(CONNECTORS.map(c => c.id));
let primed = 0;
for (const m of presets) {
try {
const intent = await LLM.classifyIntent(m, { connectors: CONNECTORS, userConnected });
if (intent) {
_demoCacheSet(m.trim().toLowerCase(), {
connector: intent.connector, action: intent.action || null,
reason: intent.reason || null, question: intent.question || null,
options: intent.options || null, suggested: intent.suggested || null,
});
primed++;
}
} catch { /* continue */ }
}
logEvent({ kind: "admin_cache_prime", by: req.session.email, primed, total: presets.length });
res.json({ ok: true, primed, total: presets.length, cache_size: _demoCache.size });
});
// /healthz — text "ok" for load-balancer probes; JSON variant for admin observability.
app.get("/healthz", (req, res) => {
if (req.query.format === "json" || req.headers.accept?.includes("application/json")) {
return res.json({
ok: true,
uptime_s: Math.round(process.uptime()),
mem_mb: Math.round(process.memoryUsage().rss / 1024 / 1024),
demo_cache_size: _demoCache.size,
demo_cache_max: DEMO_CACHE_MAX,
llm: {
primary: LLM.OLLAMA_URL,
primary_model: LLM.OLLAMA_MODEL,
fallback: process.env.OLLAMA_FALLBACK_URL || null,
},
domain: DOMAIN,
node: process.version,
});
}
res.type("text").send("ok");
});
// Favicon — gold connector-graph SVG. Same file served at /favicon.ico, /favicon.svg,
// /apple-touch-icon.png to satisfy every browser/social shape (browsers accept SVG with the right Content-Type).
app.get(/^\/(favicon\.ico|favicon\.svg|apple-touch-icon\.png|apple-touch-icon-precomposed\.png)$/, (req, res) => {
res.set("Cache-Control", "public, max-age=86400");
res.type("image/svg+xml").sendFile(path.join(PUB, "favicon.svg"));
});
app.get("/api/llm/health", requireAuth, async (req, res) => res.json(await LLM.health()));
// Public demo classifier — for the landing-page "try it" widget.
// CRITICAL: intent classification ONLY. Never executes a real connector. Never
// reads or writes credentials. Output is purely the LLM's routing decision so
// visitors can see what the agent WOULD do with their command.
const demoLimiter = rateLimit({
windowMs: 60 * 1000, max: 8, standardHeaders: true, legacyHeaders: false,
message: { error: "rate_limited", msg: "Demo limit hit — try again in a minute or sign up for an account." }
});
// In-memory LRU cache for demo-classify. Same query 2x = instant 2nd response.
// Critical because Mac1's qwen3 cold-load can hit 30-60s during contention.
const _demoCache = new Map(); // normalizedMessage -> { intent, at }
const DEMO_CACHE_MAX = 200;
const DEMO_CACHE_TTL_MS = 10 * 60 * 1000; // 10 min
function _demoCacheGet(key) {
const e = _demoCache.get(key);
if (!e) return null;
if (Date.now() - e.at > DEMO_CACHE_TTL_MS) { _demoCache.delete(key); return null; }
// touch (LRU) — re-insert moves to end
_demoCache.delete(key); _demoCache.set(key, e);
return e.intent;
}
function _demoCacheSet(key, intent) {
if (_demoCache.size >= DEMO_CACHE_MAX) {
// Evict oldest (Map preserves insertion order)
const first = _demoCache.keys().next().value;
_demoCache.delete(first);
}
_demoCache.set(key, { intent, at: Date.now() });
}
// Public catalog — same data as /api/connectors but unauthenticated, for the
// homepage demo widget. Only ships catalog metadata (no creds, no health probes).
app.get("/api/public/connectors", (req, res) => {
res.json({ connectors: CONNECTORS.map(c => ({
id: c.id, name: c.name, category: c.category, logo: c.logo, tint: c.tint,
triggers: c.triggers, actions: c.actions, sensitive: !!c.sensitive,
})) });
});
// Public services hub catalog — 18 ready-to-run orchestrations on top of the connectors.
// Mirrors /api/public/connectors. Use for SEO syndication, partner listings, future affiliate feed.
const SERVICES_CATALOG = [
{ id: "business-bank-account", category: "banking", name: "Business bank account", partners: ["Mercury","Relay","Bluevine"], price_free: "Recommendation", price_paid: 39, unit: "flat" },
{ id: "stripe-square-setup", category: "banking", name: "Stripe / Square setup", partners: ["Stripe Connect","Square"], price_free: "DIY", price_paid: 49, unit: "flat" },
{ id: "payroll-setup", category: "banking", name: "Payroll setup", partners: ["Gusto","Justworks"], price_free: "DIY", price_paid: 79, unit: "flat" },
{ id: "business-credit-card", category: "banking", name: "Business credit card", partners: ["BREX","Mercury IO","Capital One"], price_free: "Guidance", price_paid: null, unit: "self-serve" },
{ id: "accounting-setup", category: "banking", name: "Accounting setup", partners: ["QuickBooks","Wave","Xero"], price_free: "DIY", price_paid: 59, unit: "flat" },
{ id: "quarterly-estimates", category: "tax-help", name: "Quarterly estimates", partners: ["IRS Direct Pay"], price_free: "Calc + reminders", price_paid: null, unit: "cpa-referral" },
{ id: "schedule-c-walkthrough", category: "tax-help", name: "Schedule C walkthrough", partners: [], price_free: "Walkthrough", price_paid: null, unit: "cpa-referral" },
{ id: "1099-nec-filing", category: "tax-help", name: "1099-NEC filing", partners: ["Track1099","TaxBandits"], price_free: "DIY", price_paid: 9, unit: "per-form" },
{ id: "cpa-referral", category: "tax-help", name: "CPA referral", partners: [], price_free: "Intro", price_paid: null, unit: "free-referral" },
{ id: "general-liability", category: "insurance", name: "General liability", partners: ["Embroker","Coverwallet","Next"], price_free: "Quote routing", price_paid: null, unit: "broker-quote" },
{ id: "professional-liability", category: "insurance", name: "Professional liability (E&O)", partners: ["Embroker","Coverwallet","Next"], price_free: "Quote routing", price_paid: null, unit: "broker-quote" },
{ id: "cyber-liability", category: "insurance", name: "Cyber liability", partners: ["Embroker","Coalition"], price_free: "Quote routing", price_paid: null, unit: "broker-quote" },
{ id: "bop", category: "insurance", name: "Business Owner's Policy", partners: ["Embroker","Hiscox"], price_free: "Quote routing", price_paid: null, unit: "broker-quote" },
{ id: "domain-email-setup", category: "identity", name: "Domain + email", partners: ["Porkbun","Namecheap","Cloudflare"], price_free: "Walkthrough", price_paid: 39, unit: "flat-plus-reg-fee" },
{ id: "virtual-mailbox", category: "identity", name: "Virtual mailbox", partners: ["iPostal1"], price_free: "Recommendation", price_paid: null, unit: "provider-monthly" },
{ id: "operating-agreement", category: "documents", name: "Operating agreement", partners: ["template-engine"], price_free: "Template", price_paid: 49, unit: "flat" },
{ id: "privacy-tos-generator", category: "documents", name: "Privacy policy + ToS", partners: ["template-engine"], price_free: "Generate", price_paid: 39, unit: "flat" },
{ id: "contract-templates", category: "documents", name: "Contract templates", partners: ["template-engine"], price_free: "Library", price_paid: 29, unit: "flat" },
];
app.get("/api/public/services", (req, res) => {
res.json({
count: SERVICES_CATALOG.length,
services: SERVICES_CATALOG,
notes: {
operating_model: "API-orchestration only — no government-portal filing",
currency: "USD",
docs_url: `https://${DOMAIN}/services`,
flow_url: `https://${DOMAIN}/how-it-works`,
legal_notice_url: `https://${DOMAIN}/legal-notice`,
},
});
});
app.post("/api/demo-classify", demoLimiter, express.json({ limit: "2kb" }), async (req, res) => {
const { message } = req.body || {};
if (typeof message !== "string" || message.length < 3 || message.length > 200) {
return res.status(400).json({ error: "bad_input", msg: "Message must be 3–200 characters." });
}
const cacheKey = message.trim().toLowerCase();
const cached = _demoCacheGet(cacheKey);
if (cached) return res.json({ ok: true, intent: cached, cached: true });
const userConnected = new Set(CONNECTORS.map(c => c.id));
try {
const intent = await LLM.classifyIntent(message, { connectors: CONNECTORS, userConnected });
if (!intent) {
const out = { connector: "none", reason: "local LLM offline or no match" };
// Don't cache "offline" — next request might be lucky.
return res.json({ ok: true, intent: out });
}
const out = {
connector: intent.connector,
action: intent.action || null,
reason: intent.reason || null,
question: intent.question || null,
options: intent.options || null,
suggested: intent.suggested || null,
};
_demoCacheSet(cacheKey, out);
res.json({ ok: true, intent: out });
} catch (e) {
res.status(500).json({ error: "classify_failed" });
}
});
// Public services-demo endpoint — "Try it now" widget.
// Returns a structured plan-of-API-calls for a given operation (no external hits yet — risk-free demo).
// Captures the desired domain + visitor IP for follow-up via the lead viewer.
const SERVICES_DEMO_PATH = path.join(__dirname, "data", "services-demo.jsonl");
const servicesDemoLimiter = rateLimit({
windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false,
message: { error: "rate_limited", msg: "Slow down — 30/min." }
});
app.post("/api/services-demo/domain-email", servicesDemoLimiter, express.json({ limit: "2kb" }), async (req, res) => {
const { domain, business_name, forward_to } = req.body || {};
if (typeof domain !== "string" || !/^[a-z0-9-]+\.[a-z]{2,24}$/i.test(domain.trim()) || domain.length > 60) {
return res.status(400).json({ error: "bad_domain", msg: "Domain must be a valid hostname like 'mybiz.com'." });
}
const dom = domain.trim().toLowerCase();
const tld = dom.split(".").pop();
// Approximate pricing per TLD. Real Porkbun pricing API would replace this; for the demo it's a fixed table.
const priceTable = { com: 9.13, net: 11.55, org: 9.66, io: 32.99, ai: 70.45, co: 26.41, app: 15.18, dev: 16.30, me: 24.99, biz: 17.99, info: 6.95 };
const reg_price = priceTable[tld] ?? 18.99;
const plan = {
summary: `Register ${dom}, point DNS at Cloudflare, set up info@${dom} with SPF/DKIM/DMARC, return working business email.`,
estimated_total_seconds: 47,
steps: [
{ n: 1, api: "Porkbun /api/json/v3/domain/checkAvailability", action: `check ${dom}`, expected: "available / unavailable + reg price", risk: "read-only" },
{ n: 2, api: "Porkbun /api/json/v3/domain/registerDomain", action: `register ${dom} for 1 year`, expected: `success → domain in your account`, cost: `$${reg_price.toFixed(2)} reg fee` },
{ n: 3, api: "Cloudflare POST /zones", action: `create zone for ${dom}`, expected: "zone_id + nameservers", risk: "create" },
{ n: 4, api: "Porkbun /api/json/v3/domain/updateNs", action: `set nameservers to Cloudflare`, expected: "propagation begins (~5 min)", risk: "destructive: changes ownership of DNS" },
{ n: 5, api: "Cloudflare POST /zones/{id}/dns_records ×6", action: `create A, MX, SPF, DKIM, DMARC, _domainkey records`, expected: "all records green in CF dashboard", risk: "create" },
{ n: 6, api: "Cloudflare email-routing API", action: `enable forwarding info@${dom} → ${forward_to || "[your inbox]"}`, expected: "rule active", risk: "create", needs_input: !forward_to },
{ n: 7, api: "internal verification job", action: `wait for SPF/DKIM/DMARC to validate via Cloudflare`, expected: "all 3 = pass", risk: "read-only" },
],
fees: {
ventura_claw_orchestration: 39.00,
domain_registration: reg_price,
cloudflare: 0,
total: +(39 + reg_price).toFixed(2),
},
you_get: [
`${dom} registered in your name (you own it, not us)`,
`info@${dom} forwarding to wherever you want`,
`SPF / DKIM / DMARC configured so your email lands in inboxes`,
`Cloudflare dashboard handed over to you`,
`Audit trail of every API call we fired (Cloudflare CFs, Porkbun receipt)`,
],
not_included: [
"Mailbox storage (we set up forwarding, not Gmail/Outlook)",
"Trademark search on the domain name",
"WHOIS privacy (Porkbun does it free; on by default)",
],
next_step: forward_to && business_name ? "ready_to_run" : "needs_inputs",
};
// Persist for follow-up
try {
fs.mkdirSync(path.dirname(SERVICES_DEMO_PATH), { recursive: true });
fs.appendFileSync(SERVICES_DEMO_PATH, JSON.stringify({
at: new Date().toISOString(),
kind: "domain-email",
domain: dom,
business_name: (business_name || "").slice(0, 80) || null,
forward_to: (forward_to || "").slice(0, 200) || null,
ip: (req.headers["x-forwarded-for"] || req.ip || "").toString().split(",")[0].trim(),
ua: (req.headers["user-agent"] || "").slice(0, 200),
}) + "\n");
} catch (e) {
console.warn("[services-demo] persist failed:", e.message);
}
res.json({ ok: true, plan });
});
// Public services-lead endpoint — captures "Get started" form submissions.
// Writes to JSONL (lossless) + best-effort email via George Gmail (non-blocking).
const SERVICES_LEADS_PATH = path.join(__dirname, "data", "services-leads.jsonl");
const servicesLeadLimiter = rateLimit({
windowMs: 60 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false,
message: { error: "rate_limited", msg: "Too many submissions — try again in an hour." }
});
async function _emailLeadToSteve(lead) {
const url = process.env.GEORGE_URL || "http://100.107.67.67:9850";
const auth = process.env.GEORGE_AUTH || "admin:DWSecure2024!";
const body = {
to: "steve@designerwallcoverings.com",
subject: `[VenturaClaw lead] ${lead.service} · ${lead.email}`,
html: `<h2>New /services lead</h2>
<table cellpadding=8 style="border-collapse:collapse;font-family:sans-serif">
<tr><td><b>Service</b></td><td>${lead.service}</td></tr>
<tr><td><b>Email</b></td><td>${lead.email}</td></tr>
<tr><td><b>Phone</b></td><td>${lead.phone || "(none)"}</td></tr>
<tr><td><b>State</b></td><td>${lead.state || "(none)"}</td></tr>
<tr><td><b>Notes</b></td><td><pre style="white-space:pre-wrap;font-family:inherit;margin:0">${(lead.notes || "(none)").replace(/[<>&]/g, c => ({"<":"<",">":">","&":"&"}[c]))}</pre></td></tr>
<tr><td><b>UA</b></td><td style="font-size:11px;color:#888">${lead.ua || ""}</td></tr>
<tr><td><b>IP</b></td><td>${lead.ip || ""}</td></tr>
<tr><td><b>Time</b></td><td>${lead.at}</td></tr>
</table>`
};
await fetch(`${url}/api/send`, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": "Basic " + Buffer.from(auth).toString("base64") },
body: JSON.stringify(body),
signal: AbortSignal.timeout(5000),
});
}
app.post("/api/services-lead", servicesLeadLimiter, express.json({ limit: "4kb" }), async (req, res) => {
const { service, email, phone, state, notes } = req.body || {};
// Validate
if (typeof service !== "string" || service.length < 2 || service.length > 80)
return res.status(400).json({ error: "bad_service" });
if (typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 200)
return res.status(400).json({ error: "bad_email" });
if (notes && (typeof notes !== "string" || notes.length > 1500))
return res.status(400).json({ error: "bad_notes" });
if (phone && (typeof phone !== "string" || phone.length > 40))
return res.status(400).json({ error: "bad_phone" });
if (state && (typeof state !== "string" || state.length > 60))
return res.status(400).json({ error: "bad_state" });
const lead = {
at: new Date().toISOString(),
id: "lead_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 8),
service: service.trim(),
email: email.trim().toLowerCase(),
phone: (phone || "").trim() || null,
state: (state || "").trim() || null,
notes: (notes || "").trim() || null,
ip: (req.headers["x-forwarded-for"] || req.ip || "").toString().split(",")[0].trim(),
ua: (req.headers["user-agent"] || "").slice(0, 200),
};
try {
fs.mkdirSync(path.dirname(SERVICES_LEADS_PATH), { recursive: true });
fs.appendFileSync(SERVICES_LEADS_PATH, JSON.stringify(lead) + "\n");
} catch (e) {
console.error("[services-lead] failed to write JSONL:", e.message);
return res.status(500).json({ error: "persist_failed" });
}
// Best-effort email — don't block the response on it.
_emailLeadToSteve(lead).catch(e => console.warn("[services-lead] George email failed:", e.message));
res.json({ ok: true, id: lead.id, msg: "Got it. We'll reply to " + lead.email + " within 1 business day." });
});
// Admin: list recent services leads (auth-gated; for ops review).
app.get("/api/admin/services-leads", requireAdmin, (req, res) => {
try {
const raw = fs.existsSync(SERVICES_LEADS_PATH) ? fs.readFileSync(SERVICES_LEADS_PATH, "utf8") : "";
const leads = raw.trim().split("\n").filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
res.json({ count: leads.length, leads: leads.slice(-100).reverse() });
} catch (e) {
res.status(500).json({ error: "read_failed", msg: e.message });
}
});
// Public services-chat endpoint — answers small-business questions on the /services page.
// Routes via smart-router (local Ollama default, Claude only if SMART_ROUTER_ALLOW_ANTHROPIC=1).
const _smartRouter = require("./lib/smart-router");
const servicesChatLimiter = rateLimit({
windowMs: 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false,
message: { error: "rate_limited", msg: "Slow down — 20/min." }
});
app.post("/api/services-chat", servicesChatLimiter, express.json({ limit: "8kb" }), async (req, res) => {
const { message, history } = req.body || {};
if (typeof message !== "string" || message.length < 3 || message.length > 1500) {
return res.status(400).json({ error: "bad_input", msg: "Message must be 3–1500 characters." });
}
const sys = `You are VenturaClaw's small-business advisor. You help solo founders and small businesses
understand entity formation, taxes, banking, compliance, insurance, and IP. Be specific, plain-English, and
honest. Cite costs in USD. When something is FREE directly from the IRS or a state agency, say so loudly —
warn the user not to pay middlemen for free things (especially EIN). When the user is in genuinely complex
territory (multi-state, foreign income, partnership disputes, audits), recommend they consult a CPA or
attorney. Keep answers under ~200 words unless the question demands depth. Never invent specific state filing
fees — say "varies by state" if you're not sure. Format with short paragraphs and bullets where helpful.
User question:
${message}
Answer:`;
try {
const out = await _smartRouter.route(sys, { allowAnthropic: false, maxTokens: 700, timeoutMs: 45000 });
res.json({
answer: out.answer,
routed_to: out.routed_to_label,
complexity: Math.round(out.complexity_score * 100),
elapsed_ms: out.elapsed_ms,
});
} catch (e) {
res.status(500).json({ error: "chat_failed", msg: e.message.slice(0, 200) });
}
});
// (removed: duplicate /robots.txt handler — first match wins, real handler at line ~478 has the Sitemap line)
// Login rate-limit: 5 attempts / 15 min / IP. Trips return 429.
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, max: 5, standardHeaders: true, legacyHeaders: false,
message: { error: "too_many_attempts", msg: "Too many login attempts. Try again in 15 minutes." }
});
// One-time on-boot password migration: bcrypt-hash any plaintext passwords (old format had `password: "admin"`)
// so users.json on disk is always hashes, never cleartext.
function migratePasswords() {
let migrated = 0;
for (const u of users) {
if (typeof u.password === "string" && !u.password.startsWith("$2")) {
u.password = bcrypt.hashSync(u.password, 10);
migrated++;
}
}
if (migrated) { save("users", users); console.log(`[migration] hashed ${migrated} plaintext password(s)`); }
}
migratePasswords();
async function verifyPassword(stored, supplied) {
if (!stored || !supplied) return false;
if (typeof stored !== "string") return false;
if (stored.startsWith("$2")) return bcrypt.compare(supplied, stored);
// legacy plaintext fallback (shouldn't happen post-migration, but defensive)
return stored === supplied;
}
// auth API
app.post("/api/auth/login", loginLimiter, async (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) return res.status(400).json({ error: "missing" });
const lookup = String(email).toLowerCase();
const u = users.find(x =>
x.email.toLowerCase() === lookup ||
(Array.isArray(x.aliases) && x.aliases.some(a => a.toLowerCase() === lookup))
);
if (!u || !(await verifyPassword(u.password, password))) {
logEvent({ kind:"login_failed", email, ip: req.ip });
return res.status(401).json({ error: "invalid" });
}
const token = sign({ sub: u.id, email: u.email, role: u.role, name: u.name, iat: Date.now() });
res.cookie("cc_session", token, { httpOnly: true, sameSite: "lax", secure: req.secure, maxAge: 7*24*60*60*1000 });
logEvent({ kind:"login", userId: u.id, email: u.email, role: u.role });
res.json({ ok: true, role: u.role, name: u.name });
});
app.post("/api/auth/logout", (req, res) => { res.clearCookie("cc_session"); res.json({ ok: true }); });
app.get("/api/me", requireAuth, (req, res) => res.json({ user: req.session }));
// Admin audit feed — what /admin/audit page reads. Was 404 (smoke-test fix 2026-05-05).
app.get("/api/audit", requireAdmin, (req, res) => {
const limit = Math.min(parseInt(req.query.limit, 10) || 200, 1000);
res.json({ events: audit.slice(0, limit), total: audit.length });
});
// connectors API
app.get("/api/connectors", requireAuth, (req, res) => {
const my = userCreds(req.session.sub);
const real = Object.fromEntries(REAL_CONNECTORS.listConfigured(my).map(c => [c.id, c.configured]));
const enriched = CONNECTORS.map(c => ({ ...c, real_impl: c.id in real, configured: !!real[c.id] }));
res.json({ connectors: enriched, support_email: SUPPORT_EMAIL });
});
// Public connector directory — overview page + 56 per-connector pages.
// Generates SEO-indexable content from CONNECTORS catalog. No auth, no creds.
const _connectorsBy = Object.fromEntries(CONNECTORS.map(c => [c.id, c]));
function _renderConnectorIndex() {
const cats = {};
for (const c of CONNECTORS) (cats[c.category] ||= []).push(c);
const catNames = Object.keys(cats).sort();
const sections = catNames.map(cat => `
<section class="cat-section">
<h2>${esc(cat)} <span class="cat-count">${cats[cat].length}</span></h2>
<div class="cat-grid">
${cats[cat].sort((a,b)=>a.name.localeCompare(b.name)).map(c => `
<a class="c-card" href="/connectors/${esc(c.id)}">
<img class="c-logo" src="https://cdn.simpleicons.org/${esc(c.logo||c.id)}/${esc(String(c.tint||'#888').replace('#',''))}" alt="${esc(c.name)} logo" loading="lazy" onerror="this.style.display='none'">
<div class="c-name">${esc(c.name)}</div>
<div class="c-meta">${esc(c.triggers)}t · ${esc(c.actions)}a${c.sensitive?' · ⚠':''}</div>
</a>`).join("")}
</div>
</section>`).join("");
const ld = {
"@context":"https://schema.org","@type":"ItemList",
"name":"VenturaClaw Connectors",
"description":"All SaaS tools that VenturaClaw can route AI commands to.",
"numberOfItems":CONNECTORS.length,
"itemListElement": CONNECTORS.map((c, i) => ({
"@type":"ListItem","position":i+1,
"url":`https://${DOMAIN}/connectors/${c.id}`,
"name":c.name,
})),
};
return _shell({
title: `All ${CONNECTORS.length} Connectors — VenturaClaw`,
desc: `Browse every SaaS tool VenturaClaw routes to: ${CONNECTORS.slice(0,8).map(c=>c.name).join(', ')}, and ${CONNECTORS.length - 8} more. Type one command, the AI picks the right one.`,
canonical: `https://${DOMAIN}/connectors`,
jsonLd: ld,
body: `
<main class="page">
<div class="eyebrow">All connectors · pre-wired</div>
<h1>Every <em>tool</em>, ready to route.</h1>
<p class="lede">VenturaClaw ships with ${CONNECTORS.length} SaaS connectors out of the box. Browse by category below, or just type what you want on the <a href="/" style="color:var(--gold)">homepage demo</a> and let the AI pick. Looking for a curated bundle? See the <a href="/services" style="color:var(--gold)">small-business services hub</a> — 18 ready-to-run orchestrations on top of these connectors.</p>
<div style="margin:0 0 36px;display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<input type="search" id="connFilter" placeholder="Filter by name or category — try "stripe" or "chat"…"
style="flex:1;min-width:240px;background:var(--bg-elevated);color:var(--ink);border:1px solid var(--rule);padding:11px 14px;font-family:var(--mono);font-size:13px;outline:none;border-radius:3px"
oninput="filterConn(this.value)" />
<span id="connCount" style="font-family:var(--mono);font-size:10px;letter-spacing:.14em;color:var(--ink-mute)">${CONNECTORS.length} connectors</span>
</div>
${sections}
</main>
<script>
function filterConn(q) {
q = (q || '').trim().toLowerCase();
let shown = 0, total = 0;
document.querySelectorAll('.cat-section').forEach(sec => {
let any = false;
sec.querySelectorAll('.c-card').forEach(card => {
total++;
const name = (card.querySelector('.c-name')?.textContent || '').toLowerCase();
const meta = (card.querySelector('.c-meta')?.textContent || '').toLowerCase();
const cat = (sec.querySelector('h2')?.textContent || '').toLowerCase();
const hit = !q || name.includes(q) || meta.includes(q) || cat.includes(q);
card.style.display = hit ? '' : 'none';
if (hit) { shown++; any = true; }
});
sec.style.display = any ? '' : 'none';
});
const c = document.getElementById('connCount');
if (c) c.textContent = q ? (shown + ' / ' + total + ' match') : (total + ' connectors');
}
</script>`
});
}
function _renderConnectorPage(c) {
// Synthesize 3-4 example commands per connector based on category + actions
const cat = c.category;
const examples = {
commerce: [`refund order 1234 on ${c.name}`, `list recent ${c.name} orders`, `add product "Widget" to ${c.name}`],
payments: [`refund the last ${c.name} charge`, `show this week's ${c.name} balance`, `list failed payments`],
chat: [`post in #launch on ${c.name} saying we shipped`, `list ${c.name} channels`, `DM Alice about the meeting`],
crm: [`add John Doe to ${c.name} as a lead`, `find ${c.name} contacts at Acme Corp`, `update deal stage to Closed-Won`],
email: [`send a welcome email via ${c.name}`, `pause the welcome ${c.name} campaign`, `add subscriber to "VIPs" list`],
docs: [`create a ${c.name} doc titled "Q4 Plan"`, `find the ${c.name} page about onboarding`, `share doc with team`],
infra: [`purge ${c.name} cache for venturaclaw.com`, `list ${c.name} DNS records for example.com`, `add an A record`],
storage: [`upload report.pdf to ${c.name}`, `list files in ${c.name} /reports/`, `share folder with steve@`],
analytics: [`show this week's ${c.name} top pages`, `compare last 7d vs prior 7d`, `export the funnel to CSV`],
social: [`post to ${c.name} with this image`, `schedule a ${c.name} post for Friday 9am`, `find recent ${c.name} comments`],
devtools: [`open a ${c.name} issue titled "Fix login"`, `merge ${c.name} PR #42`, `list open PRs`],
"project-mgmt": [`create a ${c.name} task "Fix SSO"`, `assign task to Alex`, `move card to Done column`],
};
const exs = examples[cat] || [`use ${c.name} to do something`, `list things in ${c.name}`, `update something on ${c.name}`];
const ld = {
"@context":"https://schema.org","@type":"SoftwareSourceCode","name":`${c.name} integration · VenturaClaw`,
"applicationCategory":"BusinessApplication","programmingLanguage":"natural-language",
"description":`Connect ${c.name} to VenturaClaw and run actions with natural-language commands.`
};
return _shell({
title: `${c.name} integration — VenturaClaw`,
desc: `Connect ${c.name} to VenturaClaw. Type "${exs[0]}" and the AI routes it. ${c.triggers} triggers · ${c.actions} actions${c.sensitive ? ' · sensitive actions gated by approval' : ''}.`,
canonical: `https://${DOMAIN}/connectors/${c.id}`,
jsonLd: ld,
body: `
<main class="page page-narrow">
<div class="breadcrumb"><a href="/connectors">All connectors</a> · ${esc(cat)}</div>
<div class="connector-hero">
<img class="c-logo-big" src="https://cdn.simpleicons.org/${esc(c.logo||c.id)}/${esc(String(c.tint||'#888').replace('#',''))}" alt="${esc(c.name)} logo" onerror="this.style.display='none'">
<h1>${esc(c.name)}</h1>
<div class="c-tagline">${esc(cat)} · ${esc(c.triggers)} triggers · ${esc(c.actions)} actions${c.sensitive ? ' · <span style="color:var(--gold)">sensitive · approval-gated</span>' : ''}</div>
</div>
<h2>What you can ask the agent</h2>
<ul class="examples">
${exs.map(ex => `<li><a href="/?demo=${encodeURIComponent(ex)}#demo" style="text-decoration:none;color:inherit"><code>${esc(ex)}</code> <span style="font-size:9px;color:var(--gold);letter-spacing:.18em;margin-left:8px">TRY IT →</span></a></li>`).join("")}
</ul>
<h2>How it works</h2>
<p>Type one of those sentences (or anything similar) on the <a href="/" style="color:var(--gold)">VenturaClaw homepage</a>. The local LLM detects intent and routes to <strong>${esc(c.name)}</strong>. ${c.sensitive ? 'Because writes to ' + esc(c.name) + ' are sensitive, the action lands in your approval queue with the parsed intent shown in plain English — you confirm before anything actually happens.' : 'Read-only actions execute immediately. Writes go through the approval queue.'}</p>
<h2>Auth</h2>
<p>${c.auth === 'oauth2' ? `Connect ${esc(c.name)} via one-click OAuth at <a href="/login" style="color:var(--gold)">sign-in</a> → My Connections.` : `Paste your ${esc(c.name)} API key at sign-in → My Connections. Keys are AES-256-GCM encrypted at rest with a versioned envelope.`}</p>
<p style="margin-top:34px"><a href="${esc(c.docs)}" target="_blank" rel="noopener">${esc(c.name)} API docs ↗</a> · <a href="/login" style="color:var(--gold)">Sign in to connect →</a></p>
</main>`
});
}
function _shell({ title, desc, canonical, body, jsonLd }) {
const ldBlock = jsonLd ? `<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>` : "";
// Auto-generate BreadcrumbList JSON-LD from the canonical URL path. Helps Google rich snippets
// ("Home › Pricing") on every _shell-rendered page without per-route plumbing.
let breadcrumbBlock = "";
try {
const urlPath = (canonical || "").replace(/^https?:\/\/[^/]+/, "");
const seg = urlPath.split("/").filter(Boolean)[0];
if (seg) {
const labelMap = { connectors: "Connectors", about: "About", faq: "FAQ", docs: "API Docs", pricing: "Pricing", changelog: "Changelog", privacy: "Privacy", terms: "Terms" };
const label = labelMap[seg] || (seg.charAt(0).toUpperCase() + seg.slice(1).replace(/-/g, " "));
const bc = { "@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[
{"@type":"ListItem","position":1,"name":"Home","item":`https://${DOMAIN}/`},
{"@type":"ListItem","position":2,"name":label,"item":canonical},
]};
breadcrumbBlock = `<script type="application/ld+json">${JSON.stringify(bc)}</script>`;
}
} catch {}
return `<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<meta name="theme-color" content="#0e0e10">
<title>${esc(title)}</title>
<meta name="description" content="${esc(desc)}">
<link rel="canonical" href="${esc(canonical)}">
<meta property="og:title" content="${esc(title)}"><meta property="og:description" content="${esc(desc)}">
<meta property="og:url" content="${esc(canonical)}"><meta property="og:type" content="website">
<meta property="og:image" content="https://${DOMAIN}/static/og-cover.svg">
<meta name="twitter:card" content="summary_large_image">
<script>(function(){try{if(localStorage.getItem('vc-theme')==='light')document.documentElement.setAttribute('data-theme','light');}catch(e){}})();</script>
${breadcrumbBlock}
${ldBlock}
<link rel="stylesheet" href="/static/style.css">
<style>
html[data-theme="light"] {
--bg: #faf8f3; --bg-elevated: #f1ece1;
--rule: #d6cfbd; --rule-2: #c3b9a2;
--ink: #1a1a1a; --ink-soft: #383530; --ink-mute: #6a655a;
--gold: #8a6520;
}
html, body { transition: background-color 200ms ease, color 200ms ease; }
.theme-toggle {
position: fixed; top: 18px; right: 18px; z-index: 100;
background: var(--bg-elevated); color: var(--ink-mute); border: 1px solid var(--rule);
width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
font-size: 14px; line-height: 1; padding: 0;
transition: all 200ms;
}
.theme-toggle:hover { color: var(--gold); border-color: var(--gold); transform: rotate(20deg); }
.theme-toggle .moon { display: inline; }
.theme-toggle .sun { display: none; }
html[data-theme="light"] .theme-toggle .moon { display: none; }
html[data-theme="light"] .theme-toggle .sun { display: inline; }
.page { max-width: 1080px; margin: 0 auto; padding: 60px 28px; }
.page-narrow { max-width: 760px; }
.eyebrow { font-family: var(--mono); font-size: 11px; letter-spacing: .18em; text-transform: uppercase; color: var(--gold); margin-bottom: 14px; }
h1 { font-family: var(--serif); font-weight: 500; font-size: clamp(36px, 5vw, 56px); letter-spacing: -.01em; line-height: 1; margin: 0 0 22px; }
h1 em { font-style: italic; color: var(--gold); }
h2 { font-family: var(--serif); font-weight: 500; font-size: 26px; margin: 40px 0 14px; letter-spacing: -.005em; }
.lede { font-family: var(--serif); font-style: italic; font-size: 19px; color: var(--ink-soft); max-width: 64ch; line-height: 1.55; margin: 0 0 36px; }
.cat-section { margin-bottom: 44px; }
.cat-count { font-family: var(--mono); font-size: 11px; letter-spacing: .14em; color: var(--ink-mute); margin-left: 10px; vertical-align: middle; }
.cat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; }
.c-card { background: var(--bg-elevated); border: 1px solid var(--rule); border-radius: 3px; padding: 16px 14px; display: flex; flex-direction: column; gap: 6px; text-decoration: none; transition: border-color 180ms, transform 180ms; }
.c-card:hover { border-color: var(--gold); transform: translateY(-2px); }
.c-card .c-logo { width: 22px; height: 22px; }
.c-card .c-name { font-family: var(--serif); font-size: 15px; color: var(--ink); }
.c-card .c-meta { font-family: var(--mono); font-size: 9px; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-mute); }
.breadcrumb { font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-mute); margin-bottom: 18px; }
.breadcrumb a { color: var(--gold); text-decoration: none; }
.connector-hero { margin-bottom: 36px; }
.c-logo-big { width: 56px; height: 56px; margin-bottom: 16px; }
.c-tagline { font-family: var(--mono); font-size: 11px; letter-spacing: .12em; color: var(--ink-soft); margin-top: 8px; }
.examples { font-family: var(--mono); font-size: 13px; line-height: 2; padding-left: 20px; }
.examples code { background: rgba(0,0,0,0.3); color: var(--gold); padding: 2px 8px; border-radius: 2px; font-size: 12px; }
</style>
</head><body>
<header class="topbar" style="max-width:1180px;margin:0 auto;padding:18px 28px;display:flex;justify-content:space-between;align-items:center">
<a href="/" style="text-decoration:none;display:flex;gap:12px;align-items:center">
<div class="logo-dot"></div>
<div class="brand-name" style="font-family:var(--serif);font-size:18px;font-weight:500;color:var(--ink)">Ventura<em style="font-style:italic;color:var(--gold)">Claw</em></div>
</a>
<nav style="display:flex;gap:18px;font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;align-items:center">
<a href="/services" style="color:var(--ink-soft);text-decoration:none">Services</a>
<a href="/connectors" style="color:var(--ink-soft);text-decoration:none">Connectors</a>
<a href="/login" style="color:var(--gold);border:1px solid var(--gold);padding:8px 16px;border-radius:2px;text-decoration:none">Sign in</a>
</nav>
</header>
<button class="theme-toggle" id="themeToggle" type="button" title="Toggle light/dark" aria-label="Toggle theme"><span class="moon">☾</span><span class="sun">☀︎</span></button>
<script>document.getElementById('themeToggle').addEventListener('click',()=>{const c=document.documentElement.getAttribute('data-theme')==='light'?'light':'dark';const n=c==='light'?'dark':'light';if(n==='light')document.documentElement.setAttribute('data-theme','light');else document.documentElement.removeAttribute('data-theme');try{localStorage.setItem('vc-theme',n);}catch{}});</script>
${body}
<footer style="max-width:1180px;margin:60px auto 0;padding:28px;border-top:1px solid var(--rule);font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-mute);display:flex;justify-content:space-between;flex-wrap:wrap;gap:18px">
<span>VenturaClaw · ${esc(DOMAIN)}</span>
<span style="display:flex;gap:18px;flex-wrap:wrap">
<a href="/connectors" style="color:var(--ink-soft);text-decoration:none">Connectors</a>
<a href="/about" style="color:var(--ink-soft);text-decoration:none">About</a>
<a href="/faq" style="color:var(--ink-soft);text-decoration:none">FAQ</a>
<a href="/docs" style="color:var(--ink-soft);text-decoration:none">Docs</a>
<a href="/privacy" style="color:var(--ink-soft);text-decoration:none">Privacy</a>
<a href="/terms" style="color:var(--ink-soft);text-decoration:none">Terms</a>
<a href="/sitemap.xml" style="color:var(--ink-soft);text-decoration:none">Sitemap</a>
<a href="mailto:${esc(SUPPORT_EMAIL)}" style="color:var(--ink-soft);text-decoration:none">${esc(SUPPORT_EMAIL)}</a>
</span>
</footer>
</body></html>`;
}
app.get("/connectors", (req, res) => {
res.type("html").send(_renderConnectorIndex());
});
// Required SaaS pages — privacy, terms, about. Markdown-style content rendered
// through the same _shell template so design stays consistent.
function _renderTextPage(title, eyebrow, h1, blocks) {
return _shell({
title: `${title} — VenturaClaw`,
desc: `${title} for VenturaClaw — ${DOMAIN}.`,
canonical: `https://${DOMAIN}/${title.toLowerCase()}`,
body: `
<main class="page page-narrow">
<div class="eyebrow">${esc(eyebrow)}</div>
<h1>${h1}</h1>
${blocks.map(b => `<p style="font-family:var(--mono);font-size:13px;line-height:1.85;color:var(--ink-soft);margin-bottom:20px">${b}</p>`).join("")}
<p style="margin-top:42px;font-family:var(--mono);font-size:11px;color:var(--ink-mute)">
Last updated 2026-05-07 · Questions: <a href="mailto:${esc(SUPPORT_EMAIL)}" style="color:var(--gold)">${esc(SUPPORT_EMAIL)}</a>
</p>
</main>`
});
}
app.get("/privacy", (req, res) => {
res.type("html").send(_renderTextPage("Privacy", "Privacy policy", `What we <em>store.</em> What we <em>don't.</em>`, [
`<strong>Tokens.</strong> Every API key, OAuth access_token, and refresh_token you save is wrapped in AES-256-GCM with a versioned key envelope before it touches disk. Plaintext never persists. The encryption key is derived from a server-side secret loaded only at boot.`,
`<strong>Commands.</strong> Your chat messages and routed actions are logged in an audit trail tied to your account. The audit log records the action taken, the connector involved, and a 140-character snippet of your original message — not full message bodies, not vendor responses.`,
`<strong>LLM routing.</strong> When the agent classifies your intent, your message is sent to a self-hosted Ollama instance running on Steve's hardware. We do not send your commands to third-party LLM providers (Anthropic, OpenAI, etc.). Your business workflow is not training someone else's model.`,
`<strong>Cookies.</strong> One signed session cookie (HttpOnly, SameSite=Lax). No third-party trackers, no advertising pixels, no analytics SDKs.`,
`<strong>Vendor calls.</strong> When you authorize an action against a connected SaaS tool (Shopify, Stripe, Slack, etc.) — including the multi-API orchestrations on <a href="/services" style="color:var(--gold)">/services</a> — the agent calls that vendor's API directly with <em>your</em> credentials. Their privacy policy applies to that data, not ours. We never act as a man-in-the-middle for the data that flows between you and the vendor.`,
`<strong>Lead capture.</strong> If you submit the "Get Started" form on /services, we store the email, optional phone, optional state, and the free-text notes you typed. That's it — no tracking pixels, no enrichment-vendor lookups, no third-party sharing. We reply within one business day from <a href="mailto:${esc(SUPPORT_EMAIL)}" style="color:var(--gold)">${esc(SUPPORT_EMAIL)}</a>.`,
`<strong>Data export / deletion.</strong> Email <a href="mailto:${esc(SUPPORT_EMAIL)}" style="color:var(--gold)">${esc(SUPPORT_EMAIL)}</a> and we'll respond within 7 days.`,
]));
});
app.get("/terms", (req, res) => {
res.type("html").send(_renderTextPage("Terms", "Terms of use", `Plain-English <em>terms.</em>`, [
`VenturaClaw is provided as-is. The agent routes commands to vendor APIs that you've connected. You are responsible for the actions you authorize — including refunds, posts, DNS changes, and anything else with real-world consequences.`,
`<strong>Sensitive actions are gated.</strong> Writes (refunds, posts, DNS edits, deletions) land in an approval queue with the parsed intent shown in plain English. Nothing actually happens until you approve. We strongly recommend reading what you're approving before clicking.`,
`<strong>No fee-splitting, no commissions.</strong> We do not take a cut of any transactions you run through connected vendors. Your relationship with Stripe, Shopify, etc. is direct. The only money that flows to us is the flat orchestration fee on a paid /services tier; partner fees (registrar, government, USPTO, IRS, broker premiums) are paid directly to the partner and never marked up.`,
`<strong>Acceptable use.</strong> Don't use this to spam, harass, defraud, or violate the terms of any connected vendor. We reserve the right to revoke access for misuse.`,
`<strong>Liability.</strong> To the maximum extent permitted by law, VenturaClaw is not liable for damages resulting from agent-routed actions, vendor outages, or LLM misclassification. Always review approval-queue items before approving.`,
`By signing in, you accept these terms.`,
]));
});
app.get("/faq", (req, res) => {
const qa = [
["Where do my API keys actually live?", "On disk, AES-256-GCM encrypted with a versioned key envelope (key_id rotation supported). The encryption key is derived from a server-side secret loaded only at boot and never logged. Plaintext leaves memory the moment it's saved."],
["Does the AI ever see my full data?", "The local LLM only sees your <em>command</em> (e.g. \"refund order 1234 on Shopify\") to pick a connector and parse arguments. It never sees vendor responses or other connectors' data. Routing runs on Steve's hardware — no third-party LLM API."],
["What if the AI picks the wrong tool?", "Sensitive actions (refunds, posts, DNS edits, deletions) land in an approval queue with the parsed intent shown in plain English. Read it, approve or reject. Reads execute immediately because they don't change state."],
["How does this differ from Zapier or Make?", "Those are workflow builders — you wire triggers to actions on a canvas, then maintain the wires. VenturaClaw is the opposite: type one sentence, the AI picks the connector. No canvas, no Zaps to maintain, no per-task pricing."],
["What's the pricing?", "Free during early access. Eventual model is a flat workspace fee — no per-task metering, no credit math. Pricing changes will give 30 days notice; your audit log + connector registrations are exportable on the way out."],
["Can I self-host?", `Not yet packaged for distribution. Architecture is one Node process + JSON store + local Ollama, so it lifts cleanly. Email <a href="mailto:${SUPPORT_EMAIL}" style="color:var(--gold)">${SUPPORT_EMAIL}</a> for the Docker recipe.`],
["What if I want a connector that's not in the 56?", "Most modern SaaS speak OAuth2 + REST and slot in cleanly — new connectors typically ship in 1-3 days. Anything that exposes a webhook can be wired the same week."],
["Is this production-ready?", "Encryption-at-rest, atomic writes with fsync, audit trail, sensitive-action approval queue, CSP+HSTS, rate limits — yes, by mainstream-SaaS standards. Early-access label is about connector matrix maturity, not security floor."],
];
const ld = {
"@context":"https://schema.org","@type":"FAQPage",
"mainEntity": qa.map(([q,a]) => ({
"@type":"Question","name":q,
"acceptedAnswer":{"@type":"Answer","text":a.replace(/<[^>]+>/g,'')},
})),
};
res.type("html").send(_shell({
title: "FAQ — VenturaClaw",
desc: "How VenturaClaw handles your API keys, what the AI sees, how sensitive actions are gated, and how it compares to Zapier/Make.",
canonical: `https://${DOMAIN}/faq`,
jsonLd: ld,
body: `
<main class="page page-narrow">
<div class="eyebrow">FAQ · what people actually ask</div>
<h1>Honest <em>answers.</em></h1>
<div style="display:grid;gap:14px;margin-top:36px">
${qa.map(([q,a]) => `
<details class="faq-item" style="background:var(--bg-elevated);border:1px solid var(--rule);border-radius:4px;padding:16px 22px">
<summary style="font-family:var(--serif);font-size:17px;font-weight:500;cursor:pointer;list-style:none;position:relative;padding-right:24px">${esc(q)}<span style="position:absolute;right:0;top:50%;transform:translateY(-50%);color:var(--gold);font-family:var(--mono);font-size:18px">+</span></summary>
<p style="font-family:var(--mono);font-size:12px;line-height:1.75;color:var(--ink-soft);margin:12px 0 0">${a}</p>
</details>`).join("")}
</div>
<p style="margin-top:42px;font-family:var(--mono);font-size:11px;color:var(--ink-mute)">
Don't see your question? Email <a href="mailto:${esc(SUPPORT_EMAIL)}" style="color:var(--gold)">${esc(SUPPORT_EMAIL)}</a>.
</p>
</main>
<style>.faq-item[open] { border-color: var(--gold) !important } .faq-item summary::-webkit-details-marker { display: none }</style>`
}));
});
app.get("/docs", (req, res) => {
const examples = [
{
title: "Public catalog — list all 67 connectors",
method: "GET", path: "/api/public/connectors", auth: "none",
curl: `curl https://${DOMAIN}/api/public/connectors | jq '.connectors[0]'`,
reply: `{
"id": "shopify",
"name": "Shopify",
"category": "commerce",
"logo": "shopify",
"tint": "#95BF47",
"triggers": 5,
"actions": 8,
"sensitive": true
}`,
},
{
title: "Public services hub — list all 18 small-business orchestrations",
method: "GET", path: "/api/public/services", auth: "none",
curl: `curl https://${DOMAIN}/api/public/services | jq '.services[] | {id, name, price_paid}'`,
reply: `{
"id": "domain-email-setup",
"name": "Domain + email",
"price_paid": 39
}
{
"id": "operating-agreement",
"name": "Operating agreement",
"price_paid": 49
}
{
"id": "stripe-square-setup",
"name": "Stripe / Square setup",
"price_paid": 49
}`,
},
{
title: "Demo classifier — try the routing without signing in",
method: "POST", path: "/api/demo-classify", auth: "rate-limited (8/min/IP)",
curl: `curl -X POST https://${DOMAIN}/api/demo-classify \\
-H 'content-type: application/json' \\
-d '{"message":"refund order 1234 on shopify"}'`,
reply: `{
"ok": true,
"intent": {
"connector": "shopify",
"action": "order.refund",
"reason": null
}
}`,
},
{
title: "Login — receive a session cookie",
method: "POST", path: "/api/auth/login", auth: "none",
curl: `curl -i -X POST https://${DOMAIN}/api/auth/login \\
-H 'content-type: application/json' \\
-d '{"email":"you@example.com","password":"…"}'`,
reply: `HTTP/2 200
set-cookie: cc_session=…; HttpOnly; Secure; SameSite=Lax
{ "ok": true, "user": { "id": …, "email": "…", "role": "user" } }`,
},
{
title: "Chat — route a command (auth required)",
method: "POST", path: "/api/chat", auth: "session cookie",
curl: `curl -X POST https://${DOMAIN}/api/chat \\
-b 'cc_session=…' \\
-H 'content-type: application/json' \\
-d '{"message":"post in #launch on slack saying we shipped"}'`,
reply: `{
"reply": "Queued slack.chat.postMessage for admin approval.",
"toolCalls": [{
"connector": "slack",
"action": "chat.postMessage",
"queued": true,
"input": { "channel": "launch", "text": "we shipped" }
}]
}`,
},
{
title: "Save a connector token (per-user vault)",
method: "POST", path: "/api/me/connections/:id", auth: "session cookie",
curl: `curl -X POST https://${DOMAIN}/api/me/connections/stripe \\
-b 'cc_session=…' \\
-H 'content-type: application/json' \\
-d '{"api_key":"sk_live_…"}'`,
reply: `{ "ok": true, "saved": ["api_key"] }
# Token is AES-256-GCM encrypted at rest before write.`,
},
];
res.type("html").send(_shell({
title: "API docs — VenturaClaw",
desc: "5 curl examples covering the public + authenticated API surface for VenturaClaw — the AI agent that routes commands across 67 SaaS connectors and orchestrates 18 small-business services on top.",
canonical: `https://${DOMAIN}/docs`,
body: `
<main class="page page-narrow">
<div class="eyebrow">API · curl examples</div>
<h1>The <em>API</em> in 5 calls.</h1>
<p style="font-family:var(--mono);font-size:13px;line-height:1.7;color:var(--ink-soft);margin:0 0 28px">No SDK. No client library. Just HTTP. Auth is a signed session cookie (HttpOnly, Secure, SameSite=Lax). Sensitive actions are gated by the approval queue regardless of how you call them.</p>
${examples.map((e, i) => `
<section style="margin-bottom:38px">
<div style="font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:6px">${String(i+1).padStart(2,'0')} · ${esc(e.method)} ${esc(e.path)} · ${esc(e.auth)}</div>
<h2 style="font-family:var(--serif);font-size:20px;margin:0 0 14px">${esc(e.title)}</h2>
<pre style="font-family:var(--mono);font-size:11px;line-height:1.7;background:rgba(0,0,0,0.34);padding:14px 16px;border-radius:2px;border-left:2px solid var(--gold);margin:0 0 10px;overflow-x:auto;color:var(--ink)">${esc(e.curl)}</pre>
<pre style="font-family:var(--mono);font-size:11px;line-height:1.7;background:rgba(0,0,0,0.18);padding:14px 16px;border-radius:2px;border-left:2px solid var(--good);margin:0;overflow-x:auto;color:var(--ink-soft)">${esc(e.reply)}</pre>
</section>`).join("")}
<p style="margin-top:42px;font-family:var(--mono);font-size:11px;color:var(--ink-mute)">
Need an endpoint that's not here? Email <a href="mailto:${esc(SUPPORT_EMAIL)}" style="color:var(--gold)">${esc(SUPPORT_EMAIL)}</a>.
</p>
</main>`
}));
});
// Public changelog — append entries here as work ships. Most-recent first.
const CHANGELOG = [
{ date: "2026-05-07", title: "API-orchestration pivot + small-business services hub", items: [
"Strategic pivot — /services hub goes from 30 government-portal-style services (LegalZoom-clone, UPL exposure) to 18 API-orchestration services (Stripe Connect, QuickBooks, Cloudflare, Embroker, Track1099 — all real partner APIs, zero regulation risk)",
"Hidden door LIVE — app.venturaclaw.com with Let's Encrypt HTTPS (auto-renew nightly via certbot.timer); teaser at venturaclaw.com/ stays untouched as the public funnel",
"/how-it-works — 3-step explainer with HowTo JSON-LD; /pricing v2 — dual-product split (chat free + per-service flat fees $0–$99); /legal-notice rewritten as 'API orchestration only' (§6400 LDA explicitly inapplicable since we don't file gov forms)",
"Try-it-now demo widget — type a domain, get a 7-step API plan + cost breakdown ($48 total for .com), risk-free preview before commit",
"Lead capture funnel — modal form on every service card → JSONL append + best-effort George Gmail to Steve + /admin/services-leads viewer with 30s auto-refresh and dark/light theme toggle",
"Brand consistency sweep — every reference to 'BusinessClaw' replaced with 'VenturaClaw'; legacy login emails kept as aliases so existing sessions don't break",
"session-debrief skill hardened — per-build-unique workDir (fixes Bubbe's-Block-into-VenturaClaw concurrent-run bug), ElevenLabs as primary voice (TTS_FALLBACK=say required to opt into macOS say), llava critic gate (vision-checks 2 frames before publishing)",
"dw-collections-viewer — sort selector on Save & Deploy with per-site persistence; 'Preview sort' (deploy:false); stale-products pill (>30d mtime); fixed 2 empty sister-sites (1980swallpaper + embroideredwallpaper)",
"SEO — sitemap.xml gets per-URL <lastmod> + <priority>; Last-Modified HTTP header on services/legal-notice/how-it-works for CDN cache hints; /api/public/services endpoint mirroring /api/public/connectors for syndication; 'Connector count' stale '56' swept to live count 67 across homepage/about/pricing/docs/teaser/changelog",
"Theme toggle — sun/moon button shipped on /admin/services-leads + /services + /legal-notice + /how-it-works + every _shell-rendered page (pricing/faq/docs/about/privacy/terms/connectors); anti-flash inline script + light CSS vars + localStorage persistence; warm-paper light palette (#faf8f3) per CLAUDE.md sun/moon rule",
"Continued brand sweep — caught lingering 'Commerce Claw' in _shell topbar (rendered all auth-pages with old name); fixed + added /services to topbar nav; verified zero residuals across 3 repos and Kamatera teaser",
"Disk audit — Mac2 at 96% full; identified 18.3+ GB safely recoverable (room-settings/ 18GB regen-able + tasks-database.json 298MB stale + Videos/_work + npm/Playwright caches); 7-step prune playbook saved to memory at project_mac2_disk_prune_targets.md, ready when Steve says 'prune disk'",
]},
{ date: "2026-05-06", title: "Overnight loop — public surface polish", items: [
"Admin POST /api/admin/cache/prime + ● Prime cache button on /admin/connectors (re-fires preset cache when Mac1 evicts qwen3)",
"Sequential preset cache prime on boot — 4/5 fills (was 2/3 with parallel) · presets serve in 20ms",
"/changelog page (this one) · /feed RSS · auto-discovery <link rel=alternate>",
"Branded 404 (HTML for browsers, JSON for /api/*)",
"Favicon — gold connector-graph SVG · served at .ico/.svg/apple-touch-icon paths",
"/healthz?format=json exposes uptime, mem, demo cache size, LLM endpoints",
"Allowlist env loader extended for OLLAMA_FALLBACK_URL + CC_KEY_ID",
"robots.txt cleanup — removed dead duplicate handler",
"/docs page · 5 curl examples for public + auth API surface",
"/faq dedicated page with FAQPage JSON-LD for Google rich-result eligibility",
"/privacy /terms /about — required SaaS pages",
"<noscript> graceful fallback · theme-color #0e0e10 · <link rel=prefetch href=/connectors>",
"esc() XSS sweep on chat.html + brand.html — all 5 user-facing HTML pages now hardened",
"Initial git commit (61cfcb1) · 88 files · secrets clean · gitignore covers all data/*.json",
]},
{ date: "2026-05-05 (evening)", title: "Public homepage shipped", items: [
"/ now serves a real public homepage (was login redirect)",
"Live-demo widget — type a command, watch the LLM route it to one of 67 connectors",
"10-min LRU response cache + parallel preset-prime on boot + 25-min keep-alive ping",
"/connectors directory — 1 hub + 67 indexable per-connector pages",
"ItemList JSON-LD on /connectors · sitemap grew from 3 to 64 URLs",
"Live route trail · rotating taglines · tile float anim · animated thinking state",
"?demo=<query> deep-link from connector pages auto-fires the demo",
'"How it works" 3-step · 8-question FAQ section · footer cross-links',
]},
{ date: "2026-05-05 (afternoon)", title: "Security floor", items: [
"AES-256-GCM at-rest encryption with versioned key_id envelope (rotation supported)",
"Atomic write + fsync(2) in save() — no partial writes, no power-loss corruption",
"Loud-fail load() — corrupt JSON → move file aside + exit · never return defaults",
"chmod 0700 data/ · 0600 *.json · plaintext never persists",
"CSP · HSTS preload · X-Frame DENY · X-Content-Type-Options nosniff",
"Rate limits (login 5/15min, demo 8/min)",
"esc() XSS sweep across all 5 user-facing HTML pages",
"Type-coerce req.session.sub to string everywhere (mixed-id partition fix)",
]},
{ date: "2026-05-05 (morning)", title: "Foundation", items: [
"Express + JSON store · per-user credential vault",
"OAuth2 generic handler for 7 vendors (Google · Slack · Stripe · Notion · HubSpot · Mailchimp · Discord)",
"67 connector catalog · sensitive-action approval queue · audit log",
"Local Ollama routing (qwen3:14b on Mac Studio 1) — zero third-party LLM API",
"Browserbase OAuth-app registration sandbox",
"API.md (~520 lines) · DEPLOY.md · PLAN.md",
]},
];
app.get("/changelog", (req, res) => {
res.type("html").send(_shell({
title: "Changelog — VenturaClaw",
desc: "Daily progress log for VenturaClaw. What shipped, when, and why it matters.",
canonical: `https://${DOMAIN}/changelog`,
body: `
<main class="page page-narrow">
<div class="eyebrow">Changelog · what shipped</div>
<h1>Built in the <em>open.</em></h1>
<p style="font-family:var(--mono);font-size:13px;line-height:1.7;color:var(--ink-soft);margin:0 0 38px">Most recent first. Kept honest because Steve writes it as the work happens, not after.</p>
${CHANGELOG.map(entry => `
<section style="margin-bottom:42px;border-left:2px solid var(--gold);padding-left:22px">
<div style="font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--gold);margin-bottom:6px">${esc(entry.date)}</div>
<h2 style="font-family:var(--serif);font-size:22px;margin:0 0 14px;letter-spacing:-.005em">${esc(entry.title)}</h2>
<ul style="font-family:var(--mono);font-size:12px;line-height:1.8;color:var(--ink-soft);padding-left:18px;margin:0">
${entry.items.map(it => `<li style="margin-bottom:4px">${esc(it)}</li>`).join("")}
</ul>
</section>`).join("")}
<p style="margin-top:48px;font-family:var(--mono);font-size:11px;color:var(--ink-mute)">
Subscribe via <a href="/feed" style="color:var(--gold)">RSS</a> · or email <a href="mailto:${esc(SUPPORT_EMAIL)}" style="color:var(--gold)">${esc(SUPPORT_EMAIL)}</a> for direct notifications.
</p>
</main>`
}));
});
// Minimal RSS feed — entries map 1:1 to changelog. Good for IndieWeb / readers.
app.get("/feed", (req, res) => {
const items = CHANGELOG.map(e => `
<item>
<title>${esc(e.title)}</title>
<link>https://${DOMAIN}/changelog</link>
<pubDate>${new Date(e.date.replace(/\s.*/, '') + 'T00:00:00Z').toUTCString()}</pubDate>
<guid isPermaLink="false">cc-${e.date.replace(/\s.*/, '')}-${e.title.toLowerCase().replace(/[^a-z0-9]+/g,'-')}</guid>
<description><![CDATA[${e.items.map(it => '• ' + it).join('<br/>')}]]></description>
</item>`).join("");
res.type("application/rss+xml").send(`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>VenturaClaw · changelog</title>
<link>https://${DOMAIN}/changelog</link>
<description>What shipped on VenturaClaw, in reverse chronological order.</description>
<language>en-us</language>
${items}
</channel></rss>`);
});
app.get("/pricing", (req, res) => {
res.type("html").send(_shell({
title: "Pricing — VenturaClaw",
desc: "Two products, two price models. The chat orchestrator is free during early access (eventual flat workspace fee). The services hub is per-service flat fees from $0 to $99. No metering, no credit math, no surprise bills.",
canonical: `https://${DOMAIN}/pricing`,
body: `
<main class="page page-narrow">
<div class="eyebrow">Pricing · honest answer</div>
<h1>Two products. <em>Two prices.</em><br>Neither punishes you for being successful.</h1>
<p style="font-family:var(--mono);font-size:13px;line-height:1.7;color:var(--ink-soft);margin:0 0 36px;max-width:64ch">No per-task metering. No credit math. No "you used your monthly quota" emails. The <a href="/" style="color:var(--gold)">chat orchestrator</a> is one product; the <a href="/services" style="color:var(--gold)">services hub</a> is another. Each has its own honest price.</p>
<h2 style="font-family:var(--serif);font-size:26px;margin:48px 0 8px">01 · The <em>chat orchestrator</em></h2>
<p style="font-family:var(--mono);font-size:12px;line-height:1.7;color:var(--ink-mute);margin:0 0 22px">Type one sentence; we route it to the right SaaS connector and run it. Free during early access; eventual flat workspace fee.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px;margin-bottom:42px">
<div style="background:var(--bg-elevated);border:1px solid var(--gold);border-left:3px solid var(--gold);border-radius:4px;padding:24px 26px">
<div style="font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--gold);margin-bottom:8px">Today · Early access</div>
<div style="font-family:var(--serif);font-size:42px;font-style:italic;color:var(--ink);line-height:1;margin-bottom:14px">Free</div>
<ul style="font-family:var(--mono);font-size:11px;line-height:1.85;color:var(--ink-soft);padding-left:18px;margin:0"><li>67 connectors pre-wired</li><li>Local LLM routing (qwen3:14b on Mac1)</li><li>Sensitive-action approval queue · free tier</li><li>AES-256-GCM at-rest token vault</li><li>Per-event audit log · exportable</li><li>Email support — <a href="mailto:${esc(SUPPORT_EMAIL)}" style="color:var(--gold)">${esc(SUPPORT_EMAIL)}</a></li></ul>
</div>
<div style="background:var(--bg-elevated);border:1px solid var(--rule);border-radius:4px;padding:24px 26px;opacity:0.78">
<div style="font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:8px">Eventual · TBD</div>
<div style="font-family:var(--serif);font-size:42px;font-style:italic;color:var(--ink-soft);line-height:1;margin-bottom:14px">Flat <span style="font-size:16px;color:var(--ink-mute)">/ workspace</span></div>
<ul style="font-family:var(--mono);font-size:11px;line-height:1.85;color:var(--ink-soft);padding-left:18px;margin:0"><li>No per-task fees · no Zap quotas</li><li>No credit metering on AI routing</li><li>Predictable bill regardless of volume</li><li>30-day notice before any pricing change</li><li>Audit log + connector registrations exportable on cancel</li></ul>
</div>
</div>
<h2 style="font-family:var(--serif);font-size:26px;margin:48px 0 8px">02 · The <em>services hub</em></h2>
<p style="font-family:var(--mono);font-size:12px;line-height:1.7;color:var(--ink-mute);margin:0 0 22px">18 small-business services that automate via real partner APIs (Stripe, Cloudflare, Embroker, Track1099, etc.). Pay per service — flat fee, no subscription. Bring-your-own-token; we orchestrate.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-bottom:32px">
<div style="background:var(--bg-elevated);border:1px solid var(--rule);border-left:3px solid var(--good);padding:20px 22px"><div style="font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--good);margin-bottom:6px">Free · DIY walkthrough</div><div style="font-family:var(--serif);font-size:32px;font-style:italic;color:var(--ink);margin-bottom:10px">$0</div><div style="font-family:var(--mono);font-size:11px;color:var(--ink-soft);line-height:1.6">Chat answers, free templates (operating agreement, ToS, contracts), gov't form walkthroughs (EIN, BOI), partner referrals (banks, CPAs).</div></div>
<div style="background:var(--bg-elevated);border:1px solid var(--gold);border-left:3px solid var(--gold);padding:20px 22px"><div style="font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--gold);margin-bottom:6px">Quick · We orchestrate</div><div style="font-family:var(--serif);font-size:32px;font-style:italic;color:var(--gold);margin-bottom:10px">$29–$59</div><div style="font-family:var(--mono);font-size:11px;color:var(--ink-soft);line-height:1.6">Contracts ($29), 1099-NEC ($9/form), accounting setup ($59), document customization ($39). One-shot, no subscription.</div></div>
<div style="background:var(--bg-elevated);border:1px solid var(--gold);border-left:3px solid var(--gold);padding:20px 22px"><div style="font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--gold);margin-bottom:6px">Full · End-to-end</div><div style="font-family:var(--serif);font-size:32px;font-style:italic;color:var(--gold);margin-bottom:10px">$79–$99</div><div style="font-family:var(--mono);font-size:11px;color:var(--ink-soft);line-height:1.6">Domain + email setup ($39), payroll setup ($79), Stripe Connect setup ($49). Multi-API orchestration, ~60s end-to-end.</div></div>
</div>
<p style="font-family:var(--mono);font-size:11px;color:var(--ink-mute);margin:0 0 8px">Government / partner fees (state filing, USPTO, IRS, registrar) are separate and paid directly to the agency or vendor. We never mark those up.</p>
<p style="font-family:var(--mono);font-size:11px;color:var(--ink-mute);margin:0 0 36px">Insurance routing, banking referrals, CPA referrals: <strong style="color:var(--gold)">always free</strong>. We don't take undisclosed kickbacks; if a referral fee exists we name it on the service card.</p>
<h2 style="font-family:var(--serif);font-size:24px;margin:48px 0 14px">Why not per-task?</h2>
<p style="font-family:var(--mono);font-size:12px;line-height:1.8;color:var(--ink-soft);margin:0 0 20px">Zapier charges per task. Make charges per operation. Bardeen meters credits at 3 per row. The math punishes you for being successful — ops cost scales linearly with traffic, even though the marginal cost of routing a command is essentially electricity.</p>
<p style="font-family:var(--mono);font-size:12px;line-height:1.8;color:var(--ink-soft);margin:0 0 20px">We run inference on local hardware. The marginal cost of routing your 10,000th command this month is the same as your first. Pricing should reflect that — flat workspace fee on the chat, flat per-service fee on the hub. Either way, predictable.</p>
<p style="margin-top:42px;font-family:var(--mono);font-size:11px;color:var(--ink-mute)">Questions? Email <a href="mailto:${esc(SUPPORT_EMAIL)}" style="color:var(--gold)">${esc(SUPPORT_EMAIL)}</a> · or browse the <a href="/services" style="color:var(--gold)">services hub</a> · or read <a href="/how-it-works" style="color:var(--gold)">how it works</a>.</p>
</main>`
}));
});
app.get("/about", (req, res) => {
res.type("html").send(_renderTextPage("About", "About this project", `Built by <em>Steve.</em>`, [
`VenturaClaw is built and operated by Steve Abrams. It exists because every workflow-builder I tried — Zapier, Make, n8n — felt like more work than the work it was supposed to replace. Drag, wire, debug, maintain. The actual job (refund this, post that) is one sentence; the canvas was a tax.`,
`So this tool sells the outcome, not the canvas. You type what you want; the local LLM picks the connector and runs it. 67 connectors are pre-wired and the <a href="/services" style="color:var(--gold)">/services hub</a> ships 18 small-business orchestrations on top (domain + email, Stripe + accounting, insurance routing, contracts, etc.). Sensitive actions are gated by approval. Tokens are encrypted at rest with a versioned key envelope. Your business commands are routed through a self-hosted model — not a third-party API.`,
`If you want to talk shop, reach me at <a href="mailto:${esc(SUPPORT_EMAIL)}" style="color:var(--gold)">${esc(SUPPORT_EMAIL)}</a>.`,
]));
});
app.get("/connectors/:id", (req, res) => {
const c = _connectorsBy[req.params.id];
if (!c) return res.status(404).type("html").send(_shell({
title: "Connector not found — VenturaClaw",
desc: "That connector slug isn't in the catalog. Browse all 56 at /connectors.",
canonical: `https://${DOMAIN}/connectors`,
body: `<main class="page"><h1>Not <em>found.</em></h1><p class="lede">That connector isn't in the catalog. <a href="/connectors" style="color:var(--gold)">Browse all 56 →</a></p></main>`
}));
res.type("html").send(_renderConnectorPage(c));
});
// connector health — uses caller's creds (falls back to env)
app.get("/api/connectors/:id/health", requireAuth, async (req, res) => {
const fresh = await getFreshUserCred(req.session.sub, req.params.id);
const h = await REAL_CONNECTORS.health(req.params.id, fresh);
res.json(h);
});
// bulk health snapshot — kills the 56-parallel-probe N+1 from /admin/connectors
// 30s in-memory TTL, keyed by user id; returns { id: { ok, reason, ... }, ... }
const _healthSnapshot = new Map(); // userId -> { at, data }
app.get("/api/connectors/health-all", requireAuth, async (req, res) => {
const userId = String(req.session.sub);
const cached = _healthSnapshot.get(userId);
if (cached && Date.now() - cached.at < 30_000) return res.json({ data: cached.data, cached: true });
const my = userCreds(userId);
const realIds = CONNECTORS.filter(c => REAL_CONNECTORS.get && REAL_CONNECTORS.get(c.id)).map(c => c.id);
const out = {};
await Promise.all(realIds.map(async id => {
try { out[id] = await REAL_CONNECTORS.health(id, await getFreshUserCred(req.session.sub, id)); }
catch (e) { out[id] = { ok: false, reason: "probe failed" }; }
}));
_healthSnapshot.set(userId, { at: Date.now(), data: out });
res.json({ data: out, cached: false });
});
// direct connector execute (admin only — testing). Phase 8: gate write actions through executeGated.
// Reads execute immediately + audit-logged. Writes return 409 unless body has { force: true }.
// `force: true` is an admin-only escape hatch and is logged as connector_exec_forced.
app.post("/api/connectors/:id/run", requireAdmin, async (req, res) => {
const { action, input, force } = req.body || {};
const my = userCreds(req.session.sub);
const writes = REAL_CONNECTORS.isWrite(req.params.id, action);
try {
const fresh = await getFreshUserCred(req.session.sub, req.params.id);
const result = await REAL_CONNECTORS.executeGated(req.params.id, action, input, fresh, { force: !!force });
logEvent({
kind: force && writes ? "connector_exec_forced" : "connector_exec",
id: req.params.id, action, by: req.session.email, writes, ok: true
});
res.json({ ok: true, result, writes, forced: !!(force && writes) });
} catch (e) {
if (e.code === "approval_required") {
logEvent({ kind: "connector_exec_blocked", id: req.params.id, action, by: req.session.email, reason: "approval_required" });
return res.status(409).json({
ok: false, code: "approval_required",
error: e.message,
hint: `${req.params.id}.${action} is a write action — POST to /api/approvals to queue, or resubmit with { force: true } to override (audit-logged).`
});
}
logEvent({ kind:"connector_exec", id: req.params.id, action, by: req.session.email, writes, ok: false, error: e.message });
res.status(500).json({ ok: false, error: e.message });
}
});
// per-user connections — list field schemas + which are filled
app.get("/api/me/connections", requireAuth, (req, res) => {
const my = userCreds(req.session.sub);
const all = REAL_CONNECTORS.listAll();
const acq = loadAcquisition();
const data = all.map(c => {
const m = acq[c.id];
return {
id: c.id, name: c.name, category: c.category, docsUrl: c.docsUrl, fields: c.fields,
filled: !!my[c.id], filled_fields: maskFields(my[c.id]),
// 2-click metadata: where to mint the key, 1-line how-to, oauth-readiness
get_key_url: m?.get_key_url || null,
get_key_steps: m?.get_key_steps || null,
oauth_supported: m?.oauth === true,
};
});
res.json({ connections: data });
});
// ── Anthropic / MCP discovery ─────────────────────────────────────────────
// /api/anthropic/me — is the user signed in to Anthropic (has API key on file)?
// /api/anthropic/discover-mcps — given Anthropic creds + (locally) ~/.claude.json access via bc-relay,
// return list of {mcp_name, bc_connector, has_token} for every MCP server the user already has wired.
const BC_CONNECTOR_BY_MCP = {
// map MCP server slug → BC connector slug (when name differs)
george: "gmail", // Steve's gmail proxy MCP
"domain-suite": null, // domain MCP doesn't map to a single BC connector
"chrome-devtools": null,
magic: null, paper: null, exa: null,
// identity-named: figma→figma, canva→canva, etsy→etsy, slack→slack, stripe→stripe, etc.
};
function mapMcpToConnector(mcpName) {
if (BC_CONNECTOR_BY_MCP[mcpName] !== undefined) return BC_CONNECTOR_BY_MCP[mcpName];
// Default: same name (e.g. slack→slack, notion→notion)
const realIds = new Set(REAL_CONNECTORS.listAll().map(c => c.id));
return realIds.has(mcpName) ? mcpName : null;
}
app.get("/api/anthropic/me", requireAuth, (req, res) => {
const my = userCreds(req.session.sub);
const a = my["anthropic"];
res.json({
connected: !!a,
has_api_key: !!a?.ANTHROPIC_API_KEY,
last4: a?.ANTHROPIC_API_KEY ? String(a.ANTHROPIC_API_KEY).slice(-4) : null,
});
});
// Discovery: returns the MCP servers the user has configured (server-side perspective).
// On Steve's Mac (loopback) this hits the local bc-relay at 127.0.0.1:9790 which can read ~/.claude.json.
// For non-loopback users, accepts a pasted JSON config in body { mcp_config: {...} }.
app.post("/api/anthropic/discover-mcps", requireAuth, async (req, res) => {
let mcpServers = null;
// Path A: pasted MCP config (any user)
if (req.body?.mcp_config) {
try {
const j = typeof req.body.mcp_config === "string" ? JSON.parse(req.body.mcp_config) : req.body.mcp_config;
mcpServers = j.mcpServers || j; // accept either shape
} catch (e) { return res.status(400).json({ error: "invalid mcp_config json" }); }
}
// Path B: loopback to bc-relay (Steve's Mac only; relay reads ~/.claude.json)
if (!mcpServers && (req.ip === "127.0.0.1" || req.ip === "::1" || req.ip === "::ffff:127.0.0.1")) {
try {
const r = await fetch("http://127.0.0.1:9790/api/mcp-config", { signal: AbortSignal.timeout(2000) });
if (r.ok) { const j = await r.json(); mcpServers = j.mcpServers; }
} catch {}
}
if (!mcpServers) return res.status(400).json({ error: "no_mcp_config", hint: "Paste your ~/.claude.json contents (or its mcpServers block) in body.mcp_config, or run from a loopback session with bc-relay running on :9790." });
const realIds = new Set(REAL_CONNECTORS.listAll().map(c => c.id));
const my = userCreds(req.session.sub);
const out = [];
for (const [mcpName, def] of Object.entries(mcpServers)) {
const bc = mapMcpToConnector(mcpName);
out.push({
mcp_name: mcpName,
bc_connector: bc,
has_real_impl: bc ? realIds.has(bc) : false,
has_token_in_bc: bc ? !!my[bc] : false,
env_keys: Object.keys(def.env || {}),
});
}
logEvent({ kind: "mcp_discovery", userId: req.session.sub, found: out.length, mapped: out.filter(o => o.bc_connector).length });
res.json({
discovered: out.length,
mapped: out.filter(o => o.bc_connector).length,
already_in_bc: out.filter(o => o.has_token_in_bc).length,
mcps: out
});
});
// Auto-import: pull MCP env + secrets-manager values from bc-relay (loopback only) and route to user creds.
// Same routing map the launchd sync agent uses; this is the manual one-click trigger.
app.post("/api/anthropic/auto-import", requireAuth, async (req, res) => {
if (!(req.ip === "127.0.0.1" || req.ip === "::1" || req.ip === "::ffff:127.0.0.1")) {
return res.status(403).json({ error: "loopback_only", hint: "auto-import only runs from the same machine where the bc-relay is exposed (your Mac)." });
}
let bag;
try {
const r = await fetch("http://127.0.0.1:9790/api/mcp-tokens", { signal: AbortSignal.timeout(2500) });
if (!r.ok) return res.status(502).json({ error: "bc_relay_unreachable" });
bag = await r.json();
} catch (e) { return res.status(502).json({ error: "bc_relay_error", detail: e.message }); }
const env = bag.env || {}, mcp = bag.mcp || {};
const pick = (...keys) => { for (const k of keys) { if (env[k]) return env[k]; for (const m of Object.values(mcp)) if (m[k]) return m[k]; } };
const ROUTES = {
stripe: { STRIPE_SECRET_KEY: pick("STRIPE_SECRET_KEY") },
cloudflare: { CF_API_TOKEN: pick("CLOUDFLARE_API_TOKEN", "CF_API_TOKEN") },
figma: { FIGMA_TOKEN: pick("FIGMA_TOKEN", "FIGMA_API_TOKEN") },
canva: { CANVA_TOKEN: pick("CANVA_TOKEN", "CANVA_API_TOKEN") },
etsy: { ETSY_KEYSTRING: pick("ETSY_KEYSTRING", "ETSY_API_KEY"), ETSY_ACCESS_TOKEN: pick("ETSY_ACCESS_TOKEN") },
gmail: { GEORGE_URL: (mcp.george && mcp.george.GEORGE_URL) || "http://127.0.0.1:9850",
GEORGE_BASIC_AUTH: (mcp.george && mcp.george.GEORGE_BASIC_AUTH) || pick("GEORGE_BASIC_AUTH") },
purelymail: { PURELYMAIL_API_TOKEN: pick("PURELYMAIL_API_TOKEN") },
mailchimp: { MAILCHIMP_API_KEY: pick("MAILCHIMP_API_KEY") },
hubspot: { HUBSPOT_TOKEN: pick("HUBSPOT_TOKEN", "HUBSPOT_ACCESS_TOKEN") },
notion: { NOTION_TOKEN: pick("NOTION_TOKEN", "NOTION_API_KEY") },
airtable: { AIRTABLE_PAT: pick("AIRTABLE_PAT", "AIRTABLE_TOKEN"), AIRTABLE_BASE_ID: pick("AIRTABLE_BASE_ID") },
slack: { SLACK_BOT_TOKEN: pick("SLACK_BOT_TOKEN") },
twilio: { TWILIO_ACCOUNT_SID: pick("TWILIO_ACCOUNT_SID"), TWILIO_AUTH_TOKEN: pick("TWILIO_AUTH_TOKEN"), TWILIO_FROM_NUMBER: pick("TWILIO_FROM_NUMBER") },
anthropic: { ANTHROPIC_API_KEY: pick("ANTHROPIC_API_KEY") },
};
const userId = req.session.sub;
if (!credentials[userId]) credentials[userId] = {};
const summary = [];
for (const [vendor, fields] of Object.entries(ROUTES)) {
const clean = Object.fromEntries(Object.entries(fields).filter(([_,v]) => v));
if (!Object.keys(clean).length) { summary.push({ vendor, ok: false, reason: "no_value" }); continue; }
credentials[userId][vendor] = { ...credentials[userId][vendor], ...clean };
summary.push({ vendor, ok: true, fields: Object.keys(clean) });
}
saveCredentials(credentials);
logEvent({ kind: "auto_import", userId, ok: summary.filter(s => s.ok).length, total: summary.length });
res.json({
ok: true,
imported: summary.filter(s => s.ok).length,
skipped: summary.filter(s => !s.ok).length,
summary
});
});
// Approval haiku — qwen3:14b writes 4 lines describing what this approval will do.
// Cached per approval-id so repeated reloads don't burn local LLM cycles.
const _haikuCache = new Map();
app.get("/api/approvals/:id/haiku", requireAuth, async (req, res) => {
const a = approvals.find(x => x.id === req.params.id);
if (!a) return res.status(404).json({ error: "not_found" });
if (_haikuCache.has(a.id)) return res.json({ haiku: _haikuCache.get(a.id), cached: true });
const haiku = await LLM.approvalHaiku(a);
if (haiku) _haikuCache.set(a.id, haiku);
res.json({ haiku, cached: false });
});
// Per-connector actions list (for the [Run skill ▼] dropdown). Returns reads + writes from connectors/index.js.
app.get("/api/connectors/:id/actions", requireAuth, (req, res) => {
const id = req.params.id;
const reads = [...(REAL_CONNECTORS.READ_ACTIONS?.[id] || [])];
const writes = [...(REAL_CONNECTORS.WRITE_ACTIONS?.[id] || [])];
if (!reads.length && !writes.length) return res.status(404).json({ error: "no_real_impl" });
res.json({ id, reads, writes });
});
// 2-click acquisition directory — every connector with deep-link to provider's API-key portal.
// Drives the redesigned /connections page: "Get key ↗" + "Connect" buttons per tile.
let _acquisitionCache = null;
function loadAcquisition() {
if (_acquisitionCache) return _acquisitionCache;
try {
_acquisitionCache = JSON.parse(fs.readFileSync(path.join(DATA, "connector-acquisition.json"), "utf8")).connectors || {};
} catch { _acquisitionCache = {}; }
return _acquisitionCache;
}
app.get("/api/connectors/acquisition", requireAuth, (req, res) => {
const my = userCreds(req.session.sub);
const acq = loadAcquisition();
const realIds = new Set(REAL_CONNECTORS.listAll().map(c => c.id));
const oauthVendors = new Set(Object.keys(OAUTH_PROVIDERS));
const out = CONNECTORS.map(c => {
const meta = acq[c.id] || null;
const isReal = realIds.has(c.id);
const oauthReady = oauthVendors.has(c.id) && !!oauthApps[c.id]?.client_id;
return {
id: c.id, name: c.name, category: c.category, logo: c.logo, tint: c.tint,
docs: c.docs, sensitive: c.sensitive,
real_impl: isReal,
filled: !!my[c.id],
filled_fields: maskFields(my[c.id]),
acquisition: meta, // get_key_url, get_key_steps, fields
oauth_supported: meta?.oauth === true,
oauth_admin_ready: oauthReady,
oauth_start_url: oauthReady ? `/oauth/${c.id}/connect` : null,
};
});
res.json({ connectors: out });
});
// save creds for a connector (per-user)
app.post("/api/me/connections/:id", requireAuth, (req, res, next) => {
const id = req.params.id;
if (id === 'import') return next(); // delegate to the dedicated /import route
const c = REAL_CONNECTORS.get(id);
if (!c) return res.status(404).json({ error: "no real impl for this connector yet" });
const incoming = req.body || {};
const allowedKeys = (c.fields || []).map(f => f.key);
const cleaned = {};
for (const k of allowedKeys) if (incoming[k] && String(incoming[k]).trim()) cleaned[k] = String(incoming[k]).trim();
if (!Object.keys(cleaned).length) return res.status(400).json({ error: "no recognized fields" });
if (!credentials[String(req.session.sub)]) credentials[String(req.session.sub)] = {};
credentials[String(req.session.sub)][id] = { ...credentials[String(req.session.sub)][id], ...cleaned };
saveCredentials(credentials);
logEvent({ kind:"connection_saved", userId: req.session.sub, connector: id, fields: Object.keys(cleaned) });
res.json({ ok: true });
});
// ─── Import: bulk-parse a paste of either ~/.claude.json (mcpServers/permissions),
// a secrets-manager .env file (KEY=value lines), or raw JSON. Maps recognized
// keys to connector credentials and merges into the current user's namespace.
//
// Body: { content: "<paste>", format?: "auto"|"claude-json"|"env"|"json" }
// Each entry: env var name → { connectorId, field }
// IMPORTANT: `field` MUST match the key the connector's handler reads from `creds[...]`.
// (Old version used descriptive names like `api_key`/`access_token` and broke runtime lookups.)
const ENV_KEY_TO_CONNECTOR = {
STRIPE_SECRET_KEY: { connector: 'stripe', field: 'STRIPE_SECRET_KEY' },
STRIPE_PUBLISHABLE_KEY: { connector: 'stripe', field: 'STRIPE_PUBLISHABLE_KEY' },
CLOUDFLARE_API_TOKEN: { connector: 'cloudflare', field: 'CF_API_TOKEN' }, // handler reads CF_API_TOKEN
CLOUDFLARE_ACCOUNT_ID: { connector: 'cloudflare', field: 'CF_ACCOUNT_ID' },
GODADDY_API_KEY: { connector: 'godaddy', field: 'GODADDY_API_KEY' },
GODADDY_API_SECRET: { connector: 'godaddy', field: 'GODADDY_API_SECRET' },
BROWSERBASE_API_KEY: { connector: 'browserbase', field: 'BROWSERBASE_API_KEY' },
BROWSERBASE_PROJECT_ID: { connector: 'browserbase', field: 'BROWSERBASE_PROJECT_ID' },
SHOPIFY_ACCESS_TOKEN: { connector: 'shopify', field: 'SHOPIFY_ADMIN_TOKEN' }, // handler reads SHOPIFY_ADMIN_TOKEN
SHOPIFY_ADMIN_TOKEN: { connector: 'shopify', field: 'SHOPIFY_ADMIN_TOKEN' },
SHOPIFY_STORE: { connector: 'shopify', field: 'SHOPIFY_STORE' },
ETSY_API_KEY: { connector: 'etsy', field: 'ETSY_KEYSTRING' }, // handler reads ETSY_KEYSTRING
ETSY_KEYSTRING: { connector: 'etsy', field: 'ETSY_KEYSTRING' },
ETSY_ACCESS_TOKEN: { connector: 'etsy', field: 'ETSY_ACCESS_TOKEN' },
PAYPAL_CLIENT_ID: { connector: 'paypal', field: 'PAYPAL_CLIENT_ID' },
PAYPAL_SECRET: { connector: 'paypal', field: 'PAYPAL_SECRET' },
SQUARE_ACCESS_TOKEN: { connector: 'square', field: 'SQUARE_ACCESS_TOKEN' },
TWILIO_ACCOUNT_SID: { connector: 'twilio', field: 'TWILIO_ACCOUNT_SID' },
TWILIO_AUTH_TOKEN: { connector: 'twilio', field: 'TWILIO_AUTH_TOKEN' },
SLACK_BOT_TOKEN: { connector: 'slack', field: 'SLACK_BOT_TOKEN' },
SLACK_TOKEN: { connector: 'slack', field: 'SLACK_BOT_TOKEN' },
DISCORD_BOT_TOKEN: { connector: 'discord', field: 'DISCORD_BOT_TOKEN' },
MAILCHIMP_API_KEY: { connector: 'mailchimp', field: 'MAILCHIMP_API_KEY' },
KLAVIYO_API_KEY: { connector: 'klaviyo', field: 'KLAVIYO_API_KEY' },
HUBSPOT_ACCESS_TOKEN: { connector: 'hubspot', field: 'HUBSPOT_TOKEN' }, // handler reads HUBSPOT_TOKEN
HUBSPOT_TOKEN: { connector: 'hubspot', field: 'HUBSPOT_TOKEN' },
SALESFORCE_ACCESS_TOKEN: { connector: 'salesforce', field: 'SALESFORCE_ACCESS_TOKEN' },
ZENDESK_API_TOKEN: { connector: 'zendesk', field: 'ZENDESK_API_TOKEN' },
INTERCOM_ACCESS_TOKEN: { connector: 'intercom', field: 'INTERCOM_ACCESS_TOKEN' },
NOTION_API_KEY: { connector: 'notion', field: 'NOTION_TOKEN' }, // handler reads NOTION_TOKEN
NOTION_TOKEN: { connector: 'notion', field: 'NOTION_TOKEN' },
AIRTABLE_PAT: { connector: 'airtable', field: 'AIRTABLE_PAT' },
ASANA_PAT: { connector: 'asana', field: 'ASANA_PAT' },
CLICKUP_API_TOKEN: { connector: 'clickup', field: 'CLICKUP_API_TOKEN' },
TRELLO_API_KEY: { connector: 'trello', field: 'TRELLO_API_KEY' },
TRELLO_TOKEN: { connector: 'trello', field: 'TRELLO_TOKEN' },
FIGMA_TOKEN: { connector: 'figma', field: 'FIGMA_TOKEN' },
CANVA_API_KEY: { connector: 'canva', field: 'CANVA_TOKEN' }, // handler reads CANVA_TOKEN
CANVA_TOKEN: { connector: 'canva', field: 'CANVA_TOKEN' },
ADOBE_CLIENT_ID: { connector: 'adobe', field: 'ADOBE_CLIENT_ID' },
AWS_ACCESS_KEY_ID: { connector: 'aws', field: 'AWS_ACCESS_KEY_ID' },
AWS_SECRET_ACCESS_KEY: { connector: 'aws', field: 'AWS_SECRET_ACCESS_KEY' },
VERCEL_TOKEN: { connector: 'vercel', field: 'VERCEL_TOKEN' },
SUPABASE_KEY: { connector: 'supabase', field: 'SUPABASE_KEY' },
ELEVENLABS_API_KEY: { connector: 'elevenlabs', field: 'ELEVENLABS_API_KEY' },
PURELYMAIL_API_TOKEN: { connector: 'purelymail', field: 'PURELYMAIL_API_TOKEN' },
GEORGE_URL: { connector: 'gmail', field: 'GEORGE_URL' },
GEORGE_BASIC_AUTH: { connector: 'gmail', field: 'GEORGE_BASIC_AUTH' },
LINKEDIN_CLIENT_ID: { connector: 'linkedin', field: 'LINKEDIN_CLIENT_ID' },
LINKEDIN_CLIENT_SECRET: { connector: 'linkedin', field: 'LINKEDIN_CLIENT_SECRET' },
// Convenience aliases for the most common variant names:
STRIPE_API_KEY: { connector: 'stripe', field: 'STRIPE_SECRET_KEY' },
CLOUDFLARE_TOKEN: { connector: 'cloudflare', field: 'CF_API_TOKEN' },
SHOPIFY_TOKEN: { connector: 'shopify', field: 'SHOPIFY_ADMIN_TOKEN' },
};
function parseEnvText(text) {
const env = {};
for (const line of text.split(/\r?\n/)) {
const m = line.match(/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
if (!m) continue;
let val = m[2];
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
if (val) env[m[1]] = val;
}
return env;
}
function extractFromClaudeJson(text) {
let parsed;
try { parsed = JSON.parse(text); } catch { return { env: {}, error: 'not valid JSON' }; }
const env = {};
// Top-level mcpServers + nested project mcpServers
const servers = { ...(parsed.mcpServers || {}) };
if (parsed.projects) for (const p of Object.values(parsed.projects)) Object.assign(servers, p.mcpServers || {});
for (const [name, cfg] of Object.entries(servers)) {
if (cfg && typeof cfg === 'object' && cfg.env) Object.assign(env, cfg.env);
}
// Also: top-level "env" block (settings.json shape)
if (parsed.env && typeof parsed.env === 'object') Object.assign(env, parsed.env);
return { env, serverCount: Object.keys(servers).length };
}
app.post("/api/me/connections/import", requireAuth, (req, res) => {
const { content, format = 'auto' } = req.body || {};
if (!content || typeof content !== 'string') return res.status(400).json({ error: 'content required' });
let env = {}, serverCount = 0, detectedFormat;
const trimmed = content.trim();
const looksLikeJson = trimmed.startsWith('{');
const useFormat = format === 'auto' ? (looksLikeJson ? 'claude-json' : 'env') : format;
if (useFormat === 'claude-json' || useFormat === 'json') {
const r = extractFromClaudeJson(content);
if (r.error) return res.status(400).json({ error: r.error });
env = r.env; serverCount = r.serverCount || 0; detectedFormat = 'claude-json';
} else {
env = parseEnvText(content); detectedFormat = 'env';
}
// Map env vars → per-connector credential fields
const userId = String(req.session.sub);
if (!credentials[userId]) credentials[userId] = {};
const populated = {}, unmatched = [];
for (const [k, v] of Object.entries(env)) {
const map = ENV_KEY_TO_CONNECTOR[k];
if (!map) { unmatched.push(k); continue; }
if (!credentials[userId][map.connector]) credentials[userId][map.connector] = {};
credentials[userId][map.connector][map.field] = v;
if (!populated[map.connector]) populated[map.connector] = [];
populated[map.connector].push(map.field);
}
// Stamp provenance (no values)
for (const cid of Object.keys(populated)) {
credentials[userId][cid]._imported_at = new Date().toISOString();
credentials[userId][cid]._import_source = detectedFormat;
}
saveCredentials(credentials);
logEvent({ kind: 'connections_imported', userId, format: detectedFormat, connectors: Object.keys(populated), envKeysSeen: Object.keys(env).length });
res.json({
ok: true, format: detectedFormat,
serverCount, envKeysSeen: Object.keys(env).length,
populated: Object.keys(populated).map(c => ({ connector: c, fields: populated[c] })),
unmatched,
});
});
// ─── OAUTH SETUP (admin) — register vendor app credentials ───
app.get("/admin/oauth-setup", requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "oauth-setup.html")));
// List all OAuth providers + which have registered creds + redirect URIs (admin-only)
app.get("/api/admin/oauth/providers", requireAdmin, (req, res) => {
const list = Object.entries(OAUTH_PROVIDERS).map(([id, p]) => ({
id, ...p,
redirect_uri: oauthRedirectUri(id),
has_credentials: !!(oauthApps[id]?.client_id && oauthApps[id]?.client_secret),
registered_at: oauthApps[id]?.registered_at || null,
}));
res.json({ providers: list, redirect_uri_pattern: `${PUBLIC_BASE}/oauth/<vendor>/callback` });
});
// Save vendor app credentials (Steve pastes client_id + client_secret after registering)
app.post("/api/admin/oauth/:vendor/credentials", requireAdmin, (req, res) => {
const v = req.params.vendor;
if (!OAUTH_PROVIDERS[v]) return res.status(404).json({ error: 'unknown vendor' });
const { client_id, client_secret } = req.body || {};
if (!client_id || !client_secret) return res.status(400).json({ error: 'client_id and client_secret required' });
oauthApps[v] = {
client_id: String(client_id).trim(),
client_secret: String(client_secret).trim(),
registered_at: new Date().toISOString(),
registered_by: req.session.email,
};
save("oauth-apps", oauthApps);
logEvent({ kind: 'oauth_app_registered', vendor: v, by: req.session.email });
res.json({ ok: true, vendor: v });
});
// Delete vendor app credentials
app.delete("/api/admin/oauth/:vendor/credentials", requireAdmin, (req, res) => {
const v = req.params.vendor;
if (typeof v !== "string" || !Object.hasOwn(OAUTH_PROVIDERS, v)) return res.status(404).json({ error: "unknown vendor" });
if (Object.hasOwn(oauthApps, v)) delete oauthApps[v];
save("oauth-apps", oauthApps);
logEvent({ kind: 'oauth_app_removed', vendor: v, by: req.session.email });
res.json({ ok: true });
});
// ─── Browserbase: launch isolated cloud browser per vendor (admin-only) ───
// Creates a Browserbase session pre-aimed at the vendor's register page.
// Steve opens the live-view URL, logs in once in the cloud browser, registers
// the app there. Cleaner than using his local browser (no cookie pollution,
// session expires in 1hr automatically).
const BROWSERBASE_KEY = process.env.BROWSERBASE_API_KEY;
const BROWSERBASE_PROJECT = process.env.BROWSERBASE_PROJECT_ID;
// Release a Browserbase session early (called from beforeunload when admin closes the cloud-browser tab)
app.post("/api/admin/browserbase/release/:sessionId", requireAdmin, async (req, res) => {
if (!BROWSERBASE_KEY) return res.json({ ok: false, error: 'no key' });
try {
await fetch(`https://api.browserbase.com/v1/sessions/${req.params.sessionId}`, {
method: 'POST',
headers: { 'x-bb-api-key': BROWSERBASE_KEY, 'content-type': 'application/json' },
body: JSON.stringify({ status: 'REQUEST_RELEASE', projectId: BROWSERBASE_PROJECT }),
});
logEvent({ kind: 'browserbase_released', session: req.params.sessionId, by: req.session.email });
res.json({ ok: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post("/api/admin/oauth/:vendor/sandbox", requireAdmin, async (req, res) => {
const v = req.params.vendor;
const provider = OAUTH_PROVIDERS[v];
if (!provider) return res.status(404).json({ error: 'unknown vendor' });
if (!BROWSERBASE_KEY) return res.status(500).json({ error: 'BROWSERBASE_API_KEY not in env (add to ~/Projects/secrets-manager and restart)' });
try {
const sr = await fetch('https://api.browserbase.com/v1/sessions', {
method: 'POST',
headers: { 'x-bb-api-key': BROWSERBASE_KEY, 'content-type': 'application/json' },
body: JSON.stringify({
projectId: BROWSERBASE_PROJECT,
keepAlive: false,
timeout: 600, // 10 min idle timeout (default 60 min) — saves ~83% on session-minute billing
}),
});
const session = await sr.json();
if (!session.id) return res.status(500).json({ error: 'browserbase session create failed', detail: session });
// Get live-view URL
const dr = await fetch(`https://api.browserbase.com/v1/sessions/${session.id}/debug`, {
headers: { 'x-bb-api-key': BROWSERBASE_KEY },
});
const debug = await dr.json();
logEvent({ kind: 'browserbase_sandbox_opened', vendor: v, session: session.id, by: req.session.email });
res.json({
ok: true,
session_id: session.id,
live_view: debug.debuggerFullscreenUrl || null,
register_url: provider.register_url,
redirect_uri: oauthRedirectUri(v),
hint: `Step 1: paste ${provider.register_url} into the cloud browser's address bar. Step 2: register the app, set redirect URI, copy Client ID/Secret. Step 3: paste them back here.`,
});
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ─── OAUTH USER FLOW ───
// User clicks "Connect Google" → server signs a state token → redirects to vendor consent
app.get("/oauth/:vendor/connect", requireAuth, (req, res) => {
const v = req.params.vendor;
const provider = OAUTH_PROVIDERS[v];
if (!provider) return res.status(404).send('unknown vendor');
const app = oauthApps[v];
if (!app?.client_id) return res.status(400).send(`<h1>Not configured</h1><p>${provider.name} OAuth app hasn't been registered yet. Ask the admin to register it at <a href="/admin/oauth-setup">/admin/oauth-setup</a>.</p>`);
pruneStaleStates();
const state = newOAuthState();
oauthState[state] = { userId: String(req.session.sub), vendor: v, redirect_after: req.query.redirect_after || '/connections', created_at: Date.now() };
save("oauth-state", oauthState);
const params = new URLSearchParams({
client_id: app.client_id,
redirect_uri: oauthRedirectUri(v),
state,
response_type: 'code',
...(provider.scope ? { scope: provider.scope } : {}),
...(provider.extra_params || {}),
});
res.redirect(`${provider.auth_url}?${params}`);
});
// Vendor returns to /oauth/:vendor/callback?code=...&state=...
app.get("/oauth/:vendor/callback", async (req, res) => {
const v = req.params.vendor;
const provider = OAUTH_PROVIDERS[v];
if (!provider) return res.status(404).send('unknown vendor');
const { code, state, error } = req.query;
if (error) return res.status(400).send(`<h1>OAuth error</h1><pre>${esc(error)}</pre><p><a href="/connections">← back</a></p>`);
if (typeof state !== "string" || !state) return res.status(400).send("invalid state");
const stateRec = Object.hasOwn(oauthState, state) ? oauthState[state] : null;
if (!stateRec || stateRec.vendor !== v) return res.status(400).send('invalid or expired state');
delete oauthState[state]; save("oauth-state", oauthState);
const app = oauthApps[v];
if (!app?.client_id) return res.status(400).send('vendor app missing');
try {
const body = new URLSearchParams({
client_id: app.client_id,
client_secret: app.client_secret,
code,
grant_type: 'authorization_code',
redirect_uri: oauthRedirectUri(v),
});
const r = await fetch(provider.token_url, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
body,
});
const tokenJson = await r.json();
if (!r.ok || !tokenJson.access_token) {
return res.status(500).send(`<h1>Token exchange failed</h1><pre>${esc(JSON.stringify(tokenJson, null, 2))}</pre>`);
}
const userId = String(stateRec.userId);
if (!credentials[userId]) credentials[userId] = {};
credentials[userId][v] = {
access_token: tokenJson.access_token,
refresh_token: tokenJson.refresh_token || null,
token_type: tokenJson.token_type || 'Bearer',
scope: tokenJson.scope || provider.scope,
expires_at: tokenJson.expires_in ? new Date(Date.now() + tokenJson.expires_in * 1000).toISOString() : null,
_connected_at: new Date().toISOString(),
_via: 'oauth',
};
saveCredentials(credentials);
logEvent({ kind: 'oauth_connected', userId, vendor: v });
res.redirect(safeRedirectPath(stateRec.redirect_after));
} catch (e) {
res.status(500).send(`<h1>OAuth exchange error</h1><pre>${esc(e.message)}</pre>`);
}
});
// disconnect
app.delete("/api/me/connections/:id", requireAuth, (req, res) => {
const sub = req.session.sub;
const id = req.params.id;
if (credentials[sub] && Object.hasOwn(credentials[sub], id)) delete credentials[sub][id];
saveCredentials(credentials);
logEvent({ kind:"connection_removed", userId: req.session.sub, connector: req.params.id });
res.json({ ok: true });
});
// chat API
app.post("/api/chat", requireAuth, async (req, res) => {
const { message } = req.body || {};
if (!message) return res.status(400).json({ error: "missing message" });
let out = planChat(message, req.session);
// If regex router missed, escalate to local LLM (Mac1 Ollama qwen3:14b)
if (!out.toolCalls.length) {
try {
const myConns = userCreds(req.session.sub);
const userConnected = new Set(Object.keys(myConns));
const intent = await LLM.classifyIntent(message, { connectors: CONNECTORS, userConnected });
if (intent && intent.connector === "clarify" && intent.question) {
out = { reply: intent.question, toolCalls: [], clarify: { options: intent.options || [] } };
} else if (intent && intent.connector === "unconfigured" && intent.suggested) {
const cName = (CONNECTORS.find(x => x.id === intent.suggested)?.name) || intent.suggested;
out = { reply: `You haven't connected ${cName} yet. Set it up at /connections, then ask me again.`, toolCalls: [], setupLink: `/connections#${intent.suggested}` };
} else if (intent && intent.connector === "none") {
// Leave default planChat reply in place; nothing to route
} else if (intent && intent.connector && intent.connector !== "mock" && REAL_CONNECTORS.get(intent.connector)) {
const c = CONNECTORS.find(x => x.id === intent.connector);
const sensitive = c?.sensitive !== false;
out = {
reply: `Local LLM routed → ${intent.connector}.${intent.action}${sensitive ? " (queued for approval)" : " (executing)"}`,
toolCalls: [{ connector: intent.connector, name: c?.name || intent.connector, action: intent.action, queued: sensitive, input: intent.input || {} }]
};
}
} catch (e) { /* fall through to default reply */ }
}
// queue 'queued' calls as approvals (with parsed input)
// execute non-sensitive (queued:false) calls immediately if real impl exists
for (const c of out.toolCalls) {
if (c.queued) {
const a = { id: crypto.randomUUID(), userId: req.session.sub, userEmail: req.session.email,
connector: c.connector, connectorName: c.name, action: c.action, input: c.input || null,
message: message.slice(0,200), status: "pending", createdAt: new Date().toISOString() };
approvals.unshift(a);
} else if (REAL_CONNECTORS.get(c.connector)) {
// Phase 8 — even when chat router said queued:false, the connector-layer gate has the final say.
// If gate says this is a write, transparently re-route to the approval queue rather than executing.
try {
const fresh = await getFreshUserCred(req.session.sub, c.connector);
c.executionResult = await REAL_CONNECTORS.executeGated(c.connector, c.action, c.input || {}, fresh);
c.executed = true;
logEvent({ kind:"chat_exec", userId: req.session.sub, connector: c.connector, action: c.action, writes: false, ok: true });
} catch (e) {
if (e.code === "approval_required") {
// Mismatched router: chat said immediate, but the gate caught a write. Queue it.
const a = { id: crypto.randomUUID(), userId: req.session.sub, userEmail: req.session.email,
connector: c.connector, connectorName: c.name, action: c.action, input: c.input || null,
message: message.slice(0,200), status: "pending", createdAt: new Date().toISOString(),
queuedBy: "gate_intercept" };
approvals.unshift(a);
c.queued = true; c.executed = false;
logEvent({ kind:"chat_exec_queued", userId: req.session.sub, connector: c.connector, action: c.action, reason: "gate_intercept" });
} else {
c.executionError = e.message; c.executed = false;
logEvent({ kind:"chat_exec", userId: req.session.sub, connector: c.connector, action: c.action, writes: false, ok: false, error: e.message });
}
}
}
}
if (out.toolCalls.some(c => c.queued)) save("approvals", approvals);
// Local-LLM summary of tool results (qwen3:14b on Mac1). Falls through silently if Ollama offline.
if (out.toolCalls?.length) {
const summary = await LLM.summarizeResult(message, out.toolCalls);
if (summary) out.reply = summary;
}
// Persist to per-user chat history
pushChat(req.session.sub, { role: "user", text: message });
pushChat(req.session.sub, { role: "assistant", text: out.reply, toolCalls: out.toolCalls });
save("chat-history", chatHistory);
logEvent({ kind:"chat", userId: req.session.sub, message: message.slice(0,140), toolCalls: out.toolCalls.length });
res.json(out);
});
// Chat history per user — read for restore-on-mount, delete to clear conversation.
app.get("/api/chat/history", requireAuth, (req, res) => {
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 200);
const list = chatHistory[req.session.sub] || [];
res.json({ messages: list.slice(-limit), total: list.length });
});
// Frequent commands: top-N of this user's actual chat phrases (case-insensitive, normalized).
app.get("/api/chat/frequent", requireAuth, (req, res) => {
const list = chatHistory[req.session.sub] || [];
const counts = {};
for (const m of list) {
if (m.role !== "user") continue;
const norm = (m.text || "").trim().toLowerCase();
if (norm.length < 6 || norm.length > 200) continue;
counts[norm] = (counts[norm] || 0) + 1;
}
const top = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 5)
.map(([text, count]) => ({ text, count }));
res.json({ top });
});
app.delete("/api/chat/history", requireAuth, (req, res) => {
delete chatHistory[req.session.sub];
save("chat-history", chatHistory);
logEvent({ kind: "chat_history_cleared", userId: req.session.sub });
res.json({ ok: true });
});
// approvals API
app.get("/api/approvals", requireAuth, (req, res) => {
const list = req.session.role === "admin" ? approvals : approvals.filter(a => a.userId === req.session.sub);
res.json({ approvals: list });
});
app.post("/api/approvals/:id/decide", requireAdmin, async (req, res) => {
const { decision, input, comms_compliance_override } = req.body || {};
const a = approvals.find(x => x.id === req.params.id);
if (!a) return res.status(404).json({ error: "not found" });
if (a.status !== "pending") return res.status(409).json({ error: "already decided" });
// Comms-compliance gate (CAN-SPAM / TCPA / DNC). Fail-closed unless override flag set.
const actionId = a.action.startsWith(a.connector + ".") ? a.action : `${a.connector}.${a.action}`;
if (decision === "approve" && COMMS.isCommsAction(actionId)) {
const effectiveInput = input || a.input || {};
const scrub = COMMS.scrub(actionId, effectiveInput);
if (!scrub.ok && !comms_compliance_override) {
logEvent({ kind: "comms_compliance_blocked", id: a.id, action: actionId, by: req.session.email, violations: scrub.violations });
return res.status(422).json({
error: "comms_compliance_violations", kind: scrub.kind,
violations: scrub.violations, message: scrub.message,
hint: "fix the violations OR resubmit with comms_compliance_override:true (audit-logged)"
});
}
if (!scrub.ok && comms_compliance_override) {
logEvent({ kind: "comms_compliance_override", id: a.id, action: actionId, by: req.session.email, violations: scrub.violations });
a.commsComplianceOverride = { violations: scrub.violations, by: req.session.email, ts: new Date().toISOString() };
} else {
logEvent({ kind: "comms_compliance_passed", id: a.id, action: actionId, by: req.session.email });
a.commsCompliancePassed = true;
}
}
a.status = decision === "approve" ? "approved" : "rejected";
a.decidedBy = req.session.email; a.decidedAt = new Date().toISOString();
// On approve, attempt real-impl execution if available. Phase 8: pass fromApproval:true so the gate allows the write.
if (a.status === "approved" && REAL_CONNECTORS.get(a.connector)) {
try {
const fresh = await getFreshUserCred(a.userId, a.connector);
a.executionResult = await REAL_CONNECTORS.executeGated(
a.connector, a.action, input || a.input || {},
fresh,
{ fromApproval: true }
);
a.executedAt = new Date().toISOString();
logEvent({ kind:"approval_executed", id: a.id, connector: a.connector, action: a.action, by: req.session.email, ok: true });
} catch (e) {
a.executionError = e.message;
logEvent({ kind:"approval_executed", id: a.id, connector: a.connector, action: a.action, by: req.session.email, ok: false, error: e.message });
}
}
save("approvals", approvals);
logEvent({ kind:"approval_decision", id: a.id, decision: a.status, by: req.session.email, connector: a.connector, action: a.action });
res.json({ ok: true, approval: a });
});
// Comms-compliance — admin reads/edits suppression list, anyone can preview a scrub
app.get("/api/comms/suppression", requireAdmin, (req, res) => res.json({ entries: COMMS.listSuppression() }));
app.post("/api/comms/suppression", requireAdmin, (req, res) => {
const { kind, value, reason } = req.body || {};
if (!kind || !value) return res.status(400).json({ error: "kind and value required" });
try {
const out = COMMS.suppress(kind, value, reason);
logEvent({ kind: "comms_suppression_added", target_kind: kind, by: req.session.email });
res.json({ ok: true, ...out });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post("/api/comms/scrub-preview", requireAuth, (req, res) => {
const { action, input } = req.body || {};
if (!action) return res.status(400).json({ error: "action required" });
res.json(COMMS.scrub(action, input || {}));
});
// admin user CRUD
app.get("/api/admin/users", requireAdmin, (req, res) => {
res.json({ users: users.map(({password, ...u}) => u) });
});
app.post("/api/admin/users", requireAdmin, (req, res) => {
const { email, password, name, role } = req.body || {};
if (!email || !password) return res.status(400).json({ error: "missing email/password" });
if (users.some(u => u.email.toLowerCase() === email.toLowerCase())) return res.status(409).json({ error: "email exists" });
// Hash password inline — never store plaintext at rest. Migration is a fallback for legacy rows.
const hashed = bcrypt.hashSync(password, 10);
const u = { id: Date.now(), email, password: hashed, name: name||email, role: role==="admin"?"admin":"user" };
users.push(u); save("users", users);
logEvent({ kind:"user_created", by: req.session.email, target: u.email, role: u.role });
res.json({ ok: true, user: { ...u, password: undefined }});
});
app.delete("/api/admin/users/:id", requireAdmin, (req, res) => {
const id = parseInt(req.params.id, 10);
const before = users.length;
users = users.filter(u => u.id !== id);
save("users", users);
logEvent({ kind:"user_deleted", by: req.session.email, id });
res.json({ ok: true, removed: before - users.length });
});
// Password rotation — admin must be authenticated; can rotate own or others.
// Body: { password: "new-strong-value" } (min 12 chars).
// Stored as bcrypt hash; old hash is irrecoverable. Audit-logged.
app.put("/api/admin/users/:id/password", requireAdmin, (req, res) => {
const id = String(req.params.id);
const u = users.find(x => String(x.id) === id);
if (!u) return res.status(404).json({ error: "user_not_found" });
const pw = (req.body && typeof req.body.password === "string") ? req.body.password : "";
if (pw.length < 12) return res.status(400).json({ error: "password_too_short", min: 12 });
u.password = bcrypt.hashSync(pw, 10);
save("users", users);
logEvent({ kind: "password_rotated", by: req.session.email, target: u.email });
res.json({ ok: true });
});
// (duplicate /api/audit removed — earlier route at line ~346 with limit + total is canonical)
// Branded 404 — catches anything no other handler matched. Must come last,
// after all routes + static. Renders the same _shell template as /privacy /terms etc.
app.use((req, res) => {
// API requests get JSON; HTML for browsers
if (req.path.startsWith("/api/") || req.headers.accept?.includes("application/json")) {
return res.status(404).json({ error: "not_found", path: req.path });
}
res.status(404).type("html").send(_shell({
title: "Not found — VenturaClaw",
desc: "That page doesn't exist on VenturaClaw.",
canonical: `https://${DOMAIN}/`,
body: `
<main class="page page-narrow" style="text-align:center;padding-top:120px">
<div class="eyebrow">404 · path not found</div>
<h1 style="font-size:clamp(48px,7vw,88px)">Lost in <em>routing.</em></h1>
<p class="lede" style="margin:0 auto 36px">The agent couldn't find <code style="font-family:var(--mono);font-size:13px;color:var(--gold);background:rgba(0,0,0,0.32);padding:3px 10px;border-radius:2px">${esc(req.path).slice(0,80)}</code>. Either it never existed, or it moved while you were typing.</p>
<div style="display:flex;gap:14px;justify-content:center;flex-wrap:wrap">
<a href="/" style="font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;padding:14px 28px;background:var(--gold);color:var(--bg);border-radius:2px;text-decoration:none">← Home</a>
<a href="/connectors" style="font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;padding:14px 28px;background:transparent;color:var(--gold);border:1px solid var(--gold);border-radius:2px;text-decoration:none">All connectors →</a>
</div>
</main>`
}));
});
app.listen(PORT, HOST, () => {
console.log(`[ventura-claw] listening on http://${HOST}:${PORT} (public: ${DOMAIN})`);
// Pre-warm the LLM (Mac1 + Mac2 fallback if configured) so the first visitor
// doesn't pay a cold-load. Then prime the demo cache by classifying the 5 preset
// queries — same warm window, so all 5 finish in seconds and are cached for 10min.
(async () => {
await LLM.prewarm();
const presets = [
"post in #launch on slack saying we shipped",
"refund order 1234 on shopify",
"purge cloudflare cache for venturaclaw.com",
"add a row to my hubspot deals pipeline",
"send a message to the team",
];
const userConnected = new Set(CONNECTORS.map(c => c.id));
// Sequential prime — Mac1 has OLLAMA_NUM_PARALLEL=1 default, so parallel prime
// serialized anyway AND let some hit the per-request timeout. Sequential lets
// call #1 absorb the cold-load (~30-60s) and calls #2-5 reuse the now-warm
// model in seconds. Total time ~30-60s but ALL 5 land in cache.
let primed = 0;
for (const m of presets) {
try {
const intent = await LLM.classifyIntent(m, { connectors: CONNECTORS, userConnected });
if (intent) {
_demoCacheSet(m.trim().toLowerCase(), {
connector: intent.connector,
action: intent.action || null,
reason: intent.reason || null,
question: intent.question || null,
options: intent.options || null,
suggested: intent.suggested || null,
});
primed++;
}
} catch { /* skip on failure, keep going */ }
}
console.log(`[ventura-claw] primed demo cache with ${primed}/${presets.length} preset queries`);
})().catch(e => console.warn(`[ventura-claw] cache prime failed: ${e.message}`));
// Periodic keep-alive ping — re-warms qwen3 every 25 minutes if it's been idle,
// so a visitor showing up after a quiet hour doesn't pay the cold-load penalty.
setInterval(() => { LLM.prewarm().catch(() => {}); }, 25 * 60 * 1000);
});