/**
* Tool Router — tool registry, user tools, and dispatch.
*
* Extracted from index.ts to reduce god file. Contains:
* - Tool registry loading (from ai_tool_registry with 60s cache)
* - User tool loading (per-store custom tools)
* - User tool execution (HTTP, RPC, SQL)
* - Main tool dispatch (routes to handler modules)
*/
import type { SupabaseClient } from "@supabase/supabase-js";
import { sanitizeError } from "../shared/agent-core.js";
import { validateToolArgs } from "./validation.js";
import { handleInventory, handleInventoryQuery, handleInventoryAudit } from "./handlers/inventory.js";
import { handlePurchaseOrders, handleTransfers } from "./handlers/supply-chain.js";
import { handleProducts, handleCollections } from "./handlers/catalog.js";
import { handleCustomers, handleOrders } from "./handlers/crm.js";
import { handleAnalytics } from "./handlers/analytics.js";
import { handleLocations, handleSuppliers, handleAlerts, handleAuditTrail, handleStore } from "./handlers/operations.js";
import { handleEmail, handleDocuments } from "./handlers/comms.js";
import { handleWebSearch, handleTelemetry } from "./handlers/platform.js";
import { handleBrowser } from "./handlers/browser.js";
import { handleDiscovery } from "./handlers/discovery.js";
import { handleVoice } from "./handlers/voice.js";
import { handleWorkflows } from "./handlers/workflows.js";
import { handleEmbeddings } from "./handlers/embeddings.js";
import { handleLLM } from "./handlers/llm-providers.js";
import { handleImageGen } from "./handlers/image-gen.js";
import { handleVideoGen } from "./handlers/video-gen.js";
import { handleAPIKeys } from "./handlers/api-keys.js";
import { summarizeResult, withTimeout } from "./lib/utils.js";
/**
* Escape SQL LIKE/ILIKE wildcards to prevent pattern injection.
* Escapes %, _, and \ characters.
*/
function sanitizeFilterValue(value: string): string {
return value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
}
// ============================================================================
// TYPES
// ============================================================================
export interface ToolDef {
name: string;
description: string;
input_schema: Record<string, unknown>;
}
export interface UserToolRow {
id: string;
name: string;
display_name: string;
description: string;
input_schema: Record<string, unknown>;
execution_type: "rpc" | "http" | "sql";
is_read_only: boolean;
requires_approval: boolean;
http_config: Record<string, unknown> | null;
rpc_function: string | null;
sql_template: string | null;
allowed_tables: string[] | null;
max_execution_time_ms: number;
}
export interface AgentConfig {
id: string;
name: string;
description: string;
system_prompt: string;
model: string;
max_tokens: number;
max_tool_calls: number;
temperature: number;
enabled_tools: string[];
can_query: boolean;
can_send: boolean;
can_modify: boolean;
tone: string;
verbosity: string;
api_key: string | null;
store_id: string | null;
context_config: {
includeLocations?: boolean;
locationIds?: string[];
includeCustomers?: boolean;
customerSegments?: string[];
max_history_chars?: number;
max_tool_result_chars?: number;
max_message_chars?: number;
} | null;
}
// ============================================================================
// TOOL REGISTRY (loaded from database — same as CLI and edge function)
// ============================================================================
let cachedTools: ToolDef[] = [];
let cacheTime = 0;
export async function loadTools(supabase: SupabaseClient): Promise<ToolDef[]> {
if (cachedTools.length > 0 && Date.now() - cacheTime < 60_000) return cachedTools;
const { data, error } = await supabase
.from("ai_tool_registry")
.select("name, description, definition")
.eq("is_active", true)
.neq("tool_mode", "code");
if (error || !data) return cachedTools;
cachedTools = data.map((t: any) => ({
name: t.name,
description: t.description || t.definition?.description || t.name,
input_schema: t.definition?.input_schema || { type: "object", properties: {} },
}));
cacheTime = Date.now();
return cachedTools;
}
// ============================================================================
// USER TOOLS (per-store custom tools from user_tools table)
// ============================================================================
const userToolCache = new Map<string, { tools: UserToolRow[]; defs: ToolDef[]; time: number }>();
const USER_TOOL_CACHE_MAX = 100;
function evictUserToolCache(): void {
if (userToolCache.size <= USER_TOOL_CACHE_MAX) return;
// Map iteration order is insertion order — delete oldest entries
const excess = userToolCache.size - USER_TOOL_CACHE_MAX;
let i = 0;
for (const key of userToolCache.keys()) {
if (i >= excess) break;
userToolCache.delete(key);
i++;
}
}
export async function loadUserTools(supabase: SupabaseClient, storeId: string): Promise<{ rows: UserToolRow[]; defs: ToolDef[] }> {
const cached = userToolCache.get(storeId);
if (cached && Date.now() - cached.time < 60_000) return { rows: cached.tools, defs: cached.defs };
const { data, error } = await supabase
.from("user_tools")
.select("id, name, display_name, description, input_schema, execution_type, is_read_only, requires_approval, http_config, rpc_function, sql_template, allowed_tables, max_execution_time_ms")
.eq("store_id", storeId)
.eq("is_active", true);
if (error || !data?.length) return { rows: [], defs: [] };
const rows: UserToolRow[] = data as UserToolRow[];
const defs: ToolDef[] = rows.map((t) => ({
name: `user_tool__${t.name}`,
description: `[Custom Tool] ${t.display_name}: ${t.description}${t.requires_approval ? " (requires approval)" : ""}`,
input_schema: t.input_schema || { type: "object", properties: {} },
}));
userToolCache.set(storeId, { tools: rows, defs, time: Date.now() });
evictUserToolCache();
return { rows, defs };
}
export function getUserToolByPrefixedName(rows: UserToolRow[], prefixedName: string): UserToolRow | undefined {
const toolName = prefixedName.replace(/^user_tool__/, "");
return rows.find((t) => t.name === toolName);
}
export function getToolsForAgent(agent: AgentConfig, allTools: ToolDef[], userToolDefs: ToolDef[] = []): ToolDef[] {
const combined = [...allTools, ...userToolDefs];
if (agent.enabled_tools?.length > 0) {
return combined.filter((t) => agent.enabled_tools.includes(t.name));
}
return combined;
}
// ============================================================================
// USER TOOL EXECUTOR — handles RPC, HTTP, SQL execution types
// ============================================================================
const TOOL_TIMEOUT_MS = 30_000;
async function executeUserTool(
supabase: SupabaseClient,
userTool: UserToolRow,
args: Record<string, unknown>,
storeId: string,
agentId?: string,
conversationId?: string,
): Promise<{ success: boolean; data?: unknown; error?: string }> {
const timeout = userTool.max_execution_time_ms || 5000;
if (userTool.execution_type === "http") {
return executeHTTPUserTool(supabase, userTool, args, storeId, timeout);
}
try {
const { data, error } = await supabase.rpc("execute_user_tool", {
p_tool_id: userTool.id,
p_store_id: storeId,
p_args: args,
p_agent_id: agentId || null,
p_conversation_id: conversationId || null,
});
if (error) return { success: false, error: error.message };
const result = data as { success: boolean; data?: unknown; error?: string; pending_approval?: boolean; execution_id?: string; message?: string };
if (result.pending_approval) {
return { success: false, error: `Tool requires approval. Execution ID: ${result.execution_id}. ${result.message}` };
}
if (result.success && result.data && (result.data as any).execute_sql) {
return executeSQLUserTool(supabase, result.data as any, args, storeId);
}
return result.success
? { success: true, data: result.data }
: { success: false, error: result.error || "Unknown error" };
} catch (err) {
return { success: false, error: sanitizeError(err) };
}
}
async function executeHTTPUserTool(
supabase: SupabaseClient,
userTool: UserToolRow,
args: Record<string, unknown>,
storeId: string,
timeout: number,
): Promise<{ success: boolean; data?: unknown; error?: string }> {
const config = userTool.http_config;
if (!config || !(config as any).url) {
return { success: false, error: "HTTP tool has no URL configured" };
}
const httpCfg = config as { url: string; method?: string; headers?: Record<string, string>; body_template?: Record<string, unknown> };
let url = httpCfg.url;
let headers: Record<string, string> = { ...(httpCfg.headers || {}) };
// Collect secret names referenced in the URL, headers, and body template
const configStr = JSON.stringify({ url: httpCfg.url, headers: httpCfg.headers, body: httpCfg.body_template });
const secretRefs = [...configStr.matchAll(/\{\{secret:(\w+)\}\}/g)].map(m => m[1]);
// Decrypt each referenced secret via RPC (consistent with all other handlers)
const secretMap = new Map<string, string>();
await Promise.all(
[...new Set(secretRefs)].map(async (name) => {
try {
const { data } = await supabase.rpc("decrypt_secret", { p_name: name, p_store_id: storeId });
if (data) secretMap.set(name, data as string);
} catch { /* secret not found — will remain as {{secret:NAME}} placeholder */ }
}),
);
const resolveSecrets = (text: string): string => {
return text.replace(/\{\{secret:(\w+)\}\}/g, (_, name) => secretMap.get(name) || `{{secret:${name}}}`);
};
const resolveArgs = (text: string): string => {
return text.replace(/\{\{(\w+)\}\}/g, (_, name) => {
if (name === "secret") return `{{${name}}}`;
const val = args[name];
return val !== undefined ? String(val) : `{{${name}}}`;
});
};
const resolve = (text: string): string => resolveSecrets(resolveArgs(text));
url = resolve(url);
for (const [key, val] of Object.entries(headers)) {
headers[key] = resolve(val);
}
let body: string | undefined;
const method = (httpCfg.method || "GET").toUpperCase();
if (method !== "GET" && method !== "HEAD") {
if (httpCfg.body_template) {
const resolvedBody: Record<string, unknown> = {};
for (const [key, val] of Object.entries(httpCfg.body_template)) {
if (typeof val === "string") {
resolvedBody[key] = resolve(val);
} else {
resolvedBody[key] = val;
}
}
for (const [key, val] of Object.entries(args)) {
if (!(key in resolvedBody)) {
resolvedBody[key] = val;
}
}
body = JSON.stringify(resolvedBody);
} else {
body = JSON.stringify(args);
}
if (!headers["Content-Type"] && !headers["content-type"]) {
headers["Content-Type"] = "application/json";
}
}
// SSRF protection
try {
const parsedUrl = new URL(url);
const host = parsedUrl.hostname.toLowerCase();
const blocked = ["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]", "metadata.google.internal"];
if (blocked.includes(host) || host.endsWith(".internal") || host.endsWith(".local") ||
/^10\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host) || /^192\.168\./.test(host) ||
/^169\.254\./.test(host) || host.startsWith("fd") || host.startsWith("fc00:") || host.startsWith("fe80:") ||
host.includes("::ffff:") ||
(parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:")) {
return { success: false, error: "URL targets a blocked internal/private address" };
}
} catch {
return { success: false, error: "Invalid URL" };
}
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const resp = await fetch(url, { method, headers, body, signal: controller.signal });
clearTimeout(timer);
const contentType = resp.headers.get("content-type") || "";
let data: unknown;
if (contentType.includes("json")) {
data = await resp.json();
} else {
data = await resp.text();
}
if (!resp.ok) {
return { success: false, error: `HTTP ${resp.status}: ${typeof data === "string" ? data.substring(0, 500) : JSON.stringify(data).substring(0, 500)}` };
}
return { success: true, data };
} catch (err: any) {
if (err.name === "AbortError") {
return { success: false, error: `HTTP request timed out after ${timeout}ms` };
}
return { success: false, error: sanitizeError(err) };
}
}
async function executeSQLUserTool(
supabase: SupabaseClient,
sqlConfig: { template: string; allowed_tables: string[]; is_read_only: boolean },
args: Record<string, unknown>,
storeId: string,
): Promise<{ success: boolean; data?: unknown; error?: string }> {
if (!sqlConfig.is_read_only) {
return { success: false, error: "Write SQL tools are not supported in server execution" };
}
if (!sqlConfig.template) {
return { success: false, error: "No SQL template configured" };
}
try {
const { data, error } = await supabase.rpc("execute_safe_sql", {
p_sql: sqlConfig.template,
p_params: args,
p_store_id: storeId,
p_allowed_tables: sqlConfig.allowed_tables || [],
});
if (error) return { success: false, error: error.message };
if (data?.success) {
return { success: true, data: data.data };
}
return { success: false, error: data?.error || "SQL execution failed" };
} catch (err) {
return { success: false, error: sanitizeError(err) };
}
}
// ============================================================================
// TOOL EXECUTOR — dispatches to handler modules
// ============================================================================
export async function executeTool(
supabase: SupabaseClient,
toolName: string,
args: Record<string, unknown>,
storeId?: string,
traceId?: string,
userId?: string | null,
userEmail?: string | null,
source?: string,
conversationId?: string,
userToolRows?: UserToolRow[],
agentId?: string,
): Promise<{ success: boolean; data?: unknown; error?: string }> {
const startTime = Date.now();
const action = args.action as string | undefined;
let result: { success: boolean; data?: unknown; error?: string };
const storeRequired = toolName !== "web_search" && toolName !== "discovery";
if (storeRequired && (!storeId || !/^[0-9a-fA-F]{8}-/.test(storeId))) {
return { success: false, error: `store_id is required for ${toolName}. Ensure a store is selected.` };
}
// Validate mutation args before dispatch — read-only actions pass through
const validation = validateToolArgs(toolName, args);
if (!validation.valid) {
return { success: false, error: validation.error };
}
// Use validated (coerced) args for downstream handlers
args = validation.data;
try {
const toolPromise = (async () => {
switch (toolName) {
case "inventory": {
const invAction = args.action as string || "";
if (invAction.startsWith("audit_")) {
return handleInventoryAudit(supabase, { ...args, action: invAction.slice(6) }, storeId);
}
const queryActions = new Set(["summary", "velocity", "by_location", "in_stock"]);
if (queryActions.has(invAction)) {
return handleInventoryQuery(supabase, args, storeId);
}
return handleInventory(supabase, args, storeId);
}
case "purchase_orders": return handlePurchaseOrders(supabase, args, storeId);
case "transfers": return handleTransfers(supabase, args, storeId);
case "products": return handleProducts(supabase, args, storeId);
case "collections": return handleCollections(supabase, args, storeId);
case "customers": return handleCustomers(supabase, args, storeId);
case "orders": return handleOrders(supabase, args, storeId);
case "analytics": return handleAnalytics(supabase, args, storeId);
case "locations": return handleLocations(supabase, args, storeId);
case "suppliers": return handleSuppliers(supabase, args, storeId);
case "store": return handleStore(supabase, args, storeId);
case "email": return handleEmail(supabase, args, storeId);
case "documents": return handleDocuments(supabase, args, storeId);
case "alerts": return handleAlerts(supabase, args, storeId);
case "audit_trail": return handleAuditTrail(supabase, args, storeId);
case "web_search": return handleWebSearch(supabase, args, storeId);
case "telemetry": return handleTelemetry(supabase, args, storeId);
case "workflows": return handleWorkflows(supabase, args, storeId);
case "browser": return handleBrowser(supabase, args, storeId);
case "discovery": return handleDiscovery(supabase, args, storeId);
case "voice": return handleVoice(supabase, args, storeId);
case "embeddings": return handleEmbeddings(supabase, args, storeId);
case "llm": return handleLLM(supabase, args, storeId);
case "image_gen": return handleImageGen(supabase, args, storeId);
case "video_gen": return handleVideoGen(supabase, args, storeId);
case "api_keys": return handleAPIKeys(supabase, args, storeId);
case "supply_chain": {
const scAction = args.action as string || "";
if (scAction === "find_suppliers") {
let q = supabase.from("suppliers").select("*").eq("store_id", storeId).order("created_at", { ascending: false }).limit(args.limit as number || 50);
if (args.name) q = q.ilike("external_company", `%${sanitizeFilterValue(args.name as string)}%`);
const { data, error } = await q;
return error ? { success: false, error: error.message } : { success: true, data };
}
if (scAction.startsWith("po_")) {
return handlePurchaseOrders(supabase, { ...args, action: scAction.slice(3) }, storeId);
}
if (scAction.startsWith("transfer_")) {
return handleTransfers(supabase, { ...args, action: scAction.slice(9) }, storeId);
}
return { success: false, error: `Unknown supply_chain action: ${scAction}. Use po_* or transfer_* actions.` };
}
default: {
if (toolName.startsWith("user_tool__") && userToolRows && storeId) {
const userTool = getUserToolByPrefixedName(userToolRows, toolName);
if (userTool) {
return executeUserTool(supabase, userTool, args, storeId, agentId, conversationId);
}
}
return { success: false, error: `Unknown tool: ${toolName}` };
}
}
})();
result = await withTimeout(toolPromise, TOOL_TIMEOUT_MS, toolName);
} catch (err) {
result = { success: false, error: sanitizeError(err) };
}
// Audit log
try {
const details: Record<string, unknown> = { source: source || "fly_container", args };
if (result.success && result.data) {
details.result_summary = summarizeResult(toolName, action, result.data);
}
const { error: auditErr } = await supabase.from("audit_logs").insert({
action: `tool.${toolName}${action ? `.${action}` : ""}`,
severity: result.success ? "info" : "error",
store_id: storeId || null,
resource_type: "mcp_tool",
resource_id: toolName,
request_id: traceId || null,
conversation_id: conversationId || null,
source: source || "fly_container",
details,
error_message: result.error || null,
duration_ms: Date.now() - startTime,
user_id: userId || null,
user_email: userEmail || null,
});
if (auditErr) console.error("[audit] tool insert failed:", auditErr.message, auditErr.details, auditErr.hint);
} catch (err) {
console.error("[audit] exception:", err);
}
return result;
}
// ============================================================================
// AGENT LOADER
// ============================================================================
export async function loadAgentConfig(supabase: SupabaseClient, agentId: string): Promise<AgentConfig | null> {
const { data, error } = await supabase
.from("ai_agent_config")
.select("*")
.eq("id", agentId)
.single();
if (error || !data) return null;
return data as AgentConfig;
}