From b144e7e9eee5c69f71845406e12be1a53485b49b Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:17:12 +0300 Subject: [PATCH] Fix: Block unauthenticated paid-API abuse and enforce session governance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR secures the chat and tool execution pathways by strictly enforcing server-validated sessions. It prevents unauthenticated callers from bypassing governance checks and consuming the server tenant’s paid services (such as web search, phone verification, and image generation) without a verified identity or spending rule. Changes: app/api/chat/route.ts: Removed the bypassable if (validatedSessionId) check. A verified sessionId and phoneNumber are now strictly required before triggering orchestrate(). Added a payload size limit (50KB) to prevent memory exhaustion lib/sapiom.ts: Modified sapiomFetch to explicitly require an agentName (session identifier) and disabled fallback to the unauthenticated defaultClient for paid API requests lib/tools.ts: Added strict context validation (context?.agentName) in tool executors to ensure billing and governance boundaries are respected before invoking paid services. --- app/api/chat/route.ts | 207 +++++------ lib/sapiom.ts | 93 ++--- lib/tools.ts | 816 +++++++++++++++++++++--------------------- 3 files changed, 571 insertions(+), 545 deletions(-) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index f5acee5..93394a6 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,100 +1,107 @@ -import { NextRequest } from "next/server"; -import type { ChatMessage } from "@/types"; -import { createSSEStream } from "@/lib/stream"; -import { orchestrate } from "@/lib/orchestrator"; -import { validateAgentAndRule } from "@/lib/governance"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export async function POST(req: NextRequest) { - let body: { messages?: unknown; sessionId?: unknown; phoneNumber?: unknown }; - try { - body = await req.json(); - } catch { - return new Response( - JSON.stringify({ error: "Invalid JSON body" }), - { status: 400, headers: { "Content-Type": "application/json" } } - ); - } - - const { messages, sessionId, phoneNumber } = body; - - if (!Array.isArray(messages) || messages.length === 0) { - return new Response( - JSON.stringify({ error: "messages must be a non-empty array" }), - { status: 400, headers: { "Content-Type": "application/json" } } - ); - } - - // Basic validation: each message needs role and content - for (const msg of messages) { - if ( - typeof msg !== "object" || - msg === null || - !("role" in msg) || - !("content" in msg) || - typeof msg.content !== "string" - ) { - return new Response( - JSON.stringify({ error: "Each message must have role and content fields" }), - { status: 400, headers: { "Content-Type": "application/json" } } - ); - } - } - - const validatedMessages = messages as ChatMessage[]; - const validatedSessionId = typeof sessionId === "string" ? sessionId : null; - const validatedPhoneNumber = typeof phoneNumber === "string" ? phoneNumber : null; - - // Gate: verified requests must have a registered agent + spending rule. - // This prevents session spoofing — only phone numbers that completed - // verification (which creates the agent) pass this check. - if (validatedSessionId) { - // A sessionId without a phoneNumber is invalid — legitimate clients always - // send both after verification. Reject to prevent unmetered API calls. - if (!validatedPhoneNumber) { - return new Response( - JSON.stringify({ error: "Session requires a phone number. Please clear your session and verify again." }), - { status: 403, headers: { "Content-Type": "application/json" } } - ); - } - - try { - const isRegistered = await validateAgentAndRule(validatedPhoneNumber); - if (!isRegistered) { - return new Response( - JSON.stringify({ error: "Session not recognized. Please clear your session and verify again." }), - { status: 403, headers: { "Content-Type": "application/json" } } - ); - } - } catch (err) { - console.error("Governance check failed for", validatedPhoneNumber, err); - return new Response( - JSON.stringify({ error: "Unable to verify session. Please try again." }), - { status: 500, headers: { "Content-Type": "application/json" } } - ); - } - } - - const { stream, emit, close } = createSSEStream(); - - // Run orchestration in background — don't await, let it stream - orchestrate(validatedMessages, validatedSessionId, emit, validatedPhoneNumber) - .then(() => close()) - .catch((err) => { - emit({ - type: "error", - message: err instanceof Error ? err.message : "Internal server error", - }); - close(); - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); -} +import { NextRequest } from "next/server"; +import type { ChatMessage } from "@/types"; +import { createSSEStream } from "@/lib/stream"; +import { orchestrate } from "@/lib/orchestrator"; +import { validateAgentAndRule } from "@/lib/governance"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + // SECURITY FIX: Enforce request body size limits to prevent memory exhaustion + const contentLength = req.headers.get("content-length"); + if (contentLength && parseInt(contentLength, 10) > 50000) { + return new Response( + JSON.stringify({ error: "Payload too large" }), + { status: 413, headers: { "Content-Type": "application/json" } } + ); + } + + let body: { messages?: unknown; sessionId?: unknown; phoneNumber?: unknown }; + try { + body = await req.json(); + } catch { + return new Response( + JSON.stringify({ error: "Invalid JSON body" }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + const { messages, sessionId, phoneNumber } = body; + + if (!Array.isArray(messages) || messages.length === 0) { + return new Response( + JSON.stringify({ error: "messages must be a non-empty array" }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // Basic validation: each message needs role and content + for (const msg of messages) { + if ( + typeof msg !== "object" || + msg === null || + !("role" in msg) || + !("content" in msg) || + typeof msg.content !== "string" + ) { + return new Response( + JSON.stringify({ error: "Each message must have role and content fields" }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + } + + const validatedMessages = messages as ChatMessage[]; + const validatedSessionId = typeof sessionId === "string" ? sessionId : null; + const validatedPhoneNumber = typeof phoneNumber === "string" ? phoneNumber : null; + + // SECURITY FIX: Unauthenticated access bypass prevented. + // We strictly require both a sessionId and a phoneNumber for ANY orchestration. + // The previous implementation used `if (validatedSessionId)`, which allowed callers + // to bypass governance simply by omitting the sessionId in the request payload. + if (!validatedSessionId || !validatedPhoneNumber) { + return new Response( + JSON.stringify({ error: "Unauthorized: A valid verified session and phone number are strictly required." }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); + } + + try { + // Gate: verified requests must have a registered agent + spending rule. + const isRegistered = await validateAgentAndRule(validatedPhoneNumber); + if (!isRegistered) { + return new Response( + JSON.stringify({ error: "Session not recognized. Please clear your session and verify again." }), + { status: 403, headers: { "Content-Type": "application/json" } } + ); + } + } catch (err) { + console.error("Governance check failed for", validatedPhoneNumber, err); + return new Response( + JSON.stringify({ error: "Unable to verify session. Please try again." }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + const { stream, emit, close } = createSSEStream(); + + // Run orchestration in background + orchestrate(validatedMessages, validatedSessionId, emit, validatedPhoneNumber) + .then(() => close()) + .catch((err) => { + emit({ + type: "error", + message: err instanceof Error ? err.message : "Internal server error", + }); + close(); + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} \ No newline at end of file diff --git a/lib/sapiom.ts b/lib/sapiom.ts index 9c441c9..a6ef2f1 100644 --- a/lib/sapiom.ts +++ b/lib/sapiom.ts @@ -1,46 +1,47 @@ -import { createFetch } from "@sapiom/fetch"; - -const defaultClient = createFetch({ - apiKey: process.env.SAPIOM_API_KEY, -}); - -// Cache clients by agentName to avoid recreating on every call -const agentClients = new Map(); - -function getClient(agentName?: string): typeof fetch { - if (!agentName) return defaultClient; - let client = agentClients.get(agentName); - if (!client) { - client = createFetch({ - apiKey: process.env.SAPIOM_API_KEY, - agentName, - }); - agentClients.set(agentName, client); - } - return client; -} - -export async function sapiomFetch( - service: string, - path: string, - options: { - method?: string; - body?: unknown; - headers?: Record; - agentName?: string; - } = {} -): Promise { - const { method = "POST", body, headers = {}, agentName } = options; - - const url = `https://${service}.services.sapiom.ai${path}`; - const client = getClient(agentName); - - return client(url, { - method, - headers: { - "Content-Type": "application/json", - ...headers, - }, - ...(body !== undefined ? { body: JSON.stringify(body) } : {}), - }); -} +import { createFetch } from "@sapiom/fetch"; + +// Cache clients by agentName to avoid recreating on every call +const agentClients = new Map(); + +function getClient(agentName: string): typeof fetch { + let client = agentClients.get(agentName); + if (!client) { + client = createFetch({ + apiKey: process.env.SAPIOM_API_KEY, + agentName, + }); + agentClients.set(agentName, client); + } + return client; +} + +export async function sapiomFetch( + service: string, + path: string, + options: { + method?: string; + body?: unknown; + headers?: Record; + agentName?: string; + } = {} +): Promise { + const { method = "POST", body, headers = {}, agentName } = options; + + // SECURITY FIX: Prevent unauthenticated paid-API abuse by strictly requiring a verified session (agentName). + // This ensures that the server's default SAPIOM_API_KEY cannot be consumed by unauthenticated callers. + if (!agentName) { + throw new Error("Unauthorized: Paid API requests require a verified session/agent."); + } + + const url = `https://${service}.services.sapiom.ai${path}`; + const client = getClient(agentName); + + return client(url, { + method, + headers: { + "Content-Type": "application/json", + ...headers, + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); +} \ No newline at end of file diff --git a/lib/tools.ts b/lib/tools.ts index 7e2573d..29cd845 100644 --- a/lib/tools.ts +++ b/lib/tools.ts @@ -1,399 +1,417 @@ -import type { ToolDefinition, ToolExecutor, ToolContext, StreamEvent } from "@/types"; -import { sapiomFetch } from "./sapiom"; -import { registerAgentWithSpendingRule } from "./governance"; - -// ── Tool Definitions (OpenAI function-calling format) ── - -export const TOOL_DEFINITIONS: ToolDefinition[] = [ - { - type: "function", - function: { - name: "search_web", - description: - "Search the web for information, design inspiration, or content to include in the website", - parameters: { - type: "object", - properties: { - query: { type: "string", description: "Search query" }, - num_results: { - type: "number", - description: "Number of results (default 5)", - }, - }, - required: ["query"], - }, - }, - }, - { - type: "function", - function: { - name: "request_phone_number", - description: - "Prompt the user to enter their phone number for verification. Call this before verify_phone to collect the number.", - parameters: { - type: "object", - properties: {}, - required: [], - }, - }, - }, - { - type: "function", - function: { - name: "verify_phone", - description: - "Send a verification code to the user's phone number to establish a session", - parameters: { - type: "object", - properties: { - phone_number: { - type: "string", - description: - "Phone number in E.164 format (e.g., +14155551234)", - }, - }, - required: ["phone_number"], - }, - }, - }, - { - type: "function", - function: { - name: "check_verification", - description: - "Verify the code the user entered. Returns session_id on success.", - parameters: { - type: "object", - properties: { - verification_request_id: { - type: "string", - description: - "The verification request ID returned from verify_phone", - }, - code: { - type: "string", - description: "6-digit verification code entered by the user", - }, - }, - required: ["verification_request_id", "code"], - }, - }, - }, - { - type: "function", - function: { - name: "generate_image", - description: - "Generate an image for the website (hero image, logo, background, etc.)", - parameters: { - type: "object", - properties: { - prompt: { - type: "string", - description: "Image generation prompt", - }, - aspect_ratio: { - type: "string", - enum: ["16:9", "1:1", "9:16"], - description: "Image aspect ratio (default 16:9)", - }, - }, - required: ["prompt"], - }, - }, - }, -]; - -// ── Tool Executors ── - -async function searchWeb( - args: Record, - emitEvent: (event: StreamEvent) => void, - context?: ToolContext -): Promise { - const query = args.query as string; - const numResults = (args.num_results as number) ?? 5; - - emitEvent({ - type: "tool_status", - tool: "search_web", - status: "running", - message: `Searching for "${query}"...`, - }); - - const response = await sapiomFetch("linkup", "/v1/search", { - method: "POST", - body: { q: query, depth: "standard", outputType: "sourcedAnswer" }, - agentName: context?.agentName, - }); - - if (!response.ok) { - const errorText = await response.text().catch(() => "Unknown error"); - throw new Error(`Linkup search failed (${response.status}): ${errorText}`); - } - - const data = await response.json(); - - // Trim the result to avoid blowing up the LLM context window. - // Keep the answer (capped) and just source names/URLs (no snippets). - const answer = typeof data.answer === "string" - ? data.answer.slice(0, 2000) - : data.answer; - const sources = Array.isArray(data.sources) - ? data.sources.slice(0, 5).map((s: { name?: string; url?: string }) => ({ - name: s.name, - url: s.url, - })) - : []; - const truncated = { answer, sources }; - - emitEvent({ - type: "tool_status", - tool: "search_web", - status: "complete", - }); - - emitEvent({ - type: "tool_result", - tool: "search_web", - result: truncated, - }); - - return truncated; -} - -async function requestPhoneNumber( - _args: Record, - emitEvent: (event: StreamEvent) => void, - _context?: ToolContext -): Promise { - emitEvent({ - type: "tool_status", - tool: "request_phone_number", - status: "needs_input", - message: "Please enter your phone number", - }); - - return { - needs_input: true, - step: "phone", - message: "Please enter your phone number to get started", - }; -} - -async function verifyPhone( - args: Record, - emitEvent: (event: StreamEvent) => void, - context?: ToolContext -): Promise { - const phoneNumber = args.phone_number as string; - - emitEvent({ - type: "tool_status", - tool: "verify_phone", - status: "running", - message: `Sending verification code to ${phoneNumber}...`, - }); - - const response = await sapiomFetch("prelude", "/verifications", { - method: "POST", - body: { - target: { type: "phone_number", value: phoneNumber }, - }, - agentName: context?.agentName, - }); - - if (!response.ok) { - const errorText = await response.text().catch(() => "Unknown error"); - throw new Error( - `Failed to send verification code (${response.status}): ${errorText}` - ); - } - - const data = await response.json(); - - emitEvent({ - type: "tool_result", - tool: "verify_phone", - result: { verification_request_id: data.id }, - }); - - return { - needs_input: true, - step: "code", - verification_request_id: data.id, - message: `Enter the verification code sent to ${phoneNumber}`, - }; -} - -async function checkVerification( - args: Record, - emitEvent: (event: StreamEvent) => void, - context?: ToolContext -): Promise { - const verificationRequestId = args.verification_request_id as string; - const code = args.code as string; - - emitEvent({ - type: "tool_status", - tool: "check_verification", - status: "running", - message: "Checking verification code...", - }); - - const response = await sapiomFetch("prelude", "/verifications/check", { - method: "POST", - body: { - verificationRequestId, - code, - }, - agentName: context?.agentName, - }); - - if (!response.ok) { - const errorText = await response.text().catch(() => "Unknown error"); - - emitEvent({ - type: "tool_status", - tool: "check_verification", - status: "error", - message: "Invalid verification code, try again", - }); - - return { error: `Invalid verification code: ${errorText}` }; - } - - const data = await response.json(); - - if (data.status !== "success") { - emitEvent({ - type: "tool_status", - tool: "check_verification", - status: "error", - message: "Invalid verification code, try again", - }); - - return { error: "Invalid verification code" }; - } - - // Set up agent + spending rule for this phone number - const phoneNumber = context?.agentName; - if (phoneNumber) { - emitEvent({ - type: "tool_status", - tool: "check_verification", - status: "running", - message: "Setting up spending limits...", - }); - - const MAX_RETRIES = 3; - let setupSucceeded = false; - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - await registerAgentWithSpendingRule(phoneNumber); - setupSucceeded = true; - break; - } catch (err) { - console.error(`Agent/rule setup attempt ${attempt} failed:`, err); - if (attempt < MAX_RETRIES) { - await new Promise((r) => setTimeout(r, 500 * attempt)); - } - } - } - - if (!setupSucceeded) { - emitEvent({ - type: "tool_status", - tool: "check_verification", - status: "error", - message: "Failed to set up spending limits. Please try again.", - }); - return { error: "Failed to set up spending limits after multiple attempts" }; - } - } - - const result = { - sessionId: data.id, - verified: true, - }; - - emitEvent({ - type: "tool_status", - tool: "check_verification", - status: "complete", - message: "Phone verified successfully", - }); - - emitEvent({ - type: "tool_result", - tool: "check_verification", - result, - }); - - return result; -} - -const ASPECT_RATIO_TO_IMAGE_SIZE: Record = { - "16:9": "landscape_16_9", - "1:1": "square_hd", - "9:16": "portrait_16_9", -}; - -async function generateImage( - args: Record, - emitEvent: (event: StreamEvent) => void, - context?: ToolContext -): Promise { - const prompt = args.prompt as string; - const aspectRatio = (args.aspect_ratio as string) ?? "16:9"; - const imageSize = ASPECT_RATIO_TO_IMAGE_SIZE[aspectRatio] ?? "landscape_16_9"; - - emitEvent({ - type: "tool_status", - tool: "generate_image", - status: "running", - message: "Generating image...", - }); - - const response = await sapiomFetch("fal", "/v1/run/fal-ai/flux/dev", { - method: "POST", - body: { prompt, image_size: imageSize, num_images: 1 }, - agentName: context?.agentName, - }); - - if (!response.ok) { - const errorText = await response.text().catch(() => "Unknown error"); - throw new Error(`Image generation failed (${response.status}): ${errorText}`); - } - - const data = await response.json(); - const image = data.images?.[0]; - - if (!image?.url) { - throw new Error("No image URL returned from generation service"); - } - - const result = { url: image.url, width: image.width, height: image.height }; - - emitEvent({ - type: "tool_status", - tool: "generate_image", - status: "complete", - }); - - emitEvent({ - type: "tool_result", - tool: "generate_image", - result, - }); - - return result; -} - -export const TOOL_EXECUTORS: Record = { - search_web: searchWeb, - request_phone_number: requestPhoneNumber, - verify_phone: verifyPhone, - check_verification: checkVerification, - generate_image: generateImage, -}; +import type { ToolDefinition, ToolExecutor, ToolContext, StreamEvent } from "@/types"; +import { sapiomFetch } from "./sapiom"; +import { registerAgentWithSpendingRule } from "./governance"; + +// ── Tool Definitions (OpenAI function-calling format) ── + +export const TOOL_DEFINITIONS: ToolDefinition[] = [ + { + type: "function", + function: { + name: "search_web", + description: + "Search the web for information, design inspiration, or content to include in the website", + parameters: { + type: "object", + properties: { + query: { type: "string", description: "Search query" }, + num_results: { + type: "number", + description: "Number of results (default 5)", + }, + }, + required: ["query"], + }, + }, + }, + { + type: "function", + function: { + name: "request_phone_number", + description: + "Prompt the user to enter their phone number for verification. Call this before verify_phone to collect the number.", + parameters: { + type: "object", + properties: {}, + required: [], + }, + }, + }, + { + type: "function", + function: { + name: "verify_phone", + description: + "Send a verification code to the user's phone number to establish a session", + parameters: { + type: "object", + properties: { + phone_number: { + type: "string", + description: + "Phone number in E.164 format (e.g., +14155551234)", + }, + }, + required: ["phone_number"], + }, + }, + }, + { + type: "function", + function: { + name: "check_verification", + description: + "Verify the code the user entered. Returns session_id on success.", + parameters: { + type: "object", + properties: { + verification_request_id: { + type: "string", + description: + "The verification request ID returned from verify_phone", + }, + code: { + type: "string", + description: "6-digit verification code entered by the user", + }, + }, + required: ["verification_request_id", "code"], + }, + }, + }, + { + type: "function", + function: { + name: "generate_image", + description: + "Generate an image for the website (hero image, logo, background, etc.)", + parameters: { + type: "object", + properties: { + prompt: { + type: "string", + description: "Image generation prompt", + }, + aspect_ratio: { + type: "string", + enum: ["16:9", "1:1", "9:16"], + description: "Image aspect ratio (default 16:9)", + }, + }, + required: ["prompt"], + }, + }, + }, +]; + +// ── Tool Executors ── + +async function searchWeb( + args: Record, + emitEvent: (event: StreamEvent) => void, + context?: ToolContext +): Promise { + // SECURITY FIX: Enforce session binding before executing paid search tools + if (!context?.agentName) { + throw new Error("Unauthorized: Search requires an active verified session."); + } + + const query = args.query as string; + const numResults = (args.num_results as number) ?? 5; + + emitEvent({ + type: "tool_status", + tool: "search_web", + status: "running", + message: `Searching for "${query}"...`, + }); + + const response = await sapiomFetch("linkup", "/v1/search", { + method: "POST", + body: { q: query, depth: "standard", outputType: "sourcedAnswer" }, + agentName: context.agentName, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + throw new Error(`Linkup search failed (${response.status}): ${errorText}`); + } + + const data = await response.json(); + + // Trim the result to avoid blowing up the LLM context window. + // Keep the answer (capped) and just source names/URLs (no snippets). + const answer = typeof data.answer === "string" + ? data.answer.slice(0, 2000) + : data.answer; + const sources = Array.isArray(data.sources) + ? data.sources.slice(0, 5).map((s: { name?: string; url?: string }) => ({ + name: s.name, + url: s.url, + })) + : []; + const truncated = { answer, sources }; + + emitEvent({ + type: "tool_status", + tool: "search_web", + status: "complete", + }); + + emitEvent({ + type: "tool_result", + tool: "search_web", + result: truncated, + }); + + return truncated; +} + +async function requestPhoneNumber( + _args: Record, + emitEvent: (event: StreamEvent) => void, + _context?: ToolContext +): Promise { + emitEvent({ + type: "tool_status", + tool: "request_phone_number", + status: "needs_input", + message: "Please enter your phone number", + }); + + return { + needs_input: true, + step: "phone", + message: "Please enter your phone number to get started", + }; +} + +async function verifyPhone( + args: Record, + emitEvent: (event: StreamEvent) => void, + context?: ToolContext +): Promise { + // SECURITY FIX: Ensure context is present to track verification limits and cost + if (!context?.agentName) { + throw new Error("Unauthorized: Verification requires an initialized session context."); + } + + const phoneNumber = args.phone_number as string; + + emitEvent({ + type: "tool_status", + tool: "verify_phone", + status: "running", + message: `Sending verification code to ${phoneNumber}...`, + }); + + const response = await sapiomFetch("prelude", "/verifications", { + method: "POST", + body: { + target: { type: "phone_number", value: phoneNumber }, + }, + agentName: context.agentName, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + throw new Error( + `Failed to send verification code (${response.status}): ${errorText}` + ); + } + + const data = await response.json(); + + emitEvent({ + type: "tool_result", + tool: "verify_phone", + result: { verification_request_id: data.id }, + }); + + return { + needs_input: true, + step: "code", + verification_request_id: data.id, + message: `Enter the verification code sent to ${phoneNumber}`, + }; +} + +async function checkVerification( + args: Record, + emitEvent: (event: StreamEvent) => void, + context?: ToolContext +): Promise { + // SECURITY FIX: Explicitly require the context to authorize the code check + if (!context?.agentName) { + throw new Error("Unauthorized: Verification check requires an initialized session context."); + } + + const verificationRequestId = args.verification_request_id as string; + const code = args.code as string; + + emitEvent({ + type: "tool_status", + tool: "check_verification", + status: "running", + message: "Checking verification code...", + }); + + const response = await sapiomFetch("prelude", "/verifications/check", { + method: "POST", + body: { + verificationRequestId, + code, + }, + agentName: context.agentName, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + + emitEvent({ + type: "tool_status", + tool: "check_verification", + status: "error", + message: "Invalid verification code, try again", + }); + + return { error: `Invalid verification code: ${errorText}` }; + } + + const data = await response.json(); + + if (data.status !== "success") { + emitEvent({ + type: "tool_status", + tool: "check_verification", + status: "error", + message: "Invalid verification code, try again", + }); + + return { error: "Invalid verification code" }; + } + + // Set up agent + spending rule for this phone number + const phoneNumber = context.agentName; + emitEvent({ + type: "tool_status", + tool: "check_verification", + status: "running", + message: "Setting up spending limits...", + }); + + const MAX_RETRIES = 3; + let setupSucceeded = false; + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + await registerAgentWithSpendingRule(phoneNumber); + setupSucceeded = true; + break; + } catch (err) { + console.error(`Agent/rule setup attempt ${attempt} failed:`, err); + if (attempt < MAX_RETRIES) { + await new Promise((r) => setTimeout(r, 500 * attempt)); + } + } + } + + if (!setupSucceeded) { + emitEvent({ + type: "tool_status", + tool: "check_verification", + status: "error", + message: "Failed to set up spending limits. Please try again.", + }); + return { error: "Failed to set up spending limits after multiple attempts" }; + } + + const result = { + sessionId: data.id, + verified: true, + }; + + emitEvent({ + type: "tool_status", + tool: "check_verification", + status: "complete", + message: "Phone verified successfully", + }); + + emitEvent({ + type: "tool_result", + tool: "check_verification", + result, + }); + + return result; +} + +const ASPECT_RATIO_TO_IMAGE_SIZE: Record = { + "16:9": "landscape_16_9", + "1:1": "square_hd", + "9:16": "portrait_16_9", +}; + +async function generateImage( + args: Record, + emitEvent: (event: StreamEvent) => void, + context?: ToolContext +): Promise { + // SECURITY FIX: Enforce session binding before executing paid image generation tools + if (!context?.agentName) { + throw new Error("Unauthorized: Image generation requires an active verified session."); + } + + const prompt = args.prompt as string; + const aspectRatio = (args.aspect_ratio as string) ?? "16:9"; + const imageSize = ASPECT_RATIO_TO_IMAGE_SIZE[aspectRatio] ?? "landscape_16_9"; + + emitEvent({ + type: "tool_status", + tool: "generate_image", + status: "running", + message: "Generating image...", + }); + + const response = await sapiomFetch("fal", "/v1/run/fal-ai/flux/dev", { + method: "POST", + body: { prompt, image_size: imageSize, num_images: 1 }, + agentName: context.agentName, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + throw new Error(`Image generation failed (${response.status}): ${errorText}`); + } + + const data = await response.json(); + const image = data.images?.[0]; + + if (!image?.url) { + throw new Error("No image URL returned from generation service"); + } + + const result = { url: image.url, width: image.width, height: image.height }; + + emitEvent({ + type: "tool_status", + tool: "generate_image", + status: "complete", + }); + + emitEvent({ + type: "tool_result", + tool: "generate_image", + result, + }); + + return result; +} + +export const TOOL_EXECUTORS: Record = { + search_web: searchWeb, + request_phone_number: requestPhoneNumber, + verify_phone: verifyPhone, + check_verification: checkVerification, + generate_image: generateImage, +}; \ No newline at end of file