diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 644d7a5d..c5c8f429 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,7 +1,8 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { handleChatStream } from "@/lib/chat/handleChatStream"; +import { validateChatAuth } from "@/lib/chat/validateChatAuth"; +import { x402Chat } from "@/lib/x402/recoup/x402Chat"; /** * OPTIONS handler for CORS preflight requests. @@ -19,9 +20,13 @@ export async function OPTIONS() { * POST /api/chat * * Streaming chat endpoint that processes messages and returns a streaming response. + * All requests are routed through the x402 payment system, which: + * 1. Deducts credits from the account + * 2. Makes an on-chain USDC payment + * 3. Forwards to the x402-protected chat endpoint * - * Authentication: x-api-key header required. - * The account ID is inferred from the API key. + * Authentication: x-api-key or Authorization header required. + * The account ID is inferred from the authentication. * * Request body: * - messages: Array of chat messages (mutually exclusive with prompt) @@ -36,5 +41,60 @@ export async function OPTIONS() { * @returns A streaming response or error */ export async function POST(request: NextRequest): Promise { - return handleChatStream(request); + // Validate authentication and get accountId + const authResult = await validateChatAuth(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { body, accountId, orgId } = authResult; + + try { + // Build the chat body with resolved accountId + const chatBody = { + ...body, + accountId, + orgId, + messages: body.messages || [], + }; + + // Get the base URL for the x402 endpoint + const baseUrl = request.nextUrl.origin; + + // Route through x402 endpoint (handles credit deduction and payment) + const response = await x402Chat(chatBody, baseUrl); + + // Filter out CORS headers from internal response to avoid duplicates + const responseHeaders = Object.fromEntries(response.headers.entries()); + const filteredHeaders = Object.fromEntries( + Object.entries(responseHeaders).filter( + ([key]) => !key.toLowerCase().startsWith("access-control-"), + ), + ); + + // Return the streaming response with our CORS headers + // Add Content-Encoding: none to prevent proxy middleware from buffering the stream + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: { + ...filteredHeaders, + ...getCorsHeaders(), + "Content-Encoding": "none", + }, + }); + } catch (error) { + console.error("Error in /api/chat:", error); + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + return NextResponse.json( + { + status: "error", + message: errorMessage, + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } } diff --git a/app/api/x402/chat/route.ts b/app/api/x402/chat/route.ts new file mode 100644 index 00000000..fd089c53 --- /dev/null +++ b/app/api/x402/chat/route.ts @@ -0,0 +1,39 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleChatStreamX402 } from "@/lib/chat/handleChatStreamX402"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/x402/chat + * + * x402-protected streaming chat endpoint. Payment is verified by the x402 middleware + * before this handler is called. The accountId is passed in the request body and + * trusted because the caller paid via x402. + * + * Request body: + * - messages: Array of chat messages (mutually exclusive with prompt) + * - prompt: String prompt (mutually exclusive with messages) + * - roomId: Optional UUID of the chat room + * - artistId: Optional UUID of the artist account + * - accountId: The account ID of the user making the request + * - model: Optional model ID override + * - excludeTools: Optional array of tool names to exclude + * + * @param request - The request object + * @returns A streaming response or error + */ +export async function POST(request: NextRequest): Promise { + return handleChatStreamX402(request); +} diff --git a/lib/chat/__tests__/handleChatStream.test.ts b/lib/chat/__tests__/handleChatStream.test.ts deleted file mode 100644 index b29127dc..00000000 --- a/lib/chat/__tests__/handleChatStream.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { NextResponse } from "next/server"; - -// Mock all dependencies before importing the module under test -vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ - getApiKeyAccountId: vi.fn(), -})); - -vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ - getAuthenticatedAccountId: vi.fn(), -})); - -vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ - validateOverrideAccountId: vi.fn(), -})); - -vi.mock("@/lib/keys/getApiKeyDetails", () => ({ - getApiKeyDetails: vi.fn(), -})); - -vi.mock("@/lib/organizations/validateOrganizationAccess", () => ({ - validateOrganizationAccess: vi.fn(), -})); - -vi.mock("@/lib/chat/setupConversation", () => ({ - setupConversation: vi.fn().mockResolvedValue({ roomId: "mock-room-id", memoryId: "mock-memory-id" }), -})); - -vi.mock("@/lib/chat/validateMessages", () => ({ - validateMessages: vi.fn((messages) => ({ - lastMessage: messages[messages.length - 1] || { id: "mock-id", role: "user", parts: [] }, - validMessages: messages, - })), -})); - -vi.mock("@/lib/messages/convertToUiMessages", () => ({ - default: vi.fn((messages) => messages), -})); - -vi.mock("@/lib/chat/setupChatRequest", () => ({ - setupChatRequest: vi.fn(), -})); - -vi.mock("@/lib/chat/handleChatCompletion", () => ({ - handleChatCompletion: vi.fn(), -})); - -vi.mock("ai", () => ({ - createUIMessageStream: vi.fn(), - createUIMessageStreamResponse: vi.fn(), -})); - -import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; -import { setupChatRequest } from "@/lib/chat/setupChatRequest"; -import { setupConversation } from "@/lib/chat/setupConversation"; -import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; -import { handleChatStream } from "../handleChatStream"; - -const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); -const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId); -const mockSetupConversation = vi.mocked(setupConversation); -const mockSetupChatRequest = vi.mocked(setupChatRequest); -const mockCreateUIMessageStream = vi.mocked(createUIMessageStream); -const mockCreateUIMessageStreamResponse = vi.mocked(createUIMessageStreamResponse); - -// Helper to create mock NextRequest -function createMockRequest( - body: unknown, - headers: Record = {}, -): Request { - return { - json: () => Promise.resolve(body), - headers: { - get: (key: string) => headers[key.toLowerCase()] || null, - has: (key: string) => key.toLowerCase() in headers, - }, - } as unknown as Request; -} - -describe("handleChatStream", () => { - beforeEach(() => { - vi.clearAllMocks(); - // Re-setup mock return value after clearAllMocks - // Return the provided roomId if given, otherwise return mock-room-id - mockSetupConversation.mockImplementation(async ({ roomId }) => ({ - roomId: roomId || "mock-room-id", - memoryId: "mock-memory-id", - })); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe("validation", () => { - it("returns 400 error when neither messages nor prompt is provided", async () => { - mockGetApiKeyAccountId.mockResolvedValue("account-123"); - - const request = createMockRequest( - { roomId: "room-123" }, - { "x-api-key": "test-key" }, - ); - - const result = await handleChatStream(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(400); - const json = await result.json(); - expect(json.status).toBe("error"); - }); - - it("returns 401 error when no auth header is provided", async () => { - const request = createMockRequest({ prompt: "Hello" }, {}); - - const result = await handleChatStream(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(401); - const json = await result.json(); - expect(json.message).toBe("Exactly one of x-api-key or Authorization must be provided"); - }); - }); - - describe("streaming", () => { - it("creates a streaming response for valid requests", async () => { - mockGetApiKeyAccountId.mockResolvedValue("account-123"); - - const mockAgent = { - stream: vi.fn().mockResolvedValue({ - toUIMessageStream: vi.fn().mockReturnValue(new ReadableStream()), - usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), - }), - tools: {}, - }; - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - model: "gpt-4", - instructions: "You are a helpful assistant", - system: "You are a helpful assistant", - messages: [], - experimental_generateMessageId: vi.fn(), - tools: {}, - providerOptions: {}, - } as any); - - const mockStream = new ReadableStream(); - mockCreateUIMessageStream.mockReturnValue(mockStream); - - const mockResponse = new Response(mockStream); - mockCreateUIMessageStreamResponse.mockReturnValue(mockResponse); - - const request = createMockRequest( - { prompt: "Hello, world!" }, - { "x-api-key": "valid-key" }, - ); - - const result = await handleChatStream(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalled(); - expect(mockCreateUIMessageStream).toHaveBeenCalled(); - expect(mockCreateUIMessageStreamResponse).toHaveBeenCalledWith({ - stream: mockStream, - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS, PATCH", - "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Requested-With, x-api-key", - }, - }); - expect(result).toBe(mockResponse); - }); - - it("uses messages array when provided", async () => { - mockGetApiKeyAccountId.mockResolvedValue("account-123"); - - const mockAgent = { - stream: vi.fn().mockResolvedValue({ - toUIMessageStream: vi.fn().mockReturnValue(new ReadableStream()), - usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), - }), - tools: {}, - }; - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - model: "gpt-4", - instructions: "You are a helpful assistant", - system: "You are a helpful assistant", - messages: [], - experimental_generateMessageId: vi.fn(), - tools: {}, - providerOptions: {}, - } as any); - - const mockStream = new ReadableStream(); - mockCreateUIMessageStream.mockReturnValue(mockStream); - mockCreateUIMessageStreamResponse.mockReturnValue(new Response(mockStream)); - - const messages = [{ role: "user", content: "Hello" }]; - const request = createMockRequest( - { messages }, - { "x-api-key": "valid-key" }, - ); - - await handleChatStream(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalledWith( - expect.objectContaining({ - messages, - accountId: "account-123", - }), - ); - }); - - it("passes through optional parameters", async () => { - mockGetApiKeyAccountId.mockResolvedValue("account-123"); - - const mockAgent = { - stream: vi.fn().mockResolvedValue({ - toUIMessageStream: vi.fn().mockReturnValue(new ReadableStream()), - usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), - }), - tools: {}, - }; - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - model: "claude-3-opus", - instructions: "You are a helpful assistant", - system: "You are a helpful assistant", - messages: [], - experimental_generateMessageId: vi.fn(), - tools: {}, - providerOptions: {}, - } as any); - - const mockStream = new ReadableStream(); - mockCreateUIMessageStream.mockReturnValue(mockStream); - mockCreateUIMessageStreamResponse.mockReturnValue(new Response(mockStream)); - - const request = createMockRequest( - { - prompt: "Hello", - roomId: "room-xyz", - artistId: "artist-abc", - model: "claude-3-opus", - excludeTools: ["tool1"], - }, - { "x-api-key": "valid-key" }, - ); - - await handleChatStream(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalledWith( - expect.objectContaining({ - roomId: "room-xyz", - artistId: "artist-abc", - model: "claude-3-opus", - excludeTools: ["tool1"], - }), - ); - }); - }); - - describe("error handling", () => { - it("returns 500 error when setupChatRequest fails", async () => { - mockGetApiKeyAccountId.mockResolvedValue("account-123"); - mockSetupChatRequest.mockRejectedValue(new Error("Setup failed")); - - const request = createMockRequest( - { prompt: "Hello" }, - { "x-api-key": "valid-key" }, - ); - - const result = await handleChatStream(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(500); - const json = await result.json(); - expect(json.status).toBe("error"); - }); - }); - - describe("accountId override", () => { - it("allows org API key to override accountId", async () => { - mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); - mockValidateOverrideAccountId.mockResolvedValue({ - accountId: "target-account-456", - }); - - const mockAgent = { - stream: vi.fn().mockResolvedValue({ - toUIMessageStream: vi.fn().mockReturnValue(new ReadableStream()), - usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), - }), - tools: {}, - }; - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - model: "gpt-4", - instructions: "test", - system: "test", - messages: [], - experimental_generateMessageId: vi.fn(), - tools: {}, - providerOptions: {}, - } as any); - - const mockStream = new ReadableStream(); - mockCreateUIMessageStream.mockReturnValue(mockStream); - mockCreateUIMessageStreamResponse.mockReturnValue(new Response(mockStream)); - - const request = createMockRequest( - { prompt: "Hello", accountId: "target-account-456" }, - { "x-api-key": "org-api-key" }, - ); - - await handleChatStream(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalledWith( - expect.objectContaining({ - accountId: "target-account-456", - }), - ); - }); - }); -}); diff --git a/lib/chat/__tests__/validateChatAuth.test.ts b/lib/chat/__tests__/validateChatAuth.test.ts new file mode 100644 index 00000000..aaaed842 --- /dev/null +++ b/lib/chat/__tests__/validateChatAuth.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; +import { validateChatAuth } from "../validateChatAuth"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; +import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; + +// Mock dependencies +vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ + getApiKeyAccountId: vi.fn(), +})); + +vi.mock("@/lib/auth/getAuthenticatedAccountId", () => ({ + getAuthenticatedAccountId: vi.fn(), +})); + +vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ + validateOverrideAccountId: vi.fn(), +})); + +vi.mock("@/lib/keys/getApiKeyDetails", () => ({ + getApiKeyDetails: vi.fn(), +})); + +vi.mock("@/lib/organizations/validateOrganizationAccess", () => ({ + validateOrganizationAccess: vi.fn(), +})); + +const mockGetApiKeyAccountId = vi.mocked(getApiKeyAccountId); +const mockGetAuthenticatedAccountId = vi.mocked(getAuthenticatedAccountId); +const mockValidateOverrideAccountId = vi.mocked(validateOverrideAccountId); +const mockGetApiKeyDetails = vi.mocked(getApiKeyDetails); +const mockValidateOrganizationAccess = vi.mocked(validateOrganizationAccess); + +/** + * Helper to create mock NextRequest. + * + * @param body - The request body to mock. + * @param headers - The headers to include in the mock request. + * @returns A mock Request object. + */ +function createMockRequest(body: unknown, headers: Record = {}): Request { + return { + json: () => Promise.resolve(body), + headers: { + get: (key: string) => headers[key.toLowerCase()] || null, + has: (key: string) => key.toLowerCase() in headers, + }, + } as unknown as Request; +} + +describe("validateChatAuth", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("schema validation", () => { + it("accepts valid request with prompt", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "test-key" }); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).body.prompt).toBe("Hello"); + }); + + it("accepts valid request with messages", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const messages = [{ role: "user", content: "Hello" }]; + const request = createMockRequest({ messages }, { "x-api-key": "test-key" }); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).body.messages).toEqual(messages); + }); + + it("accepts valid request with optional fields", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-123"); + + const request = createMockRequest( + { + prompt: "Hello", + roomId: "room-123", + artistId: "artist-456", + model: "gpt-4", + excludeTools: ["tool1"], + }, + { "x-api-key": "test-key" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).body.roomId).toBe("room-123"); + expect((result as any).body.artistId).toBe("artist-456"); + expect((result as any).body.model).toBe("gpt-4"); + expect((result as any).body.excludeTools).toEqual(["tool1"]); + }); + }); + + describe("authentication", () => { + it("rejects request without any auth header", async () => { + const request = createMockRequest({ prompt: "Hello" }, {}); + + const result = await validateChatAuth(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Exactly one of x-api-key or Authorization must be provided"); + }); + + it("rejects request with both x-api-key and Authorization headers", async () => { + const request = createMockRequest( + { prompt: "Hello" }, + { "x-api-key": "test-key", authorization: "Bearer test-token" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Exactly one of x-api-key or Authorization must be provided"); + }); + + it("uses accountId from valid API key", async () => { + mockGetApiKeyAccountId.mockResolvedValue("account-abc-123"); + + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("account-abc-123"); + }); + + it("rejects request with invalid API key", async () => { + mockGetApiKeyAccountId.mockResolvedValue( + NextResponse.json({ status: "error", message: "Invalid API key" }, { status: 401 }), + ); + + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "invalid-key" }); + + const result = await validateChatAuth(request as any); + + expect(result).toBeInstanceOf(NextResponse); + }); + + it("accepts valid Authorization Bearer token", async () => { + mockGetAuthenticatedAccountId.mockResolvedValue("account-from-jwt-456"); + + const request = createMockRequest( + { prompt: "Hello" }, + { authorization: "Bearer valid-jwt-token" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("account-from-jwt-456"); + }); + + it("rejects request with invalid Authorization token", async () => { + mockGetAuthenticatedAccountId.mockResolvedValue( + NextResponse.json( + { status: "error", message: "Failed to verify authentication token" }, + { status: 401 }, + ), + ); + + const request = createMockRequest( + { prompt: "Hello" }, + { authorization: "Bearer invalid-token" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).toBeInstanceOf(NextResponse); + }); + }); + + describe("org context", () => { + it("returns orgId for org API key", async () => { + mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-account-123", + orgId: "org-account-123", + }); + + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "org-api-key" }); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).orgId).toBe("org-account-123"); + }); + + it("returns null orgId for personal API key", async () => { + mockGetApiKeyAccountId.mockResolvedValue("personal-account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "personal-account-123", + orgId: null, + }); + + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "personal-api-key" }); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).orgId).toBeNull(); + }); + + it("returns null orgId for bearer token auth", async () => { + mockGetAuthenticatedAccountId.mockResolvedValue("jwt-account-456"); + + const request = createMockRequest( + { prompt: "Hello" }, + { authorization: "Bearer valid-jwt-token" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).orgId).toBeNull(); + }); + }); + + describe("accountId override", () => { + it("allows org API key to override accountId", async () => { + mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); + mockValidateOverrideAccountId.mockResolvedValue({ + accountId: "target-account-456", + }); + + const request = createMockRequest( + { prompt: "Hello", accountId: "target-account-456" }, + { "x-api-key": "org-api-key" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("target-account-456"); + expect(mockValidateOverrideAccountId).toHaveBeenCalledWith({ + apiKey: "org-api-key", + targetAccountId: "target-account-456", + }); + }); + + it("rejects unauthorized accountId override", async () => { + mockGetApiKeyAccountId.mockResolvedValue("personal-account-123"); + mockValidateOverrideAccountId.mockResolvedValue( + NextResponse.json( + { status: "error", message: "Access denied to specified accountId" }, + { status: 403 }, + ), + ); + + const request = createMockRequest( + { prompt: "Hello", accountId: "target-account-456" }, + { "x-api-key": "personal-api-key" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Access denied to specified accountId"); + }); + }); + + describe("organizationId override", () => { + it("uses provided organizationId when user is member of org", async () => { + mockGetAuthenticatedAccountId.mockResolvedValue("user-account-123"); + mockValidateOrganizationAccess.mockResolvedValue(true); + + const request = createMockRequest( + { prompt: "Hello", organizationId: "org-456" }, + { authorization: "Bearer valid-jwt-token" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).orgId).toBe("org-456"); + expect(mockValidateOrganizationAccess).toHaveBeenCalledWith({ + accountId: "user-account-123", + organizationId: "org-456", + }); + }); + + it("rejects organizationId when user is NOT a member of org", async () => { + mockGetAuthenticatedAccountId.mockResolvedValue("user-account-123"); + mockValidateOrganizationAccess.mockResolvedValue(false); + + const request = createMockRequest( + { prompt: "Hello", organizationId: "org-not-member" }, + { authorization: "Bearer valid-jwt-token" }, + ); + + const result = await validateChatAuth(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Access denied to specified organizationId"); + }); + + it("uses API key orgId when no organizationId is provided", async () => { + mockGetApiKeyAccountId.mockResolvedValue("org-account-123"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-account-123", + orgId: "api-key-org-123", + }); + + const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "org-api-key" }); + + const result = await validateChatAuth(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).orgId).toBe("api-key-org-123"); + expect(mockValidateOrganizationAccess).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/lib/chat/__tests__/validateChatRequestX402.test.ts b/lib/chat/__tests__/validateChatRequestX402.test.ts new file mode 100644 index 00000000..1b810213 --- /dev/null +++ b/lib/chat/__tests__/validateChatRequestX402.test.ts @@ -0,0 +1,345 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; +import { validateChatRequestX402, chatRequestX402Schema } from "../validateChatRequestX402"; +import { setupConversation } from "@/lib/chat/setupConversation"; + +// Mock dependencies +vi.mock("@/lib/chat/setupConversation", () => ({ + setupConversation: vi.fn(), +})); + +vi.mock("@/lib/uuid/generateUUID", () => { + const mockFn = vi.fn(() => "mock-uuid-default"); + return { + generateUUID: mockFn, + default: mockFn, + }; +}); + +const mockSetupConversation = vi.mocked(setupConversation); + +/** + * Helper to create mock NextRequest. + * + * @param body - The request body to mock. + * @returns A mock Request object. + */ +function createMockRequest(body: unknown): Request { + return { + json: () => Promise.resolve(body), + headers: { + get: () => null, + has: () => false, + }, + } as unknown as Request; +} + +describe("validateChatRequestX402", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSetupConversation.mockResolvedValue({ + roomId: "mock-uuid-default", + memoryId: "mock-uuid-default", + }); + }); + + describe("schema validation", () => { + it("rejects when accountId is not provided", async () => { + const request = createMockRequest({ prompt: "Hello" }); + + const result = await validateChatRequestX402(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Invalid input"); + }); + + it("rejects when neither messages nor prompt is provided", async () => { + const request = createMockRequest({ accountId: "account-123" }); + + const result = await validateChatRequestX402(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Invalid input"); + }); + + it("rejects when both messages and prompt are provided", async () => { + const request = createMockRequest({ + accountId: "account-123", + messages: [{ role: "user", content: "Hello" }], + prompt: "Hello", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).toBeInstanceOf(NextResponse); + const json = await (result as NextResponse).json(); + expect(json.status).toBe("error"); + expect(json.message).toBe("Invalid input"); + }); + + it("accepts valid request with messages and accountId", async () => { + const request = createMockRequest({ + accountId: "account-123", + messages: [{ role: "user", content: "Hello" }], + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("account-123"); + }); + + it("accepts valid request with prompt and accountId", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello, world!", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("account-123"); + }); + }); + + describe("no authentication required", () => { + it("does not require x-api-key header", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + }); + + const result = await validateChatRequestX402(request as any); + + // Should succeed without auth headers because x402 payment is the auth + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("account-123"); + }); + + it("does not require Authorization header", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + }); + }); + + describe("accountId handling", () => { + it("uses accountId from request body", async () => { + const request = createMockRequest({ + accountId: "trusted-account-456", + prompt: "Hello", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).accountId).toBe("trusted-account-456"); + }); + + it("passes accountId to setupConversation", async () => { + const request = createMockRequest({ + accountId: "account-for-setup", + prompt: "Hello", + }); + + await validateChatRequestX402(request as any); + + expect(mockSetupConversation).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "account-for-setup", + }), + ); + }); + }); + + describe("organizationId handling", () => { + it("uses organizationId from request body without validation", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + organizationId: "org-456", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).orgId).toBe("org-456"); + }); + + it("sets orgId to null when organizationId is not provided", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).orgId).toBeNull(); + }); + }); + + describe("message normalization", () => { + it("converts prompt to messages array", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello, world!", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).messages).toHaveLength(1); + expect((result as any).messages[0].role).toBe("user"); + expect((result as any).messages[0].parts[0].text).toBe("Hello, world!"); + }); + + it("preserves original messages when provided", async () => { + const originalMessages = [ + { role: "user", content: "Hi" }, + { role: "assistant", content: "Hello!" }, + ]; + const request = createMockRequest({ + accountId: "account-123", + messages: originalMessages, + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).messages).toEqual(originalMessages); + }); + }); + + describe("conversation setup", () => { + it("calls setupConversation and returns roomId", async () => { + mockSetupConversation.mockResolvedValue({ + roomId: "generated-room-id", + memoryId: "memory-id", + }); + + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).roomId).toBe("generated-room-id"); + }); + + it("passes roomId to setupConversation when provided", async () => { + mockSetupConversation.mockResolvedValue({ + roomId: "existing-room-id", + memoryId: "memory-id", + }); + + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + roomId: "existing-room-id", + }); + + await validateChatRequestX402(request as any); + + expect(mockSetupConversation).toHaveBeenCalledWith( + expect.objectContaining({ + roomId: "existing-room-id", + }), + ); + }); + + it("passes artistId to setupConversation when provided", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + artistId: "artist-xyz", + }); + + await validateChatRequestX402(request as any); + + expect(mockSetupConversation).toHaveBeenCalledWith( + expect.objectContaining({ + artistId: "artist-xyz", + }), + ); + }); + }); + + describe("optional fields", () => { + it("passes through model selection", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + model: "gpt-4", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).model).toBe("gpt-4"); + }); + + it("passes through excludeTools array", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + excludeTools: ["tool1", "tool2"], + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).excludeTools).toEqual(["tool1", "tool2"]); + }); + }); + + describe("authToken handling", () => { + it("sets authToken to undefined since x402 payment is the auth", async () => { + const request = createMockRequest({ + accountId: "account-123", + prompt: "Hello", + }); + + const result = await validateChatRequestX402(request as any); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).authToken).toBeUndefined(); + }); + }); + + describe("chatRequestX402Schema", () => { + it("exports the schema for external validation", () => { + expect(chatRequestX402Schema).toBeDefined(); + const result = chatRequestX402Schema.safeParse({ + accountId: "account-123", + prompt: "test", + }); + expect(result.success).toBe(true); + }); + + it("schema requires accountId", () => { + const result = chatRequestX402Schema.safeParse({ prompt: "test" }); + expect(result.success).toBe(false); + }); + + it("schema enforces mutual exclusivity of messages and prompt", () => { + const result = chatRequestX402Schema.safeParse({ + accountId: "account-123", + messages: [{ role: "user", content: "test" }], + prompt: "test", + }); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/lib/chat/handleChatStreamX402.ts b/lib/chat/handleChatStreamX402.ts new file mode 100644 index 00000000..25c5afc7 --- /dev/null +++ b/lib/chat/handleChatStreamX402.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateChatRequestX402 } from "./validateChatRequestX402"; +import { streamChatResponse } from "./streamChatResponse"; + +/** + * Handles a streaming chat request for the x402-protected endpoint. + * + * This function: + * 1. Validates the request (body schema only - auth is handled by x402 payment) + * 2. Delegates to streamChatResponse for the actual streaming + * + * The accountId is passed in the request body and trusted because the caller + * has already paid via x402 payment verification. + * + * @param request - The incoming NextRequest + * @returns A streaming response or error NextResponse + */ +export async function handleChatStreamX402(request: NextRequest): Promise { + const validatedBodyOrError = await validateChatRequestX402(request); + if (validatedBodyOrError instanceof NextResponse) { + return validatedBodyOrError; + } + + return streamChatResponse(validatedBodyOrError, "/api/x402/chat"); +} diff --git a/lib/chat/handleChatStream.ts b/lib/chat/streamChatResponse.ts similarity index 50% rename from lib/chat/handleChatStream.ts rename to lib/chat/streamChatResponse.ts index fe971374..bad4411a 100644 --- a/lib/chat/handleChatStream.ts +++ b/lib/chat/streamChatResponse.ts @@ -1,29 +1,26 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; import { handleChatCompletion } from "./handleChatCompletion"; -import { validateChatRequest } from "./validateChatRequest"; import { setupChatRequest } from "./setupChatRequest"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import generateUUID from "@/lib/uuid/generateUUID"; +import type { ChatRequestBody } from "./validateChatRequest"; /** - * Handles a streaming chat request. + * Creates a streaming chat response from a validated request body. * - * This function: - * 1. Validates the request (auth, body schema) - * 2. Sets up the chat configuration (agent, model, tools) - * 3. Creates a streaming response using the AI SDK + * This is the core streaming logic shared by both: + * - handleChatStream (for /api/chat with auth validation) + * - handleChatStreamX402 (for /api/x402/chat with x402 payment validation) * - * @param request - The incoming NextRequest - * @returns A streaming response or error NextResponse + * @param body - The validated chat request body with accountId resolved. + * @param logPrefix - Optional prefix for error logs (default: "/api/chat"). + * @returns A streaming response or error NextResponse. */ -export async function handleChatStream(request: NextRequest): Promise { - const validatedBodyOrError = await validateChatRequest(request); - if (validatedBodyOrError instanceof NextResponse) { - return validatedBodyOrError; - } - const body = validatedBodyOrError; - +export async function streamChatResponse( + body: ChatRequestBody, + logPrefix: string = "/api/chat", +): Promise { try { const chatConfig = await setupChatRequest(body); const { agent } = chatConfig; @@ -31,26 +28,22 @@ export async function handleChatStream(request: NextRequest): Promise const stream = createUIMessageStream({ originalMessages: body.messages, generateId: generateUUID, - execute: async (options) => { + execute: async options => { const { writer } = options; const result = await agent.stream(chatConfig); writer.merge(result.toUIMessageStream()); - // Note: Credit handling and chat completion handling will be added - // as part of the handleChatCredits and handleChatCompletion migrations }, - onFinish: async (event) => { + onFinish: async event => { if (event.isAborted) { return; } - const assistantMessages = event.messages.filter( - (message) => message.role === "assistant", - ); + const assistantMessages = event.messages.filter(message => message.role === "assistant"); const responseMessages = assistantMessages.length > 0 ? assistantMessages : [event.responseMessage]; await handleChatCompletion(body, responseMessages); }, - onError: (e) => { - console.error("/api/chat onError:", e); + onError: e => { + console.error(`${logPrefix} onError:`, e); return JSON.stringify({ status: "error", message: e instanceof Error ? e.message : "Unknown error", @@ -58,9 +51,17 @@ export async function handleChatStream(request: NextRequest): Promise }, }); - return createUIMessageStreamResponse({ stream, headers: getCorsHeaders() }); + // Add Content-Encoding: none to prevent proxy middleware from buffering the stream + // See: https://ai-sdk.dev/docs/troubleshooting/streaming-not-working-when-proxied + return createUIMessageStreamResponse({ + stream, + headers: { + ...getCorsHeaders(), + "Content-Encoding": "none", + }, + }); } catch (e) { - console.error("/api/chat Global error:", e); + console.error(`${logPrefix} Global error:`, e); return NextResponse.json( { status: "error", diff --git a/lib/chat/validateChatAuth.ts b/lib/chat/validateChatAuth.ts new file mode 100644 index 00000000..acde97b9 --- /dev/null +++ b/lib/chat/validateChatAuth.ts @@ -0,0 +1,160 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; +import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; + +/** + * Basic schema for chat request body validation (auth phase only). + */ +const chatAuthSchema = z.object({ + prompt: z.string().optional(), + messages: z.array(z.any()).default([]), + roomId: z.string().optional(), + accountId: z.string().optional(), + artistId: z.string().optional(), + organizationId: z.string().optional(), + model: z.string().optional(), + excludeTools: z.array(z.string()).optional(), +}); + +export type ChatAuthBody = z.infer; + +export interface ChatAuthResult { + body: ChatAuthBody; + accountId: string; + orgId: string | null; +} + +/** + * Validates chat request authentication and returns the accountId. + * + * This function only handles: + * - Basic schema validation + * - Authentication (API key or Bearer token) + * - Account ID resolution + * - Organization access validation + * + * It does NOT handle conversation setup, message conversion, etc. + * Those are handled by the x402 endpoint. + * + * @param request - The NextRequest object + * @returns A NextResponse with an error or validated auth result + */ +export async function validateChatAuth( + request: NextRequest, +): Promise { + const json = await request.json(); + const validationResult = chatAuthSchema.safeParse(json); + + if (!validationResult.success) { + return NextResponse.json( + { + status: "error", + message: "Invalid input", + errors: validationResult.error.issues.map(err => ({ + field: err.path.join("."), + message: err.message, + })), + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const validatedBody = validationResult.data; + + // Check which auth mechanism is provided + const apiKey = request.headers.get("x-api-key"); + const authHeader = request.headers.get("authorization"); + const hasApiKey = !!apiKey; + const hasAuth = !!authHeader; + + // Enforce that exactly one auth mechanism is provided + if ((hasApiKey && hasAuth) || (!hasApiKey && !hasAuth)) { + return NextResponse.json( + { + status: "error", + message: "Exactly one of x-api-key or Authorization must be provided", + }, + { + status: 401, + headers: getCorsHeaders(), + }, + ); + } + + // Authenticate and get accountId and orgId + let accountId: string; + let orgId: string | null = null; + + if (hasApiKey) { + // Validate API key authentication + const accountIdOrError = await getApiKeyAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + accountId = accountIdOrError; + + // Get org context from API key details + const keyDetails = await getApiKeyDetails(apiKey!); + if (keyDetails) { + orgId = keyDetails.orgId; + } + + // Handle accountId override for org API keys + if (validatedBody.accountId) { + const overrideResult = await validateOverrideAccountId({ + apiKey, + targetAccountId: validatedBody.accountId, + }); + if (overrideResult instanceof NextResponse) { + return overrideResult; + } + accountId = overrideResult.accountId; + } + } else { + // Validate bearer token authentication (no org context for JWT auth) + const accountIdOrError = await getAuthenticatedAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + accountId = accountIdOrError; + } + + // Handle organizationId override from request body + if (validatedBody.organizationId) { + const hasOrgAccess = await validateOrganizationAccess({ + accountId, + organizationId: validatedBody.organizationId, + }); + + if (!hasOrgAccess) { + return NextResponse.json( + { + status: "error", + message: "Access denied to specified organizationId", + }, + { + status: 403, + headers: getCorsHeaders(), + }, + ); + } + + // Use the provided organizationId as orgId + orgId = validatedBody.organizationId; + } + + return { + body: validatedBody, + accountId, + orgId, + }; +} diff --git a/lib/chat/validateChatRequestX402.ts b/lib/chat/validateChatRequestX402.ts new file mode 100644 index 00000000..d309a65e --- /dev/null +++ b/lib/chat/validateChatRequestX402.ts @@ -0,0 +1,127 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getMessages } from "@/lib/messages/getMessages"; +import convertToUiMessages from "@/lib/messages/convertToUiMessages"; +import { setupConversation } from "@/lib/chat/setupConversation"; +import { validateMessages } from "@/lib/chat/validateMessages"; +import type { ChatRequestBody } from "./validateChatRequest"; + +/** + * Schema for x402-protected chat requests. + * Unlike the regular chat endpoint, accountId is required in the body + * since auth is handled by x402 payment verification. + */ +export const chatRequestX402Schema = z + .object({ + // Chat content + prompt: z.string().optional(), + messages: z.array(z.any()).default([]), + // Core routing / context fields + roomId: z.string().optional(), + accountId: z.string({ message: "accountId is required" }), + artistId: z.string().optional(), + organizationId: z.string().optional(), + model: z.string().optional(), + excludeTools: z.array(z.string()).optional(), + }) + .superRefine((data, ctx) => { + const hasMessages = Array.isArray(data.messages) && data.messages.length > 0; + const hasPrompt = typeof data.prompt === "string" && data.prompt.trim().length > 0; + + if ((hasMessages && hasPrompt) || (!hasMessages && !hasPrompt)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Exactly one of messages or prompt must be provided", + path: ["messages"], + }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Exactly one of messages or prompt must be provided", + path: ["prompt"], + }); + } + }); + +type BaseChatRequestX402Body = z.infer; + +/** + * Validates chat request body for x402-protected endpoint. + * + * Unlike the regular validateChatRequest, this function: + * - Does NOT validate authentication headers (x402 payment is the auth) + * - Requires accountId in the request body + * - Trusts the accountId because the caller has paid via x402 + * + * Returns: + * - NextResponse (400) when body is invalid + * - Parsed & augmented body when valid + * + * @param request - The NextRequest object + * @returns A NextResponse with an error or validated ChatRequestBody + */ +export async function validateChatRequestX402( + request: NextRequest, +): Promise { + const json = await request.json(); + const validationResult = chatRequestX402Schema.safeParse(json); + + if (!validationResult.success) { + return NextResponse.json( + { + status: "error", + message: "Invalid input", + errors: validationResult.error.issues.map(err => ({ + field: err.path.join("."), + message: err.message, + })), + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const validatedBody: BaseChatRequestX402Body = validationResult.data; + + // accountId is trusted because x402 payment was verified + const accountId = validatedBody.accountId; + + // organizationId can be passed but we don't validate access since x402 payment is the auth + const orgId = validatedBody.organizationId ?? null; + + // Normalize chat content: + // - If only prompt is provided, convert it into a single user UIMessage + // - Convert all messages to UIMessage format (handles mixed formats) + const hasMessages = Array.isArray(validatedBody.messages) && validatedBody.messages.length > 0; + const hasPrompt = + typeof validatedBody.prompt === "string" && validatedBody.prompt.trim().length > 0; + + if (!hasMessages && hasPrompt) { + validatedBody.messages = getMessages(validatedBody.prompt); + } + + // Convert messages to UIMessage format and get the last (newest) message + const uiMessages = convertToUiMessages(validatedBody.messages); + const { lastMessage } = validateMessages(uiMessages); + + // Setup conversation: auto-create room if needed and persist user message + const { roomId: finalRoomId } = await setupConversation({ + accountId, + roomId: validatedBody.roomId, + promptMessage: lastMessage, + artistId: validatedBody.artistId, + memoryId: lastMessage.id, + }); + + return { + ...validatedBody, + accountId, + orgId, + roomId: finalRoomId, + // No authToken for x402 - payment is the auth + authToken: undefined, + } as ChatRequestBody; +} diff --git a/lib/const.ts b/lib/const.ts index c5e01ef7..e635621f 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -9,6 +9,7 @@ export const SMART_ACCOUNT_ADDRESS = "0xbAf31935ED514e8F7da81D0A730AB5362DEEEEb7 export const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as Address; export const PAYMASTER_URL = `https://api.developer.coinbase.com/rpc/v1/base/${process.env.PAYMASTER_KEY}`; export const IMAGE_GENERATE_PRICE = "0.15"; +export const CHAT_PRICE = "0.01"; export const DEFAULT_MODEL = "openai/gpt-5-mini"; export const LIGHTWEIGHT_MODEL = "openai/gpt-4o-mini"; export const PRIVY_PROJECT_SECRET = process.env.PRIVY_PROJECT_SECRET; @@ -32,10 +33,4 @@ export const RECOUP_ORG_ID = "04e3aba9-c130-4fb8-8b92-34e95d43e66b"; // EVALS export const EVAL_ACCOUNT_ID = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; export const EVAL_ACCESS_TOKEN = process.env.EVAL_ACCESS_TOKEN || ""; -export const EVAL_ARTISTS = [ - "Gliiico", - "Mac Miller", - "Wiz Khalifa", - "Mod Sun", - "Julius Black", -]; +export const EVAL_ARTISTS = ["Gliiico", "Mac Miller", "Wiz Khalifa", "Mod Sun", "Julius Black"]; diff --git a/lib/x402/__tests__/fetchWithPaymentStream.test.ts b/lib/x402/__tests__/fetchWithPaymentStream.test.ts new file mode 100644 index 00000000..626d620c --- /dev/null +++ b/lib/x402/__tests__/fetchWithPaymentStream.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchWithPaymentStream } from "../fetchWithPaymentStream"; +import { getAccount } from "@/lib/coinbase/getAccount"; +import { deductCredits } from "@/lib/credits/deductCredits"; +import { loadAccount } from "../loadAccount"; +import { getCreditsForPrice } from "../getCreditsForPrice"; +import { CHAT_PRICE } from "@/lib/const"; + +// Import x402-fetch to mock wrapFetchWithPayment +import { wrapFetchWithPayment } from "x402-fetch"; + +// Mock dependencies +vi.mock("@/lib/coinbase/getAccount", () => ({ + getAccount: vi.fn(), +})); + +vi.mock("@/lib/credits/deductCredits", () => ({ + deductCredits: vi.fn(), +})); + +vi.mock("../loadAccount", () => ({ + loadAccount: vi.fn(), +})); + +vi.mock("../getCreditsForPrice", () => ({ + getCreditsForPrice: vi.fn(), +})); + +vi.mock("x402-fetch", () => ({ + wrapFetchWithPayment: vi.fn(() => vi.fn()), +})); + +vi.mock("viem/accounts", () => ({ + toAccount: vi.fn(account => account), +})); + +vi.mock("viem", () => ({ + parseUnits: vi.fn((value, decimals) => BigInt(Math.round(parseFloat(value) * 10 ** decimals))), +})); + +const mockGetAccount = vi.mocked(getAccount); +const mockDeductCredits = vi.mocked(deductCredits); +const mockLoadAccount = vi.mocked(loadAccount); +const mockGetCreditsForPrice = vi.mocked(getCreditsForPrice); +const mockWrapFetchWithPayment = vi.mocked(wrapFetchWithPayment); + +describe("fetchWithPaymentStream", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAccount.mockResolvedValue({ + address: "0x1234567890abcdef", + } as any); + mockDeductCredits.mockResolvedValue(undefined); + mockLoadAccount.mockResolvedValue(undefined); + mockGetCreditsForPrice.mockReturnValue(1); + }); + + it("gets account for the given accountId", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response("test")); + mockWrapFetchWithPayment.mockReturnValue(mockFetch); + + await fetchWithPaymentStream("https://example.com/api", "account-123", { data: "test" }); + + expect(mockGetAccount).toHaveBeenCalledWith("account-123"); + }); + + it("calculates credits to deduct based on price", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response("test")); + mockWrapFetchWithPayment.mockReturnValue(mockFetch); + + await fetchWithPaymentStream("https://example.com/api", "account-123", { data: "test" }); + + expect(mockGetCreditsForPrice).toHaveBeenCalledWith(CHAT_PRICE); + }); + + it("deducts credits from the account", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response("test")); + mockWrapFetchWithPayment.mockReturnValue(mockFetch); + mockGetCreditsForPrice.mockReturnValue(1); + + await fetchWithPaymentStream("https://example.com/api", "account-123", { data: "test" }); + + expect(mockDeductCredits).toHaveBeenCalledWith({ + accountId: "account-123", + creditsToDeduct: 1, + }); + }); + + it("loads the account wallet with correct price", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response("test")); + mockWrapFetchWithPayment.mockReturnValue(mockFetch); + mockGetAccount.mockResolvedValue({ + address: "0xWalletAddress123", + } as any); + + await fetchWithPaymentStream("https://example.com/api", "account-123", { data: "test" }); + + expect(mockLoadAccount).toHaveBeenCalledWith("0xWalletAddress123", CHAT_PRICE); + }); + + it("makes POST request with JSON body", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response("test")); + mockWrapFetchWithPayment.mockReturnValue(mockFetch); + + const body = { messages: [{ role: "user", content: "Hello" }] }; + await fetchWithPaymentStream("https://example.com/api", "account-123", body); + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/api", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + }); + + it("returns the response from the wrapped fetch", async () => { + const expectedResponse = new Response("streaming data", { + headers: { "Content-Type": "text/event-stream" }, + }); + const mockFetch = vi.fn().mockResolvedValue(expectedResponse); + mockWrapFetchWithPayment.mockReturnValue(mockFetch); + + const result = await fetchWithPaymentStream("https://example.com/api", "account-123", { + data: "test", + }); + + expect(result).toBe(expectedResponse); + }); + + it("uses custom price when provided", async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response("test")); + mockWrapFetchWithPayment.mockReturnValue(mockFetch); + mockGetAccount.mockResolvedValue({ + address: "0xCustomAddress", + } as any); + + await fetchWithPaymentStream( + "https://example.com/api", + "account-123", + { data: "test" }, + "0.05", + ); + + expect(mockGetCreditsForPrice).toHaveBeenCalledWith("0.05"); + expect(mockLoadAccount).toHaveBeenCalledWith("0xCustomAddress", "0.05"); + }); + + it("throws error if account retrieval fails", async () => { + mockGetAccount.mockRejectedValue(new Error("Account not found")); + + await expect( + fetchWithPaymentStream("https://example.com/api", "account-123", { data: "test" }), + ).rejects.toThrow("Account not found"); + }); + + it("throws error if credit deduction fails", async () => { + mockDeductCredits.mockRejectedValue(new Error("Insufficient credits")); + + await expect( + fetchWithPaymentStream("https://example.com/api", "account-123", { data: "test" }), + ).rejects.toThrow("Insufficient credits"); + }); + + it("throws error if account loading fails", async () => { + mockLoadAccount.mockRejectedValue(new Error("Failed to load account")); + + await expect( + fetchWithPaymentStream("https://example.com/api", "account-123", { data: "test" }), + ).rejects.toThrow("Failed to load account"); + }); +}); diff --git a/lib/x402/__tests__/loadAccount.test.ts b/lib/x402/__tests__/loadAccount.test.ts new file mode 100644 index 00000000..83324394 --- /dev/null +++ b/lib/x402/__tests__/loadAccount.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { loadAccount } from "../loadAccount"; +import { sendUserOpAndWait } from "@/lib/coinbase/sendUserOpAndWait"; +import { getTransferCalls } from "@/lib/x402/getTransferCalls"; +import { IMAGE_GENERATE_PRICE, CHAT_PRICE } from "@/lib/const"; + +// Mock dependencies +vi.mock("@/lib/coinbase/sendUserOpAndWait", () => ({ + sendUserOpAndWait: vi.fn(), +})); + +vi.mock("@/lib/x402/getTransferCalls", () => ({ + getTransferCalls: vi.fn(), +})); + +const mockSendUserOpAndWait = vi.mocked(sendUserOpAndWait); +const mockGetTransferCalls = vi.mocked(getTransferCalls); + +describe("loadAccount", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSendUserOpAndWait.mockResolvedValue("0xTransactionHash123"); + mockGetTransferCalls.mockReturnValue([{ to: "0x123", data: "0x" }] as never); + }); + + it("sends USDC to the recipient address", async () => { + const recipientAddress = "0xRecipient123" as `0x${string}`; + + await loadAccount(recipientAddress, IMAGE_GENERATE_PRICE); + + expect(mockGetTransferCalls).toHaveBeenCalledWith(recipientAddress, IMAGE_GENERATE_PRICE); + expect(mockSendUserOpAndWait).toHaveBeenCalled(); + }); + + it("uses the provided price for the transfer", async () => { + const recipientAddress = "0xRecipient456" as `0x${string}`; + + await loadAccount(recipientAddress, CHAT_PRICE); + + expect(mockGetTransferCalls).toHaveBeenCalledWith(recipientAddress, CHAT_PRICE); + }); + + it("returns the transaction hash", async () => { + mockSendUserOpAndWait.mockResolvedValue("0xSuccessHash"); + const recipientAddress = "0xRecipient789" as `0x${string}`; + + const result = await loadAccount(recipientAddress, IMAGE_GENERATE_PRICE); + + expect(result).toBe("0xSuccessHash"); + }); + + it("throws error when sendUserOpAndWait fails", async () => { + mockSendUserOpAndWait.mockRejectedValue(new Error("Transaction failed")); + const recipientAddress = "0xRecipientFail" as `0x${string}`; + + await expect(loadAccount(recipientAddress, IMAGE_GENERATE_PRICE)).rejects.toThrow( + "Failed to load account and send USDC: Transaction failed", + ); + }); + + it("passes different prices correctly", async () => { + const recipientAddress = "0xRecipient" as `0x${string}`; + + // Test with image price + await loadAccount(recipientAddress, "0.15"); + expect(mockGetTransferCalls).toHaveBeenLastCalledWith(recipientAddress, "0.15"); + + // Test with chat price + await loadAccount(recipientAddress, "0.01"); + expect(mockGetTransferCalls).toHaveBeenLastCalledWith(recipientAddress, "0.01"); + + // Test with custom price + await loadAccount(recipientAddress, "0.50"); + expect(mockGetTransferCalls).toHaveBeenLastCalledWith(recipientAddress, "0.50"); + }); +}); diff --git a/lib/x402/fetchWithPayment.ts b/lib/x402/fetchWithPayment.ts index 8a518aa0..a1958351 100644 --- a/lib/x402/fetchWithPayment.ts +++ b/lib/x402/fetchWithPayment.ts @@ -18,7 +18,7 @@ export async function fetchWithPayment(url: string, accountId: string): Promise< const account = await getAccount(accountId); const creditsToDeduct = getCreditsForPrice(IMAGE_GENERATE_PRICE); await deductCredits({ accountId, creditsToDeduct }); - await loadAccount(account.address); + await loadAccount(account.address, IMAGE_GENERATE_PRICE); const fetchWithPaymentWrapper = wrapFetchWithPayment( fetch, toAccount(account), diff --git a/lib/x402/fetchWithPaymentStream.ts b/lib/x402/fetchWithPaymentStream.ts new file mode 100644 index 00000000..303b3d50 --- /dev/null +++ b/lib/x402/fetchWithPaymentStream.ts @@ -0,0 +1,48 @@ +import { wrapFetchWithPayment } from "x402-fetch"; +import { toAccount } from "viem/accounts"; +import { getAccount } from "@/lib/coinbase/getAccount"; +import { deductCredits } from "../credits/deductCredits"; +import { loadAccount } from "./loadAccount"; +import { getCreditsForPrice } from "./getCreditsForPrice"; +import { CHAT_PRICE } from "@/lib/const"; +import { parseUnits } from "viem"; + +/** + * Fetches a URL with x402 payment handling for POST requests with streaming response. + * + * This function: + * 1. Gets the account for the given accountId + * 2. Deducts credits from the account + * 3. Loads the account wallet and sends USDC for the payment + * 4. Makes the x402-authenticated request + * 5. Returns the streaming response + * + * @param url - The URL to fetch. + * @param accountId - The account ID. + * @param body - The request body to send. + * @param price - The price for the request (defaults to CHAT_PRICE). + * @returns Promise resolving to the Response (streaming). + */ +export async function fetchWithPaymentStream( + url: string, + accountId: string, + body: unknown, + price: string = CHAT_PRICE, +): Promise { + const account = await getAccount(accountId); + const creditsToDeduct = getCreditsForPrice(price); + await deductCredits({ accountId, creditsToDeduct }); + await loadAccount(account.address, price); + const fetchWithPaymentWrapper = wrapFetchWithPayment( + fetch, + toAccount(account), + parseUnits(price, 6), + ); + return fetchWithPaymentWrapper(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); +} diff --git a/lib/x402/loadAccount.ts b/lib/x402/loadAccount.ts index 80736ddf..220db697 100644 --- a/lib/x402/loadAccount.ts +++ b/lib/x402/loadAccount.ts @@ -1,17 +1,17 @@ import { sendUserOpAndWait } from "@/lib/coinbase/sendUserOpAndWait"; import { getTransferCalls } from "@/lib/x402/getTransferCalls"; import type { Address } from "viem"; -import { IMAGE_GENERATE_PRICE } from "@/lib/const"; /** * Loads an account, gets or creates a smart account, and sends USDC to the specified address. * * @param recipientAddress - The address to send USDC to. + * @param price - The price in USDC to send (e.g., "0.01" for $0.01). * @returns Promise resolving to the transaction hash. */ -export async function loadAccount(recipientAddress: Address): Promise { +export async function loadAccount(recipientAddress: Address, price: string): Promise { try { - const calls = getTransferCalls(recipientAddress, IMAGE_GENERATE_PRICE); + const calls = getTransferCalls(recipientAddress, price); const transactionHash = await sendUserOpAndWait(calls); diff --git a/lib/x402/recoup/__tests__/x402Chat.test.ts b/lib/x402/recoup/__tests__/x402Chat.test.ts new file mode 100644 index 00000000..bd36d2d9 --- /dev/null +++ b/lib/x402/recoup/__tests__/x402Chat.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { x402Chat } from "../x402Chat"; +import { fetchWithPaymentStream } from "../../fetchWithPaymentStream"; + +// Mock fetchWithPaymentStream +vi.mock("../../fetchWithPaymentStream", () => ({ + fetchWithPaymentStream: vi.fn(), +})); + +const mockFetchWithPaymentStream = vi.mocked(fetchWithPaymentStream); + +describe("x402Chat", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchWithPaymentStream.mockResolvedValue( + new Response("streaming response", { + headers: { "Content-Type": "text/event-stream" }, + }), + ); + }); + + it("calls fetchWithPaymentStream with correct URL", async () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + orgId: null, + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + "https://api.example.com/api/x402/chat", + "account-123", + expect.any(Object), + ); + }); + + it("includes messages and accountId in request body", async () => { + const body = { + messages: [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi!" }, + ], + accountId: "account-123", + orgId: null, + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + expect.any(String), + "account-123", + expect.objectContaining({ + messages: body.messages, + accountId: "account-123", + }), + ); + }); + + it("includes optional prompt in request body", async () => { + const body = { + messages: [], + prompt: "Hello, world!", + accountId: "account-123", + orgId: null, + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + expect.any(String), + "account-123", + expect.objectContaining({ + prompt: "Hello, world!", + }), + ); + }); + + it("includes optional roomId in request body", async () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + roomId: "room-456", + orgId: null, + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + expect.any(String), + "account-123", + expect.objectContaining({ + roomId: "room-456", + }), + ); + }); + + it("includes optional artistId in request body", async () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + artistId: "artist-789", + orgId: null, + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + expect.any(String), + "account-123", + expect.objectContaining({ + artistId: "artist-789", + }), + ); + }); + + it("includes organizationId when orgId is provided", async () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + orgId: "org-456", + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + expect.any(String), + "account-123", + expect.objectContaining({ + organizationId: "org-456", + }), + ); + }); + + it("does not include organizationId when orgId is null", async () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + orgId: null, + } as any; + + await x402Chat(body, "https://api.example.com"); + + const [, , requestBody] = mockFetchWithPaymentStream.mock.calls[0]; + expect(requestBody).not.toHaveProperty("organizationId"); + }); + + it("includes optional model in request body", async () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + model: "gpt-4", + orgId: null, + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + expect.any(String), + "account-123", + expect.objectContaining({ + model: "gpt-4", + }), + ); + }); + + it("includes optional excludeTools in request body", async () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + excludeTools: ["tool1", "tool2"], + orgId: null, + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + expect.any(String), + "account-123", + expect.objectContaining({ + excludeTools: ["tool1", "tool2"], + }), + ); + }); + + it("returns the streaming response", async () => { + const expectedResponse = new Response("streaming data", { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + mockFetchWithPaymentStream.mockResolvedValue(expectedResponse); + + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + orgId: null, + } as any; + + const result = await x402Chat(body, "https://api.example.com"); + + expect(result).toBe(expectedResponse); + }); + + it("propagates errors from fetchWithPaymentStream", async () => { + mockFetchWithPaymentStream.mockRejectedValue(new Error("Payment failed")); + + const body = { + messages: [{ role: "user", content: "Hello" }], + accountId: "account-123", + orgId: null, + } as any; + + await expect(x402Chat(body, "https://api.example.com")).rejects.toThrow("Payment failed"); + }); + + it("handles all optional fields together", async () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + prompt: "Additional prompt", + accountId: "account-123", + roomId: "room-456", + artistId: "artist-789", + orgId: "org-abc", + model: "gpt-4", + excludeTools: ["tool1"], + } as any; + + await x402Chat(body, "https://api.example.com"); + + expect(mockFetchWithPaymentStream).toHaveBeenCalledWith( + "https://api.example.com/api/x402/chat", + "account-123", + { + messages: body.messages, + prompt: "Additional prompt", + accountId: "account-123", + roomId: "room-456", + artistId: "artist-789", + organizationId: "org-abc", + model: "gpt-4", + excludeTools: ["tool1"], + }, + ); + }); +}); diff --git a/lib/x402/recoup/x402Chat.ts b/lib/x402/recoup/x402Chat.ts new file mode 100644 index 00000000..4ff65eaa --- /dev/null +++ b/lib/x402/recoup/x402Chat.ts @@ -0,0 +1,61 @@ +import { fetchWithPaymentStream } from "../fetchWithPaymentStream"; +import type { ChatRequestBody } from "@/lib/chat/validateChatRequest"; + +/** + * Request body for the x402 chat endpoint. + * Similar to ChatRequestBody but with accountId required. + */ +export interface X402ChatRequestBody { + messages: unknown[]; + prompt?: string; + roomId?: string; + accountId: string; + artistId?: string; + organizationId?: string; + model?: string; + excludeTools?: string[]; +} + +/** + * Calls the x402-protected chat endpoint with payment. + * + * This function: + * 1. Deducts credits from the account + * 2. Makes the x402 payment (USDC transfer) + * 3. Forwards the request to the x402 chat endpoint + * 4. Returns the streaming response + * + * @param body - The validated chat request body. + * @param baseUrl - The base URL for the API. + * @returns Promise resolving to the streaming Response. + */ +export async function x402Chat(body: ChatRequestBody, baseUrl: string): Promise { + const x402Url = new URL("/api/x402/chat", baseUrl); + + // Build the request body for the x402 endpoint + const x402Body: X402ChatRequestBody = { + messages: body.messages, + accountId: body.accountId, + }; + + if (body.prompt) { + x402Body.prompt = body.prompt; + } + if (body.roomId) { + x402Body.roomId = body.roomId; + } + if (body.artistId) { + x402Body.artistId = body.artistId; + } + if (body.orgId) { + x402Body.organizationId = body.orgId; + } + if (body.model) { + x402Body.model = body.model; + } + if (body.excludeTools) { + x402Body.excludeTools = body.excludeTools; + } + + return fetchWithPaymentStream(x402Url.toString(), body.accountId, x402Body); +} diff --git a/middleware.ts b/middleware.ts index 0f844aab..8109535d 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,8 +1,8 @@ import { facilitator } from "@coinbase/x402"; import { paymentMiddleware } from "x402-next"; -import { IMAGE_GENERATE_PRICE, SMART_ACCOUNT_ADDRESS } from "./lib/const"; +import { CHAT_PRICE, IMAGE_GENERATE_PRICE, SMART_ACCOUNT_ADDRESS } from "./lib/const"; -const inputSchema = { +const imageInputSchema = { queryParams: { prompt: "Text prompt describing the image to generate", files: @@ -10,6 +10,20 @@ const inputSchema = { }, }; +const chatInputSchema = { + bodyType: "json" as const, + bodyFields: { + messages: + "Array of chat messages in the format { role: 'user' | 'assistant', content: string }", + prompt: "Alternative to messages - a simple string prompt (mutually exclusive with messages)", + roomId: "Optional UUID of the chat room for conversation continuity", + artistId: "Optional UUID of the artist account for context", + accountId: "The account ID of the user making the request", + model: "Optional model ID override", + excludeTools: "Optional array of tool names to exclude", + }, +}; + // Match the image generation endpoint schema const imageGenerateOutputSchema = { type: "object" as const, @@ -52,6 +66,18 @@ const imageGenerateOutputSchema = { }, }; +// Chat endpoint output schema (streaming response) +const chatOutputSchema = { + type: "object" as const, + description: "Streaming chat response with AI-generated messages", + properties: { + stream: { + type: "string" as const, + description: "Server-sent events stream containing chat messages and tool results", + }, + }, +}; + export const middleware = paymentMiddleware( SMART_ACCOUNT_ADDRESS, { @@ -59,10 +85,20 @@ export const middleware = paymentMiddleware( price: `$${IMAGE_GENERATE_PRICE}`, network: "base", config: { - discoverable: true, // make endpoint discoverable + discoverable: true, description: "Generate an image from a text prompt using AI", outputSchema: imageGenerateOutputSchema, - inputSchema, + inputSchema: imageInputSchema, + }, + }, + "POST /api/x402/chat": { + price: `$${CHAT_PRICE}`, + network: "base", + config: { + discoverable: true, + description: "Chat with an AI agent that can use tools to help with tasks", + outputSchema: chatOutputSchema, + inputSchema: chatInputSchema, }, }, }, @@ -75,7 +111,8 @@ export const middleware = paymentMiddleware( ); // Configure which paths the middleware should run on +// Only run x402 middleware on x402-protected routes to avoid interfering with CORS preflight export const config = { - matcher: ["/protected/:path*", "/api/:path*"], + matcher: ["/protected/:path*", "/api/x402/:path*"], runtime: "nodejs", };