← back to Ventura Claw

server/connectors/index.js

151 lines

// Connector registry — real implementations + per-user credential routing.
const slack      = require("./slack");
const stripe     = require("./stripe");
const cloudflare = require("./cloudflare");
const mailchimp  = require("./mailchimp");
const hubspot    = require("./hubspot");
const notion     = require("./notion");
const airtable   = require("./airtable");
const twilio     = require("./twilio");
const figma      = require("./figma");
const canva      = require("./canva");
const etsy       = require("./etsy");
const gmail      = require("./gmail");
const purelymail = require("./purelymail");
const archive    = require("./archive");
const elevenlabs = require("./elevenlabs");
const shopify    = require("./shopify");
// Public no-auth APIs (no key required) — pre-connected for everyone.
const met        = require("./met");
const cleveland  = require("./cleveland");
const aic        = require("./aic");
const wikipedia  = require("./wikipedia");
const colorapi   = require("./colorapi");
// SMB / civic — federal + state + local public-data APIs.
const sec         = require("./sec");
const usaspending = require("./usaspending");
const nws         = require("./nws");
const lacity      = require("./lacity");
const cadata      = require("./cadata");

const REAL = { slack, stripe, cloudflare, mailchimp, hubspot, notion, airtable, twilio, figma, canva, etsy, gmail, purelymail, archive, elevenlabs, shopify, met, cleveland, aic, wikipedia, colorapi, sec, usaspending, nws, lacity, cadata };

// Phase 8 — central sensitivity registry. Source of truth for "this action mutates state."
// Anything in WRITE_ACTIONS[id] requires the approval gate. Anything NOT listed is treated as read-only.
// Unknown connector or unknown action → fail-safe to "write" (require approval).
const WRITE_ACTIONS = {
  airtable:   new Set(["record.create", "record.update"]),
  archive:    new Set([]),  // Internet Archive read-only — no writes exposed
  canva:      new Set([]),
  cloudflare: new Set(["cache.purge_all", "cache.purge_files", "dns.create", "dns.update"]),
  etsy:       new Set([]),
  figma:      new Set([]),
  gmail:      new Set(["message.send"]),
  hubspot:    new Set(["contact.upsert", "deal.create"]),
  mailchimp:  new Set(["campaign.send", "member.upsert"]),
  notion:     new Set(["block.append", "page.create"]),
  purelymail: new Set(["user.create"]),
  slack:      new Set(["channels.create", "chat.postMessage", "reactions.add"]),
  stripe:     new Set(["charge.create", "customer.create", "payment_link.create", "refund.create", "subscription.cancel"]),
  twilio:     new Set(["sms.send"]),
  elevenlabs: new Set(["textToSpeech"]),
  shopify:    new Set(["order.fulfill", "order.refund", "product.update"]),
  // Public no-auth APIs are read-only — no writes.
  met:        new Set([]),
  cleveland:  new Set([]),
  aic:        new Set([]),
  wikipedia:  new Set([]),
  colorapi:   new Set([]),
  sec:        new Set([]),
  usaspending:new Set([]),
  nws:        new Set([]),
  lacity:     new Set([]),
  cadata:     new Set([]),
};

// Read actions enumerated per connector — explicit allowlist for fail-safe behavior.
const READ_ACTIONS = {
  airtable:   new Set(["bases.list", "tables.list", "records.list"]),
  archive:    new Set(["wayback.available", "wayback.cdx", "search", "metadata"]),
  canva:      new Set(["me", "design.get", "designs.list", "folders.list"]),
  cloudflare: new Set(["dns.list", "zone.list"]),
  etsy:       new Set(["me", "shops.findByUser", "listings.active", "receipts.list"]),
  figma:      new Set(["me", "team.projects", "file.read", "file.comments"]),
  gmail:      new Set([]),
  hubspot:    new Set(["contacts.list", "deals.list"]),
  mailchimp:  new Set(["lists.list", "campaigns.list", "list.members.list"]),
  notion:     new Set(["search", "databases.query"]),
  purelymail: new Set(["domains.list", "users.list"]),
  slack:      new Set(["channels.list", "users.lookup"]),
  stripe:     new Set(["balance.get", "charges.list"]),
  twilio:     new Set(["messages.list", "phone_numbers.list"]),
  elevenlabs: new Set(["voicesList", "user", "modelsList"]),
  shopify:    new Set(["shop.info", "products.list", "products.count", "orders.list", "orders.recent", "collections.list"]),
  met:        new Set(["search", "object", "departments"]),
  cleveland:  new Set(["search", "artwork"]),
  aic:        new Set(["search", "artwork"]),
  wikipedia:  new Set(["summary", "search", "onthisday"]),
  colorapi:   new Set(["identify", "scheme"]),
  sec:        new Set(["ticker_lookup", "submissions", "company_concept"]),
  usaspending:new Set(["search_awards", "recipient_summary"]),
  nws:        new Set(["forecast", "alerts"]),
  lacity:     new Set(["datasets", "query", "business_search"]),
  cadata:     new Set(["search_packages", "datastore_query"]),
};

function isWrite(id, action) {
  // Unknown connector → fail-safe write.
  if (!WRITE_ACTIONS[id]) return true;
  // Explicitly listed write → write.
  if (WRITE_ACTIONS[id].has(action)) return true;
  // Explicitly listed read → read.
  if (READ_ACTIONS[id]?.has(action)) return false;
  // Unknown action on known connector → fail-safe write.
  return true;
}

function get(id) { return REAL[id] || null; }
function listAll() {
  return Object.values(REAL).map(c => ({
    id: c.meta.id, name: c.meta.name, category: c.meta.category,
    docsUrl: c.meta.docsUrl, fields: c.fields || [], realImpl: true
  }));
}
function listConfigured(userCreds) {
  return Object.values(REAL).map(c => ({
    id: c.meta.id, name: c.meta.name,
    configured: c.configured(userCreds?.[c.meta.id])
  }));
}
async function health(id, userCreds) {
  const c = get(id);
  if (!c) return { ok: false, reason: "not_implemented" };
  return c.health(userCreds);
}
async function execute(id, action, input, userCreds) {
  const c = get(id);
  if (!c) throw new Error(`connector ${id} has no real impl`);
  const fn = c.actions[action];
  if (!fn) throw new Error(`action ${action} not implemented on ${id}`);
  return fn(input || {}, userCreds);
}

// Phase 8 — gated execute. Single sanctioned path for any caller in the app.
//   opts.fromApproval = true  → bypass approval gate (called by /api/approvals/:id/decide)
//   opts.force        = true  → admin override; logged separately as connector_exec_forced
// Throws { code: "approval_required" } if gate engaged, so callers can route to the queue.
async function executeGated(id, action, input, userCreds, opts = {}) {
  const writes = isWrite(id, action);
  if (writes && !opts.fromApproval && !opts.force) {
    const err = new Error(`approval_required: ${id}.${action} is a write action — queue it for approval`);
    err.code = "approval_required";
    err.connector = id;
    err.action = action;
    err.writes = true;
    throw err;
  }
  return execute(id, action, input, userCreds);
}

module.exports = { get, listAll, listConfigured, health, execute, executeGated, isWrite, REAL, WRITE_ACTIONS, READ_ACTIONS };