diff --git a/CLAUDE.md b/CLAUDE.md index a64148a9..1649398e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,25 @@ export async function selectTableName({ - All API routes should have JSDoc comments - Run `pnpm lint` before committing +### Terminology + +Use **"account"** terminology, never "entity" or "user". All entities in the system (individuals, artists, workspaces, organizations) are "accounts". When referring to specific types, use the specific name: + +- ✅ `account_id`, "artist", "workspace", "organization" +- ❌ `entity_id`, "entity", "user" + +### API Response Shapes + +Keep response bodies **flat** — put fields at the root level, not nested inside a `data` wrapper: + +```typescript +// ✅ Correct — flat response +{ success: true, connectors: [...] } + +// ❌ Wrong — unnecessary nesting +{ success: true, data: { connectors: [...] } } +``` + ## Test-Driven Development (TDD) **CRITICAL: Always write tests BEFORE implementing new features or fixing bugs.** diff --git a/app/api/connectors/authorize/route.ts b/app/api/connectors/authorize/route.ts deleted file mode 100644 index 1d539a12..00000000 --- a/app/api/connectors/authorize/route.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { authorizeConnector } from "@/lib/composio/connectors"; -import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; -import { validateAuthorizeConnectorBody } from "@/lib/composio/connectors/validateAuthorizeConnectorBody"; - -/** - * OPTIONS handler for CORS preflight requests. - */ -export async function OPTIONS() { - return new NextResponse(null, { - status: 200, - headers: getCorsHeaders(), - }); -} - -/** - * POST /api/connectors/authorize - * - * Generate an OAuth authorization URL for a specific connector. - * - * Authentication: x-api-key OR Authorization Bearer token required. - * The account ID is inferred from the auth header. - * - * Request body: - * - connector: The connector slug, e.g., "googlesheets" (required) - * - callback_url: Optional custom callback URL after OAuth - * - * @returns The redirect URL for OAuth authorization - */ -export async function POST(request: NextRequest): Promise { - const headers = getCorsHeaders(); - - try { - const authResult = await validateAccountIdHeaders(request); - if (authResult instanceof NextResponse) { - return authResult; - } - - const { accountId } = authResult; - const body = await request.json(); - - const validated = validateAuthorizeConnectorBody(body); - if (validated instanceof NextResponse) { - return validated; - } - - const { connector, callback_url } = validated; - const result = await authorizeConnector(accountId, connector, callback_url); - - return NextResponse.json( - { - success: true, - data: { - connector: result.connector, - redirectUrl: result.redirectUrl, - }, - }, - { status: 200, headers }, - ); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to authorize connector"; - return NextResponse.json({ error: message }, { status: 500, headers }); - } -} diff --git a/app/api/connectors/route.ts b/app/api/connectors/route.ts index bdafa4e0..9a20d83a 100644 --- a/app/api/connectors/route.ts +++ b/app/api/connectors/route.ts @@ -1,11 +1,9 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getConnectors } from "@/lib/composio/connectors"; -import { disconnectConnector } from "@/lib/composio/connectors/disconnectConnector"; -import { validateDisconnectConnectorBody } from "@/lib/composio/connectors/validateDisconnectConnectorBody"; -import { verifyConnectorOwnership } from "@/lib/composio/connectors/verifyConnectorOwnership"; -import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders"; +import { getConnectorsHandler } from "@/lib/composio/connectors/getConnectorsHandler"; +import { authorizeConnectorHandler } from "@/lib/composio/connectors/authorizeConnectorHandler"; +import { disconnectConnectorHandler } from "@/lib/composio/connectors/disconnectConnectorHandler"; /** * OPTIONS handler for CORS preflight requests. @@ -20,39 +18,37 @@ export async function OPTIONS() { /** * GET /api/connectors * - * List all available connectors and their connection status for a user. + * List all available connectors and their connection status. + * + * Query params: + * - account_id (optional): Entity ID for entity-specific connections (e.g., artist ID) * * Authentication: x-api-key OR Authorization Bearer token required. * + * @param request * @returns List of connectors with connection status */ -export async function GET(request: NextRequest): Promise { - const headers = getCorsHeaders(); - - try { - const authResult = await validateAccountIdHeaders(request); - if (authResult instanceof NextResponse) { - return authResult; - } - - const { accountId } = authResult; - - const connectors = await getConnectors(accountId); +export async function GET(request: NextRequest) { + return getConnectorsHandler(request); +} - return NextResponse.json( - { - success: true, - data: { - connectors, - }, - }, - { status: 200, headers }, - ); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to fetch connectors"; - return NextResponse.json({ error: message }, { status: 500, headers }); - } +/** + * POST /api/connectors + * + * Generate an OAuth authorization URL for a specific connector. + * + * Authentication: x-api-key OR Authorization Bearer token required. + * + * Request body: + * - connector: The connector slug, e.g., "googlesheets" or "tiktok" (required) + * - callback_url: Optional custom callback URL after OAuth + * - account_id: Optional account ID for account-specific connections + * + * @param request + * @returns The redirect URL for OAuth authorization + */ +export async function POST(request: NextRequest) { + return authorizeConnectorHandler(request); } /** @@ -60,50 +56,14 @@ export async function GET(request: NextRequest): Promise { * * Disconnect a connected account from Composio. * + * Body: + * - connected_account_id (required): The connected account ID to disconnect + * - account_id (optional): Entity ID for ownership verification (e.g., artist ID) + * * Authentication: x-api-key OR Authorization Bearer token required. * - * Body: { connected_account_id: string } + * @param request */ -export async function DELETE(request: NextRequest): Promise { - const headers = getCorsHeaders(); - - try { - const authResult = await validateAccountIdHeaders(request); - if (authResult instanceof NextResponse) { - return authResult; - } - - const { accountId } = authResult; - const body = await request.json(); - - const validated = validateDisconnectConnectorBody(body); - if (validated instanceof NextResponse) { - return validated; - } - - const { connected_account_id } = validated; - - // Verify the connected account belongs to the authenticated user - const isOwner = await verifyConnectorOwnership(accountId, connected_account_id); - if (!isOwner) { - return NextResponse.json( - { error: "Connected account not found or does not belong to this user" }, - { status: 403, headers } - ); - } - - const result = await disconnectConnector(connected_account_id); - - return NextResponse.json( - { - success: true, - data: result, - }, - { status: 200, headers }, - ); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to disconnect connector"; - return NextResponse.json({ error: message }, { status: 500, headers }); - } +export async function DELETE(request: NextRequest) { + return disconnectConnectorHandler(request); } diff --git a/lib/artists/__tests__/checkAccountArtistAccess.test.ts b/lib/artists/__tests__/checkAccountArtistAccess.test.ts new file mode 100644 index 00000000..1bb0b253 --- /dev/null +++ b/lib/artists/__tests__/checkAccountArtistAccess.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { checkAccountArtistAccess } from "../checkAccountArtistAccess"; + +vi.mock("@/lib/supabase/account_artist_ids/selectAccountArtistId", () => ({ + selectAccountArtistId: vi.fn(), +})); + +vi.mock("@/lib/supabase/artist_organization_ids/selectArtistOrganizationIds", () => ({ + selectArtistOrganizationIds: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_organization_ids/selectAccountOrganizationIds", () => ({ + selectAccountOrganizationIds: vi.fn(), +})); + +import { selectAccountArtistId } from "@/lib/supabase/account_artist_ids/selectAccountArtistId"; +import { selectArtistOrganizationIds } from "@/lib/supabase/artist_organization_ids/selectArtistOrganizationIds"; +import { selectAccountOrganizationIds } from "@/lib/supabase/account_organization_ids/selectAccountOrganizationIds"; + +describe("checkAccountArtistAccess", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return true when account has direct access to artist", async () => { + vi.mocked(selectAccountArtistId).mockResolvedValue({ artist_id: "artist-123" }); + + const result = await checkAccountArtistAccess("account-123", "artist-123"); + + expect(selectAccountArtistId).toHaveBeenCalledWith("account-123", "artist-123"); + expect(result).toBe(true); + expect(selectArtistOrganizationIds).not.toHaveBeenCalled(); + }); + + it("should return true when account and artist share an organization", async () => { + vi.mocked(selectAccountArtistId).mockResolvedValue(null); + vi.mocked(selectArtistOrganizationIds).mockResolvedValue([ + { organization_id: "org-1" }, + ]); + vi.mocked(selectAccountOrganizationIds).mockResolvedValue([ + { organization_id: "org-1" }, + ]); + + const result = await checkAccountArtistAccess("account-123", "artist-456"); + + expect(selectArtistOrganizationIds).toHaveBeenCalledWith("artist-456"); + expect(selectAccountOrganizationIds).toHaveBeenCalledWith("account-123", ["org-1"]); + expect(result).toBe(true); + }); + + it("should return false when artist org lookup errors (fail closed)", async () => { + vi.mocked(selectAccountArtistId).mockResolvedValue(null); + vi.mocked(selectArtistOrganizationIds).mockResolvedValue(null); + + const result = await checkAccountArtistAccess("account-123", "artist-123"); + + expect(result).toBe(false); + }); + + it("should return false when account has no access", async () => { + vi.mocked(selectAccountArtistId).mockResolvedValue(null); + vi.mocked(selectArtistOrganizationIds).mockResolvedValue([]); + + const result = await checkAccountArtistAccess("account-123", "artist-456"); + + expect(result).toBe(false); + expect(selectAccountOrganizationIds).not.toHaveBeenCalled(); + }); + + it("should return false when account org lookup errors (fail closed)", async () => { + vi.mocked(selectAccountArtistId).mockResolvedValue(null); + vi.mocked(selectArtistOrganizationIds).mockResolvedValue([ + { organization_id: "org-1" }, + ]); + vi.mocked(selectAccountOrganizationIds).mockResolvedValue(null); + + const result = await checkAccountArtistAccess("account-123", "artist-456"); + + expect(result).toBe(false); + }); +}); diff --git a/lib/artists/checkAccountArtistAccess.ts b/lib/artists/checkAccountArtistAccess.ts new file mode 100644 index 00000000..a0b8defe --- /dev/null +++ b/lib/artists/checkAccountArtistAccess.ts @@ -0,0 +1,44 @@ +import { selectAccountArtistId } from "@/lib/supabase/account_artist_ids/selectAccountArtistId"; +import { selectArtistOrganizationIds } from "@/lib/supabase/artist_organization_ids/selectArtistOrganizationIds"; +import { selectAccountOrganizationIds } from "@/lib/supabase/account_organization_ids/selectAccountOrganizationIds"; + +/** + * Check if an account has access to a specific artist. + * + * Access is granted if: + * 1. Account has direct access via account_artist_ids, OR + * 2. Account and artist share an organization + * + * Fails closed: returns false on any database error to deny access safely. + * + * @param accountId - The account ID to check + * @param artistId - The artist ID to check access for + * @returns true if the account has access to the artist, false otherwise + */ +export async function checkAccountArtistAccess( + accountId: string, + artistId: string, +): Promise { + // 1. Check direct access via account_artist_ids + const directAccess = await selectAccountArtistId(accountId, artistId); + + if (directAccess) return true; + + // 2. Check organization access: account and artist share an org + const artistOrgs = await selectArtistOrganizationIds(artistId); + + if (!artistOrgs) return false; // Fail closed on error + + if (!artistOrgs.length) return false; + + const orgIds = artistOrgs + .map((o) => o.organization_id) + .filter((id): id is string => Boolean(id)); + if (!orgIds.length) return false; + + const userOrgAccess = await selectAccountOrganizationIds(accountId, orgIds); + + if (!userOrgAccess) return false; // Fail closed on error + + return !!userOrgAccess.length; +} diff --git a/lib/auth/checkAccountAccess.ts b/lib/auth/checkAccountAccess.ts new file mode 100644 index 00000000..4ebda208 --- /dev/null +++ b/lib/auth/checkAccountAccess.ts @@ -0,0 +1,71 @@ +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; +import { selectAccountWorkspaceId } from "@/lib/supabase/account_workspace_ids/selectAccountWorkspaceId"; +import { validateOrganizationAccess } from "@/lib/organizations/validateOrganizationAccess"; + +/** + * The type of entity the target account_id represents relative to the caller. + * + * - "self": The caller's own account + * - "artist": An artist the caller manages + * - "workspace": A workspace the caller owns + * - "organization": An organization the caller belongs to + */ +export type AccountEntityType = "self" | "artist" | "workspace" | "organization"; + +/** + * Result of checking account access. + */ +export interface CheckAccountAccessResult { + hasAccess: boolean; + /** The entity type of the target account (only set when hasAccess is true). */ + entityType?: AccountEntityType; +} + +/** + * Check if an authenticated account can access a target account. + * + * Tries all access paths in order: + * 1. Self-access (target === caller) + * 2. Artist access (via account_artist_ids or shared org) + * 3. Workspace access (via account_workspace_ids) + * 4. Organization access (caller is a member of the target org) + * + * Returns the first match with the entity type, or { hasAccess: false } if none. + * Fails closed: any database error results in denied access. + * + * @param authenticatedAccountId - The caller's account ID + * @param targetAccountId - The account ID being accessed + * @returns Access result with entity type + */ +export async function checkAccountAccess( + authenticatedAccountId: string, + targetAccountId: string, +): Promise { + // 1. Self-access — the caller is accessing their own account + if (targetAccountId === authenticatedAccountId) { + return { hasAccess: true, entityType: "self" }; + } + + // 2. Artist access — target is an artist the caller manages + const isArtist = await checkAccountArtistAccess(authenticatedAccountId, targetAccountId); + if (isArtist) { + return { hasAccess: true, entityType: "artist" }; + } + + // 3. Workspace access — target is a workspace the caller owns + const isWorkspace = await selectAccountWorkspaceId(authenticatedAccountId, targetAccountId); + if (isWorkspace) { + return { hasAccess: true, entityType: "workspace" }; + } + + // 4. Organization access — target is an org the caller belongs to + const isOrg = await validateOrganizationAccess({ + accountId: authenticatedAccountId, + organizationId: targetAccountId, + }); + if (isOrg) { + return { hasAccess: true, entityType: "organization" }; + } + + return { hasAccess: false }; +} diff --git a/lib/chat/__tests__/setupToolsForRequest.test.ts b/lib/chat/__tests__/setupToolsForRequest.test.ts index 15522f62..eab54888 100644 --- a/lib/chat/__tests__/setupToolsForRequest.test.ts +++ b/lib/chat/__tests__/setupToolsForRequest.test.ts @@ -81,18 +81,33 @@ describe("setupToolsForRequest", () => { }); describe("Composio tools integration", () => { - it("calls getComposioTools with accountId and roomId", async () => { + it("calls getComposioTools with accountId, artistId, and roomId", async () => { const body: ChatRequestBody = { accountId: "account-123", orgId: null, authToken: "test-token-123", + artistId: "artist-789", roomId: "room-456", messages: [{ id: "1", role: "user", content: "Create a spreadsheet" }], }; await setupToolsForRequest(body); - expect(mockGetComposioTools).toHaveBeenCalledWith("account-123", "room-456"); + expect(mockGetComposioTools).toHaveBeenCalledWith("account-123", "artist-789", "room-456"); + }); + + it("passes undefined artistId when not provided", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + orgId: null, + authToken: "test-token-123", + roomId: "room-456", + messages: [{ id: "1", role: "user", content: "Create a spreadsheet" }], + }; + + await setupToolsForRequest(body); + + expect(mockGetComposioTools).toHaveBeenCalledWith("account-123", undefined, "room-456"); }); it("includes Composio tools in result", async () => { diff --git a/lib/chat/setupToolsForRequest.ts b/lib/chat/setupToolsForRequest.ts index 77b1747d..ba586e19 100644 --- a/lib/chat/setupToolsForRequest.ts +++ b/lib/chat/setupToolsForRequest.ts @@ -8,18 +8,18 @@ import { getComposioTools } from "@/lib/composio/toolRouter"; * Sets up and filters tools for a chat request. * Aggregates tools from: * - MCP server (via HTTP transport to /api/mcp for proper auth) - * - Composio Tool Router (Google Sheets, Google Drive, Google Docs) + * - Composio Tool Router (Google Sheets, Google Drive, Google Docs, TikTok) * * @param body - The chat request body * @returns Filtered tool set ready for use */ export async function setupToolsForRequest(body: ChatRequestBody): Promise { - const { accountId, roomId, excludeTools, authToken } = body; + const { accountId, artistId, roomId, excludeTools, authToken } = body; // Fetch MCP tools and Composio tools in parallel - they're independent const [mcpTools, composioTools] = await Promise.all([ authToken ? getMcpTools(authToken) : Promise.resolve({}), - getComposioTools(accountId, roomId), + getComposioTools(accountId, artistId, roomId), ]); // Merge all tools diff --git a/lib/composio/connectors/__tests__/authorizeConnector.test.ts b/lib/composio/connectors/__tests__/authorizeConnector.test.ts new file mode 100644 index 00000000..1e380949 --- /dev/null +++ b/lib/composio/connectors/__tests__/authorizeConnector.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { authorizeConnector } from "../authorizeConnector"; + +import { getComposioClient } from "../../client"; +import { getCallbackUrl } from "../../getCallbackUrl"; + +vi.mock("../../client", () => ({ + getComposioClient: vi.fn(), +})); + +vi.mock("../../getCallbackUrl", () => ({ + getCallbackUrl: vi.fn(() => "https://app.example.com/settings/connectors?connected=true"), +})); + +describe("authorizeConnector", () => { + const mockAuthorize = vi.fn(); + const mockCreate = vi.fn(); + const mockClient = { + create: mockCreate, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockCreate.mockResolvedValue({ authorize: mockAuthorize }); + mockAuthorize.mockResolvedValue({ redirectUrl: "https://oauth.example.com/auth" }); + vi.mocked(getComposioClient).mockResolvedValue(mockClient as never); + }); + + it("should generate OAuth URL for connector", async () => { + const result = await authorizeConnector("account-123", "googlesheets"); + + expect(mockCreate).toHaveBeenCalledWith("account-123", { + manageConnections: { + callbackUrl: "https://app.example.com/settings/connectors?connected=true", + }, + }); + expect(mockAuthorize).toHaveBeenCalledWith("googlesheets"); + expect(result).toEqual({ + connector: "googlesheets", + redirectUrl: "https://oauth.example.com/auth", + }); + }); + + it("should use connectors destination by default", async () => { + await authorizeConnector("account-123", "googlesheets"); + + expect(getCallbackUrl).toHaveBeenCalledWith({ destination: "connectors" }); + }); + + it("should use custom callback URL when provided", async () => { + const customUrl = "https://custom.example.com/callback"; + await authorizeConnector("account-123", "googlesheets", { customCallbackUrl: customUrl }); + + expect(mockCreate).toHaveBeenCalledWith("account-123", { + manageConnections: { + callbackUrl: customUrl, + }, + }); + // getCallbackUrl should not be called when custom URL is provided + expect(getCallbackUrl).not.toHaveBeenCalled(); + }); + + it("should include auth configs when provided", async () => { + const authConfigs = { tiktok: "ac_12345" }; + await authorizeConnector("account-456", "tiktok", { authConfigs }); + + expect(mockCreate).toHaveBeenCalledWith("account-456", { + authConfigs, + manageConnections: { + callbackUrl: "https://app.example.com/settings/connectors?connected=true", + }, + }); + }); + + it("should not include authConfigs if empty object", async () => { + await authorizeConnector("account-123", "googlesheets", { authConfigs: {} }); + + expect(mockCreate).toHaveBeenCalledWith("account-123", { + manageConnections: { + callbackUrl: "https://app.example.com/settings/connectors?connected=true", + }, + }); + }); +}); diff --git a/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts b/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts new file mode 100644 index 00000000..f6fce402 --- /dev/null +++ b/lib/composio/connectors/__tests__/authorizeConnectorHandler.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { authorizeConnectorHandler } from "../authorizeConnectorHandler"; + +import { validateAuthorizeConnectorRequest } from "../validateAuthorizeConnectorRequest"; +import { authorizeConnector } from "../authorizeConnector"; + +vi.mock("../validateAuthorizeConnectorRequest", () => ({ + validateAuthorizeConnectorRequest: vi.fn(), +})); + +vi.mock("../authorizeConnector", () => ({ + authorizeConnector: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +describe("authorizeConnectorHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return validation error if validation fails", async () => { + vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue( + NextResponse.json({ error: "Invalid request" }, { status: 400 }), + ); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "POST", + }); + const result = await authorizeConnectorHandler(request); + + expect(result.status).toBe(400); + }); + + it("should call authorizeConnector with validated params for account connection", async () => { + vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ + accountId: "account-123", + connector: "googlesheets", + }); + vi.mocked(authorizeConnector).mockResolvedValue({ + connector: "googlesheets", + redirectUrl: "https://oauth.example.com/auth", + }); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "POST", + }); + const result = await authorizeConnectorHandler(request); + + expect(authorizeConnector).toHaveBeenCalledWith("account-123", "googlesheets", { + customCallbackUrl: undefined, + authConfigs: undefined, + }); + expect(result.status).toBe(200); + const body = await result.json(); + expect(body.success).toBe(true); + expect(body.data.redirectUrl).toBe("https://oauth.example.com/auth"); + }); + + it("should call authorizeConnector with authConfigs for account with authConfigs", async () => { + vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ + accountId: "account-456", + connector: "tiktok", + authConfigs: { tiktok: "ac_123" }, + }); + vi.mocked(authorizeConnector).mockResolvedValue({ + connector: "tiktok", + redirectUrl: "https://oauth.example.com/auth", + }); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "POST", + }); + await authorizeConnectorHandler(request); + + expect(authorizeConnector).toHaveBeenCalledWith("account-456", "tiktok", { + customCallbackUrl: undefined, + authConfigs: { tiktok: "ac_123" }, + }); + }); + + it("should pass through custom callbackUrl", async () => { + vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ + accountId: "account-123", + connector: "googlesheets", + callbackUrl: "https://custom.example.com/callback", + }); + vi.mocked(authorizeConnector).mockResolvedValue({ + connector: "googlesheets", + redirectUrl: "https://oauth.example.com/auth", + }); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "POST", + }); + await authorizeConnectorHandler(request); + + expect(authorizeConnector).toHaveBeenCalledWith("account-123", "googlesheets", { + customCallbackUrl: "https://custom.example.com/callback", + authConfigs: undefined, + }); + }); + + it("should return 500 on error", async () => { + vi.mocked(validateAuthorizeConnectorRequest).mockResolvedValue({ + accountId: "account-123", + connector: "googlesheets", + }); + vi.mocked(authorizeConnector).mockRejectedValue(new Error("OAuth failed")); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "POST", + }); + const result = await authorizeConnectorHandler(request); + + expect(result.status).toBe(500); + const body = await result.json(); + expect(body.error).toBe("OAuth failed"); + }); +}); diff --git a/lib/composio/connectors/__tests__/disconnectConnector.test.ts b/lib/composio/connectors/__tests__/disconnectConnector.test.ts new file mode 100644 index 00000000..fb6fe698 --- /dev/null +++ b/lib/composio/connectors/__tests__/disconnectConnector.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { disconnectConnector } from "../disconnectConnector"; + +import { getConnectors } from "../getConnectors"; + +vi.mock("../../getComposioApiKey", () => ({ + getComposioApiKey: vi.fn(() => "test-api-key"), +})); + +vi.mock("../getConnectors", () => ({ + getConnectors: vi.fn(), +})); + +describe("disconnectConnector", () => { + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = vi.fn(); + }); + + it("should disconnect connector successfully", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + } as Response); + + const result = await disconnectConnector("ca_12345"); + + expect(global.fetch).toHaveBeenCalledWith( + "https://backend.composio.dev/api/v3/connected_accounts/ca_12345", + { + method: "DELETE", + headers: { + "x-api-key": "test-api-key", + "Content-Type": "application/json", + }, + }, + ); + expect(result).toEqual({ success: true }); + }); + + it("should throw when API returns error", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + status: 404, + text: () => Promise.resolve("Not found"), + } as Response); + + await expect(disconnectConnector("ca_12345")).rejects.toThrow( + "Failed to disconnect (404): Not found", + ); + }); + + it("should verify ownership before disconnecting when requested", async () => { + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "tiktok", name: "TikTok", isConnected: true, connectedAccountId: "ca_12345" }, + ]); + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + } as Response); + + await disconnectConnector("ca_12345", { + verifyOwnershipFor: "artist-456", + }); + + expect(getConnectors).toHaveBeenCalledWith("artist-456"); + expect(global.fetch).toHaveBeenCalled(); + }); + + it("should throw when ownership verification fails", async () => { + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "tiktok", name: "TikTok", isConnected: true, connectedAccountId: "ca_different" }, + ]); + + await expect( + disconnectConnector("ca_12345", { + verifyOwnershipFor: "artist-456", + }), + ).rejects.toThrow("Connection not found for this account"); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("should throw when account has no connections", async () => { + vi.mocked(getConnectors).mockResolvedValue([]); + + await expect( + disconnectConnector("ca_12345", { + verifyOwnershipFor: "artist-456", + }), + ).rejects.toThrow("Connection not found for this account"); + }); +}); diff --git a/lib/composio/connectors/__tests__/disconnectConnectorHandler.test.ts b/lib/composio/connectors/__tests__/disconnectConnectorHandler.test.ts new file mode 100644 index 00000000..e315420e --- /dev/null +++ b/lib/composio/connectors/__tests__/disconnectConnectorHandler.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { disconnectConnectorHandler } from "../disconnectConnectorHandler"; + +import { validateDisconnectConnectorRequest } from "../validateDisconnectConnectorRequest"; +import { disconnectConnector } from "../disconnectConnector"; + +vi.mock("../validateDisconnectConnectorRequest", () => ({ + validateDisconnectConnectorRequest: vi.fn(), +})); + +vi.mock("../disconnectConnector", () => ({ + disconnectConnector: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +describe("disconnectConnectorHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return validation error if validation fails", async () => { + vi.mocked(validateDisconnectConnectorRequest).mockResolvedValue( + NextResponse.json({ error: "Invalid request" }, { status: 400 }), + ); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + }); + const result = await disconnectConnectorHandler(request); + + expect(result.status).toBe(400); + }); + + it("should call disconnectConnector without options when no targetAccountId", async () => { + vi.mocked(validateDisconnectConnectorRequest).mockResolvedValue({ + connectedAccountId: "ca_123", + }); + vi.mocked(disconnectConnector).mockResolvedValue(undefined); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + }); + const result = await disconnectConnectorHandler(request); + + expect(disconnectConnector).toHaveBeenCalledWith("ca_123"); + expect(result.status).toBe(200); + const body = await result.json(); + expect(body.success).toBe(true); + }); + + it("should call disconnectConnector with verifyOwnershipFor when targetAccountId provided", async () => { + vi.mocked(validateDisconnectConnectorRequest).mockResolvedValue({ + connectedAccountId: "ca_123", + targetAccountId: "account-456", + }); + vi.mocked(disconnectConnector).mockResolvedValue(undefined); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + }); + const result = await disconnectConnectorHandler(request); + + expect(disconnectConnector).toHaveBeenCalledWith("ca_123", { + verifyOwnershipFor: "account-456", + }); + expect(result.status).toBe(200); + }); + + it("should return 500 on error", async () => { + vi.mocked(validateDisconnectConnectorRequest).mockResolvedValue({ + connectedAccountId: "ca_123", + }); + vi.mocked(disconnectConnector).mockRejectedValue(new Error("Disconnect failed")); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + }); + const result = await disconnectConnectorHandler(request); + + expect(result.status).toBe(500); + const body = await result.json(); + expect(body.error).toBe("Disconnect failed"); + }); +}); diff --git a/lib/composio/connectors/__tests__/getConnectors.test.ts b/lib/composio/connectors/__tests__/getConnectors.test.ts new file mode 100644 index 00000000..1ba00ecc --- /dev/null +++ b/lib/composio/connectors/__tests__/getConnectors.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getConnectors } from "../getConnectors"; + +import { getComposioClient } from "../../client"; + +vi.mock("../../client", () => ({ + getComposioClient: vi.fn(), +})); + +describe("getConnectors", () => { + const mockToolkits = vi.fn(); + const mockSession = { toolkits: mockToolkits }; + const mockComposio = { create: vi.fn(() => mockSession) }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getComposioClient).mockResolvedValue(mockComposio); + }); + + it("should return connectors list with connection status", async () => { + mockToolkits.mockResolvedValue({ + items: [ + { + slug: "googlesheets", + name: "Google Sheets", + connection: { isActive: true, connectedAccount: { id: "ca_123" } }, + }, + { + slug: "googledrive", + name: "Google Drive", + connection: null, + }, + ], + }); + + const result = await getConnectors("account-123"); + + expect(getComposioClient).toHaveBeenCalled(); + expect(mockComposio.create).toHaveBeenCalledWith("account-123"); + expect(result).toEqual([ + { + slug: "googlesheets", + name: "Google Sheets", + isConnected: true, + connectedAccountId: "ca_123", + }, + { + slug: "googledrive", + name: "Google Drive", + isConnected: false, + connectedAccountId: undefined, + }, + ]); + }); + + it("should use custom display names when provided", async () => { + mockToolkits.mockResolvedValue({ + items: [ + { + slug: "tiktok", + name: "tiktok", + connection: null, + }, + ], + }); + + const result = await getConnectors("account-123", { + displayNames: { tiktok: "TikTok" }, + }); + + expect(result[0].name).toBe("TikTok"); + }); + + it("should handle inactive connections", async () => { + mockToolkits.mockResolvedValue({ + items: [ + { + slug: "googlesheets", + name: "Google Sheets", + connection: { isActive: false, connectedAccount: { id: "ca_123" } }, + }, + ], + }); + + const result = await getConnectors("account-123"); + + expect(result[0].isConnected).toBe(false); + }); +}); diff --git a/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts b/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts new file mode 100644 index 00000000..c214bf71 --- /dev/null +++ b/lib/composio/connectors/__tests__/getConnectorsHandler.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getConnectorsHandler } from "../getConnectorsHandler"; + +import { validateGetConnectorsRequest } from "../validateGetConnectorsRequest"; +import { getConnectors } from "../getConnectors"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +vi.mock("../validateGetConnectorsRequest", () => ({ + validateGetConnectorsRequest: vi.fn(), +})); + +vi.mock("../getConnectors", () => ({ + getConnectors: vi.fn(), +})); + +describe("getConnectorsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return validation error when request validation fails", async () => { + vi.mocked(validateGetConnectorsRequest).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const request = new NextRequest("http://localhost/api/connectors"); + const result = await getConnectorsHandler(request); + + expect(result.status).toBe(401); + }); + + it("should return connectors list for account", async () => { + vi.mocked(validateGetConnectorsRequest).mockResolvedValue({ + accountId: "account-123", + }); + + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "googlesheets", name: "Google Sheets", isConnected: true }, + { slug: "googledrive", name: "Google Drive", isConnected: false }, + ]); + + const request = new NextRequest("http://localhost/api/connectors"); + const result = await getConnectorsHandler(request); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body.success).toBe(true); + expect(body.connectors).toHaveLength(2); + expect(body.connectors[0].slug).toBe("googlesheets"); + }); + + it("should fetch all connectors for any account (no filtering)", async () => { + vi.mocked(validateGetConnectorsRequest).mockResolvedValue({ + accountId: "account-456", + }); + + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "tiktok", name: "TikTok", isConnected: true }, + ]); + + const request = new NextRequest( + "http://localhost/api/connectors?account_id=account-456", + ); + await getConnectorsHandler(request); + + // API is unopinionated — no allowedToolkits filtering + expect(getConnectors).toHaveBeenCalledWith("account-456", { + displayNames: { + tiktok: "TikTok", + googlesheets: "Google Sheets", + googledrive: "Google Drive", + googledocs: "Google Docs", + }, + }); + }); + + it("should return 500 when getConnectors throws", async () => { + vi.mocked(validateGetConnectorsRequest).mockResolvedValue({ + accountId: "account-123", + }); + + vi.mocked(getConnectors).mockRejectedValue(new Error("Composio API error")); + + const request = new NextRequest("http://localhost/api/connectors"); + const result = await getConnectorsHandler(request); + const body = await result.json(); + + expect(result.status).toBe(500); + expect(body.error).toBe("Composio API error"); + }); +}); diff --git a/lib/composio/connectors/__tests__/isAllowedArtistConnector.test.ts b/lib/composio/connectors/__tests__/isAllowedArtistConnector.test.ts new file mode 100644 index 00000000..f0b223b0 --- /dev/null +++ b/lib/composio/connectors/__tests__/isAllowedArtistConnector.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { isAllowedArtistConnector, ALLOWED_ARTIST_CONNECTORS } from "../isAllowedArtistConnector"; + +describe("isAllowedArtistConnector", () => { + it("should return true for 'tiktok'", () => { + expect(isAllowedArtistConnector("tiktok")).toBe(true); + }); + + it("should return false for connectors not in ALLOWED_ARTIST_CONNECTORS", () => { + expect(isAllowedArtistConnector("googlesheets")).toBe(false); + expect(isAllowedArtistConnector("googledrive")).toBe(false); + expect(isAllowedArtistConnector("instagram")).toBe(false); + expect(isAllowedArtistConnector("random")).toBe(false); + }); + + it("should return false for empty string", () => { + expect(isAllowedArtistConnector("")).toBe(false); + }); + + it("should be case-sensitive", () => { + expect(isAllowedArtistConnector("TikTok")).toBe(false); + expect(isAllowedArtistConnector("TIKTOK")).toBe(false); + }); +}); + +describe("ALLOWED_ARTIST_CONNECTORS", () => { + it("should include tiktok", () => { + expect(ALLOWED_ARTIST_CONNECTORS).toContain("tiktok"); + }); + + it("should be a readonly array", () => { + expect(Array.isArray(ALLOWED_ARTIST_CONNECTORS)).toBe(true); + }); +}); diff --git a/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts b/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts new file mode 100644 index 00000000..593cbc69 --- /dev/null +++ b/lib/composio/connectors/__tests__/validateAuthorizeConnectorBody.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; +import { validateAuthorizeConnectorBody } from "../validateAuthorizeConnectorBody"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +describe("validateAuthorizeConnectorBody", () => { + it("should accept valid connector request without account_id", () => { + const result = validateAuthorizeConnectorBody({ + connector: "googlesheets", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + connector: "googlesheets", + }); + }); + + it("should accept valid connector request with account_id for allowed connector", () => { + const result = validateAuthorizeConnectorBody({ + connector: "tiktok", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + connector: "tiktok", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + }); + + it("should accept optional callback_url", () => { + const result = validateAuthorizeConnectorBody({ + connector: "googlesheets", + callback_url: "https://example.com/callback", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + connector: "googlesheets", + callback_url: "https://example.com/callback", + }); + }); + + it("should return 400 with field name when connector is missing", async () => { + const result = validateAuthorizeConnectorBody({}); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.missing_fields).toEqual(["connector"]); + expect(body.error).toBe("connector is required"); + }); + + it("should return 400 when connector is empty", () => { + const result = validateAuthorizeConnectorBody({ connector: "" }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should accept any connector with account_id (restriction enforced at request level)", () => { + // Connector restriction for artists is now checked in validateAuthorizeConnectorRequest + // after the entity type is determined, not at the body validation level. + const result = validateAuthorizeConnectorBody({ + connector: "googlesheets", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + connector: "googlesheets", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + }); + + it("should return 400 for invalid callback_url format", () => { + const result = validateAuthorizeConnectorBody({ + connector: "googlesheets", + callback_url: "not-a-valid-url", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should return 400 for invalid account_id UUID format", () => { + const result = validateAuthorizeConnectorBody({ + connector: "tiktok", + account_id: "not-a-uuid", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); +}); diff --git a/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts new file mode 100644 index 00000000..87966100 --- /dev/null +++ b/lib/composio/connectors/__tests__/validateAuthorizeConnectorRequest.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateAuthorizeConnectorRequest } from "../validateAuthorizeConnectorRequest"; + +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/auth/checkAccountAccess", () => ({ + checkAccountAccess: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +describe("validateAuthorizeConnectorRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return error if auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ connector: "googlesheets" }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(401); + }); + + it("should return accountId as accountId when no account_id", async () => { + const mockAccountId = "account-123"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ connector: "googlesheets" }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: mockAccountId, + connector: "googlesheets", + callbackUrl: undefined, + }); + }); + + it("should allow tiktok for artist account_id", async () => { + const mockAccountId = "account-123"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "artist" }); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ connector: "tiktok", account_id: mockTargetAccountId }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(checkAccountAccess).toHaveBeenCalledWith(mockAccountId, mockTargetAccountId); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: mockTargetAccountId, + connector: "tiktok", + callbackUrl: undefined, + authConfigs: undefined, + }); + }); + + it("should allow any connector for any account type (unopinionated)", async () => { + const mockAccountId = "account-123"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "artist" }); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ connector: "googlesheets", account_id: mockTargetAccountId }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + // API is unopinionated — artists can connect any service + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: mockTargetAccountId, + connector: "googlesheets", + callbackUrl: undefined, + authConfigs: undefined, + }); + }); + + it("should allow any connector for workspace account_id", async () => { + const mockAccountId = "account-123"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "workspace" }); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ connector: "googlesheets", account_id: mockTargetAccountId }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: mockTargetAccountId, + connector: "googlesheets", + callbackUrl: undefined, + authConfigs: undefined, + }); + }); + + it("should allow any connector for organization account_id", async () => { + const mockAccountId = "account-123"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "organization" }); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ connector: "googlesheets", account_id: mockTargetAccountId }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: mockTargetAccountId, + connector: "googlesheets", + callbackUrl: undefined, + authConfigs: undefined, + }); + }); + + it("should return 403 when account_id provided but no access", async () => { + const mockAccountId = "account-123"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: false }); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ connector: "tiktok", account_id: mockTargetAccountId }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); + + it("should include TikTok auth config when connector is tiktok and env var is set", async () => { + const mockAccountId = "account-123"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; + const originalEnv = process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID; + process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID = "ac_test123"; + + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "artist" }); + + const request = new NextRequest("http://localhost/api/connectors/authorize", { + method: "POST", + body: JSON.stringify({ connector: "tiktok", account_id: mockTargetAccountId }), + }); + const result = await validateAuthorizeConnectorRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as { authConfigs?: Record }).authConfigs).toEqual({ + tiktok: "ac_test123", + }); + + process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID = originalEnv; + }); +}); diff --git a/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts b/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts new file mode 100644 index 00000000..5f6f2382 --- /dev/null +++ b/lib/composio/connectors/__tests__/validateDisconnectConnectorBody.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; +import { validateDisconnectConnectorBody } from "../validateDisconnectConnectorBody"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +describe("validateDisconnectConnectorBody", () => { + it("should accept valid disconnect request without account_id", () => { + const result = validateDisconnectConnectorBody({ + connected_account_id: "ca_12345", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + connected_account_id: "ca_12345", + }); + }); + + it("should accept valid disconnect request with account_id", () => { + const result = validateDisconnectConnectorBody({ + connected_account_id: "ca_12345", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + connected_account_id: "ca_12345", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + }); + + it("should return 400 with field name when connected_account_id is missing", async () => { + const result = validateDisconnectConnectorBody({}); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.missing_fields).toEqual(["connected_account_id"]); + expect(body.error).toBe("connected_account_id is required"); + }); + + it("should return 400 when connected_account_id is empty", () => { + const result = validateDisconnectConnectorBody({ + connected_account_id: "", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); + + it("should return 400 for invalid account_id UUID format", () => { + const result = validateDisconnectConnectorBody({ + connected_account_id: "ca_12345", + account_id: "not-a-uuid", + }); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); +}); diff --git a/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts b/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts new file mode 100644 index 00000000..aa7ed6ca --- /dev/null +++ b/lib/composio/connectors/__tests__/validateDisconnectConnectorRequest.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateDisconnectConnectorRequest } from "../validateDisconnectConnectorRequest"; + +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; +import { verifyConnectorOwnership } from "../verifyConnectorOwnership"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/auth/checkAccountAccess", () => ({ + checkAccountAccess: vi.fn(), +})); + +vi.mock("../verifyConnectorOwnership", () => ({ + verifyConnectorOwnership: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +describe("validateDisconnectConnectorRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return error if auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + body: JSON.stringify({ connected_account_id: "ca_123" }), + }); + const result = await validateDisconnectConnectorRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(401); + }); + + it("should verify ownership when no account_id provided", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "test-token", + }); + vi.mocked(verifyConnectorOwnership).mockResolvedValue(true); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + body: JSON.stringify({ connected_account_id: "ca_123" }), + }); + const result = await validateDisconnectConnectorRequest(request); + + expect(verifyConnectorOwnership).toHaveBeenCalledWith("account-123", "ca_123"); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + connectedAccountId: "ca_123", + targetAccountId: undefined, + }); + }); + + it("should return 403 when ownership verification fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "test-token", + }); + vi.mocked(verifyConnectorOwnership).mockResolvedValue(false); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + body: JSON.stringify({ connected_account_id: "ca_123" }), + }); + const result = await validateDisconnectConnectorRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); + + it("should check account access when account_id provided (artist)", async () => { + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "artist" }); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + body: JSON.stringify({ connected_account_id: "ca_123", account_id: mockTargetAccountId }), + }); + const result = await validateDisconnectConnectorRequest(request); + + expect(checkAccountAccess).toHaveBeenCalledWith("account-123", mockTargetAccountId); + expect(verifyConnectorOwnership).not.toHaveBeenCalled(); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + connectedAccountId: "ca_123", + targetAccountId: mockTargetAccountId, + }); + }); + + it("should check account access when account_id provided (workspace)", async () => { + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "workspace" }); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + body: JSON.stringify({ connected_account_id: "ca_123", account_id: mockTargetAccountId }), + }); + const result = await validateDisconnectConnectorRequest(request); + + expect(checkAccountAccess).toHaveBeenCalledWith("account-123", mockTargetAccountId); + expect(result).not.toBeInstanceOf(NextResponse); + }); + + it("should return 403 when account access denied", async () => { + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: false }); + + const request = new NextRequest("http://localhost/api/connectors", { + method: "DELETE", + body: JSON.stringify({ connected_account_id: "ca_123", account_id: mockTargetAccountId }), + }); + const result = await validateDisconnectConnectorRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); +}); diff --git a/lib/composio/connectors/__tests__/validateGetConnectorsQuery.test.ts b/lib/composio/connectors/__tests__/validateGetConnectorsQuery.test.ts new file mode 100644 index 00000000..d6b15dc3 --- /dev/null +++ b/lib/composio/connectors/__tests__/validateGetConnectorsQuery.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; +import { validateGetConnectorsQuery } from "../validateGetConnectorsQuery"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +describe("validateGetConnectorsQuery", () => { + it("should return empty object when no params provided", () => { + const searchParams = new URLSearchParams(); + const result = validateGetConnectorsQuery(searchParams); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({}); + }); + + it("should accept valid account_id UUID", () => { + const searchParams = new URLSearchParams({ + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + const result = validateGetConnectorsQuery(searchParams); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + }); + + it("should return 400 for invalid account_id UUID format", () => { + const searchParams = new URLSearchParams({ + account_id: "not-a-uuid", + }); + const result = validateGetConnectorsQuery(searchParams); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); +}); diff --git a/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts b/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts new file mode 100644 index 00000000..5dc514ff --- /dev/null +++ b/lib/composio/connectors/__tests__/validateGetConnectorsRequest.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetConnectorsRequest } from "../validateGetConnectorsRequest"; + +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/auth/checkAccountAccess", () => ({ + checkAccountAccess: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => new Headers()), +})); + +describe("validateGetConnectorsRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return error if auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const request = new NextRequest("http://localhost/api/connectors"); + const result = await validateGetConnectorsRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(401); + }); + + it("should return accountId as accountId when no account_id provided", async () => { + const mockAccountId = "account-123"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + + const request = new NextRequest("http://localhost/api/connectors"); + const result = await validateGetConnectorsRequest(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: mockAccountId, + }); + }); + + it("should return all connectors for any account type (unopinionated)", async () => { + const mockAccountId = "account-123"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: true, entityType: "artist" }); + + const request = new NextRequest(`http://localhost/api/connectors?account_id=${mockTargetAccountId}`); + const result = await validateGetConnectorsRequest(request); + + expect(checkAccountAccess).toHaveBeenCalledWith(mockAccountId, mockTargetAccountId); + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ + accountId: mockTargetAccountId, + }); + }); + + it("should return 403 when account_id provided but no access", async () => { + const mockAccountId = "account-123"; + const mockTargetAccountId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + vi.mocked(checkAccountAccess).mockResolvedValue({ hasAccess: false }); + + const request = new NextRequest(`http://localhost/api/connectors?account_id=${mockTargetAccountId}`); + const result = await validateGetConnectorsRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(403); + }); + + it("should return 400 for invalid account_id format", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + orgId: null, + authToken: "test-token", + }); + + const request = new NextRequest("http://localhost/api/connectors?account_id=not-a-uuid"); + const result = await validateGetConnectorsRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + expect(response.status).toBe(400); + }); +}); diff --git a/lib/composio/connectors/authorizeConnector.ts b/lib/composio/connectors/authorizeConnector.ts index 7b5b44a6..7c82e799 100644 --- a/lib/composio/connectors/authorizeConnector.ts +++ b/lib/composio/connectors/authorizeConnector.ts @@ -9,28 +9,43 @@ export interface AuthorizeResult { redirectUrl: string; } +/** + * Options for authorizing a connector. + */ +export interface AuthorizeConnectorOptions { + /** + * Custom auth configs for toolkits that require custom OAuth credentials. + * e.g., { tiktok: "ac_xxxxx" } + */ + authConfigs?: Record; + /** + * Custom callback URL (overrides default). + */ + customCallbackUrl?: string; +} + /** * Generate an OAuth authorization URL for a connector. * - * Why: Used by the /api/connectors/authorize endpoint to let users - * connect from the settings page (not in-chat). - * - * @param userId - The user's account ID - * @param connector - The connector slug (e.g., "googlesheets", "gmail") - * @param customCallbackUrl - Optional custom callback URL after OAuth + * @param accountId - The account ID to store the connection under + * @param connector - The connector slug (e.g., "googlesheets", "tiktok") + * @param options - Authorization options * @returns The redirect URL for OAuth */ export async function authorizeConnector( - userId: string, + accountId: string, connector: string, - customCallbackUrl?: string, + options: AuthorizeConnectorOptions = {}, ): Promise { + const { authConfigs, customCallbackUrl } = options; const composio = await getComposioClient(); - const callbackUrl = - customCallbackUrl || getCallbackUrl({ destination: "connectors" }); + // Determine callback URL + const callbackUrl = customCallbackUrl ?? getCallbackUrl({ destination: "connectors" }); - const session = await composio.create(userId, { + // Create session with optional auth configs + const session = await composio.create(accountId, { + ...(authConfigs && Object.keys(authConfigs).length > 0 && { authConfigs }), manageConnections: { callbackUrl, }, diff --git a/lib/composio/connectors/authorizeConnectorHandler.ts b/lib/composio/connectors/authorizeConnectorHandler.ts new file mode 100644 index 00000000..081f73b2 --- /dev/null +++ b/lib/composio/connectors/authorizeConnectorHandler.ts @@ -0,0 +1,49 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthorizeConnectorRequest } from "./validateAuthorizeConnectorRequest"; +import { authorizeConnector } from "./authorizeConnector"; + +/** + * Handler for POST /api/connectors. + * + * Generates an OAuth authorization URL for a specific connector. + * Supports connecting for the authenticated account or another account (via account_id). + * + * @param request - The incoming request + * @returns The redirect URL for OAuth authorization + */ +export async function authorizeConnectorHandler(request: NextRequest): Promise { + const headers = getCorsHeaders(); + + try { + // Validate auth, body, and access in one call + const validated = await validateAuthorizeConnectorRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const { accountId, connector, callbackUrl, authConfigs } = validated; + + // Execute authorization + const result = await authorizeConnector(accountId, connector, { + customCallbackUrl: callbackUrl, + authConfigs, + }); + + return NextResponse.json( + { + success: true, + data: { + connector: result.connector, + redirectUrl: result.redirectUrl, + }, + }, + { status: 200, headers }, + ); + } catch (error) { + console.error("Connector authorize error:", error); + const message = error instanceof Error ? error.message : "Failed to authorize connector"; + return NextResponse.json({ error: message }, { status: 500, headers }); + } +} diff --git a/lib/composio/connectors/disconnectConnector.ts b/lib/composio/connectors/disconnectConnector.ts index 3aff9b9e..edcee6f7 100644 --- a/lib/composio/connectors/disconnectConnector.ts +++ b/lib/composio/connectors/disconnectConnector.ts @@ -1,4 +1,16 @@ import { getComposioApiKey } from "../getComposioApiKey"; +import { getConnectors } from "./getConnectors"; + +/** + * Options for disconnecting a connector. + */ +export interface DisconnectConnectorOptions { + /** + * Account ID to verify ownership before disconnecting. + * If provided, checks that the connected account belongs to this account. + */ + verifyOwnershipFor?: string; +} /** * Disconnect a connected account from Composio. @@ -7,11 +19,24 @@ import { getComposioApiKey } from "../getComposioApiKey"; * so we call the REST API directly to delete the connection. * * @param connectedAccountId - The ID of the connected account to disconnect + * @param options - Options for ownership verification * @returns Success status */ export async function disconnectConnector( connectedAccountId: string, + options: DisconnectConnectorOptions = {}, ): Promise<{ success: boolean }> { + const { verifyOwnershipFor } = options; + + // If ownership verification is requested, check before deleting + if (verifyOwnershipFor) { + const connectors = await getConnectors(verifyOwnershipFor); + const hasConnection = connectors.some(c => c.connectedAccountId === connectedAccountId); + if (!hasConnection) { + throw new Error("Connection not found for this account"); + } + } + const apiKey = getComposioApiKey(); // Composio v3 API uses DELETE method @@ -27,9 +52,7 @@ export async function disconnectConnector( if (!response.ok) { const errorText = await response.text(); - throw new Error( - `Failed to disconnect (${response.status}): ${errorText}`, - ); + throw new Error(`Failed to disconnect (${response.status}): ${errorText}`); } return { success: true }; diff --git a/lib/composio/connectors/disconnectConnectorHandler.ts b/lib/composio/connectors/disconnectConnectorHandler.ts new file mode 100644 index 00000000..02d93526 --- /dev/null +++ b/lib/composio/connectors/disconnectConnectorHandler.ts @@ -0,0 +1,44 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateDisconnectConnectorRequest } from "./validateDisconnectConnectorRequest"; +import { disconnectConnector } from "./disconnectConnector"; + +/** + * Handler for DELETE /api/connectors. + * + * Disconnects a connected account from Composio. + * Supports disconnecting for the authenticated account or another account (via account_id). + * + * @param request - The incoming request + * @returns Success status + */ +export async function disconnectConnectorHandler(request: NextRequest): Promise { + const headers = getCorsHeaders(); + + try { + // Validate auth, body, and access in one call + const validated = await validateDisconnectConnectorRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const { connectedAccountId, targetAccountId } = validated; + + // Disconnect from Composio + if (targetAccountId) { + // Disconnecting for another account - verify ownership + await disconnectConnector(connectedAccountId, { + verifyOwnershipFor: targetAccountId, + }); + } else { + // User's own connection - already verified in validation + await disconnectConnector(connectedAccountId); + } + + return NextResponse.json({ success: true, message: "Connector disconnected" }, { status: 200, headers }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to disconnect connector"; + return NextResponse.json({ error: message }, { status: 500, headers }); + } +} diff --git a/lib/composio/connectors/getConnectors.ts b/lib/composio/connectors/getConnectors.ts index c8248941..cf01fd99 100644 --- a/lib/composio/connectors/getConnectors.ts +++ b/lib/composio/connectors/getConnectors.ts @@ -11,19 +11,38 @@ export interface ConnectorInfo { } /** - * Get all connectors and their connection status for a user. + * Options for getting connectors. + */ +export interface GetConnectorsOptions { + /** + * Custom display names for toolkits. + * e.g., { tiktok: "TikTok" } + */ + displayNames?: Record; +} + +/** + * Get connectors and their connection status for an account. + * + * Works for any account ID. Composio uses the accountId to scope connections. * - * @param userId - The user's account ID + * @param accountId - The account ID to get connectors for + * @param options - Options for filtering and display * @returns List of connectors with connection status */ -export async function getConnectors(userId: string): Promise { +export async function getConnectors( + accountId: string, + options: GetConnectorsOptions = {}, +): Promise { + const { displayNames = {} } = options; const composio = await getComposioClient(); - const session = await composio.create(userId); + + const session = await composio.create(accountId); const toolkits = await session.toolkits(); - return toolkits.items.map((toolkit) => ({ + return toolkits.items.map(toolkit => ({ slug: toolkit.slug, - name: toolkit.name, + name: displayNames[toolkit.slug] || toolkit.name, isConnected: toolkit.connection?.isActive ?? false, connectedAccountId: toolkit.connection?.connectedAccount?.id, })); diff --git a/lib/composio/connectors/getConnectorsHandler.ts b/lib/composio/connectors/getConnectorsHandler.ts new file mode 100644 index 00000000..d92f1293 --- /dev/null +++ b/lib/composio/connectors/getConnectorsHandler.ts @@ -0,0 +1,54 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetConnectorsRequest } from "./validateGetConnectorsRequest"; +import { getConnectors } from "./getConnectors"; + +/** + * Display names for connectors. + */ +const CONNECTOR_DISPLAY_NAMES: Record = { + tiktok: "TikTok", + googlesheets: "Google Sheets", + googledrive: "Google Drive", + googledocs: "Google Docs", +}; + +/** + * Handler for GET /api/connectors. + * + * Lists all available connectors and their connection status. + * Use account_id query param to get connectors for a specific entity. + * + * @param request - The incoming request + * @returns List of connectors with connection status + */ +export async function getConnectorsHandler(request: NextRequest): Promise { + const headers = getCorsHeaders(); + + try { + // Validate auth, query params, and access in one call + const validated = await validateGetConnectorsRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const { accountId } = validated; + + // Fetch all connectors — no filtering at the API level + const connectors = await getConnectors(accountId, { + displayNames: CONNECTOR_DISPLAY_NAMES, + }); + + return NextResponse.json( + { + success: true, + connectors, + }, + { status: 200, headers }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to fetch connectors"; + return NextResponse.json({ error: message }, { status: 500, headers }); + } +} diff --git a/lib/composio/connectors/index.ts b/lib/composio/connectors/index.ts index fa866c66..848b357b 100644 --- a/lib/composio/connectors/index.ts +++ b/lib/composio/connectors/index.ts @@ -1,3 +1,13 @@ -export { getConnectors, type ConnectorInfo } from "./getConnectors"; -export { authorizeConnector, type AuthorizeResult } from "./authorizeConnector"; -export { disconnectConnector } from "./disconnectConnector"; +export { getConnectors, type ConnectorInfo, type GetConnectorsOptions } from "./getConnectors"; +export { + authorizeConnector, + type AuthorizeResult, + type AuthorizeConnectorOptions, +} from "./authorizeConnector"; +export { disconnectConnector, type DisconnectConnectorOptions } from "./disconnectConnector"; +export { + ALLOWED_ARTIST_CONNECTORS, + isAllowedArtistConnector, + type AllowedArtistConnector, +} from "./isAllowedArtistConnector"; +export { verifyConnectorOwnership } from "./verifyConnectorOwnership"; diff --git a/lib/composio/connectors/isAllowedArtistConnector.ts b/lib/composio/connectors/isAllowedArtistConnector.ts new file mode 100644 index 00000000..60201a47 --- /dev/null +++ b/lib/composio/connectors/isAllowedArtistConnector.ts @@ -0,0 +1,16 @@ +/** + * List of toolkit slugs that artists are allowed to connect. + * Only these connectors will be shown in the artist-connectors API. + */ +export const ALLOWED_ARTIST_CONNECTORS = ["tiktok"] as const; + +export type AllowedArtistConnector = (typeof ALLOWED_ARTIST_CONNECTORS)[number]; + +/** + * Check if a connector slug is an allowed artist connector. + * + * @param slug + */ +export function isAllowedArtistConnector(slug: string): slug is AllowedArtistConnector { + return (ALLOWED_ARTIST_CONNECTORS as readonly string[]).includes(slug); +} diff --git a/lib/composio/connectors/validateAuthorizeConnectorBody.ts b/lib/composio/connectors/validateAuthorizeConnectorBody.ts index df3570e3..64ab16f0 100644 --- a/lib/composio/connectors/validateAuthorizeConnectorBody.ts +++ b/lib/composio/connectors/validateAuthorizeConnectorBody.ts @@ -5,22 +5,25 @@ import { z } from "zod"; export const authorizeConnectorBodySchema = z.object({ connector: z .string({ message: "connector is required" }) - .min(1, "connector cannot be empty (e.g., 'googlesheets', 'gmail')"), + .min(1, "connector cannot be empty (e.g., 'googlesheets', 'tiktok')"), callback_url: z.string().url("callback_url must be a valid URL").optional(), + account_id: z.string().uuid("account_id must be a valid UUID").optional(), }); -export type AuthorizeConnectorBody = z.infer< - typeof authorizeConnectorBodySchema ->; +export type AuthorizeConnectorBody = z.infer; /** * Validates request body for POST /api/connectors/authorize. * + * Validates structure only (connector, callback_url, account_id). + * Connector restriction for artists is enforced in validateAuthorizeConnectorRequest + * after the entity type is determined via the access check. + * * @param body - The request body * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. */ export function validateAuthorizeConnectorBody( - body: unknown + body: unknown, ): NextResponse | AuthorizeConnectorBody { const result = authorizeConnectorBodySchema.safeParse(body); @@ -28,12 +31,14 @@ export function validateAuthorizeConnectorBody( const firstError = result.error.issues[0]; return NextResponse.json( { + status: "error", + missing_fields: firstError.path, error: firstError.message, }, { status: 400, headers: getCorsHeaders(), - } + }, ); } diff --git a/lib/composio/connectors/validateAuthorizeConnectorRequest.ts b/lib/composio/connectors/validateAuthorizeConnectorRequest.ts new file mode 100644 index 00000000..134cda3c --- /dev/null +++ b/lib/composio/connectors/validateAuthorizeConnectorRequest.ts @@ -0,0 +1,80 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateAuthorizeConnectorBody } from "./validateAuthorizeConnectorBody"; +import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; + +/** + * Validated params for authorizing a connector. + */ +export interface AuthorizeConnectorParams { + accountId: string; + connector: string; + callbackUrl?: string; + authConfigs?: Record; +} + +/** + * Validates the full POST /api/connectors/authorize request. + * + * Unopinionated: allows any connector for any account type. + * Connector usage decisions (e.g., which tools the AI agent uses) are handled + * at the tool router level, not the API level. + * + * Handles: + * 1. Authentication (x-api-key or Bearer token) + * 2. Body validation (connector, callback_url, account_id) + * 3. Access verification (when account_id is provided) + * + * @param request - The incoming request + * @returns NextResponse error or validated params + */ +export async function validateAuthorizeConnectorRequest( + request: NextRequest, +): Promise { + const headers = getCorsHeaders(); + + // 1. Validate authentication (supports x-api-key and Bearer token) + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + const { accountId } = authResult; + + // 2. Validate body structure + const body = await request.json(); + const validated = validateAuthorizeConnectorBody(body); + if (validated instanceof NextResponse) { + return validated; + } + const { connector, callback_url, account_id } = validated; + + // 3. If account_id is provided, verify access and use that entity + if (account_id) { + const accessResult = await checkAccountAccess(accountId, account_id); + if (!accessResult.hasAccess) { + return NextResponse.json({ error: "Access denied to this account" }, { status: 403, headers }); + } + + // Build auth configs for custom OAuth + const authConfigs: Record = {}; + if (connector === "tiktok" && process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID) { + authConfigs.tiktok = process.env.COMPOSIO_TIKTOK_AUTH_CONFIG_ID; + } + + return { + accountId: account_id, + connector, + callbackUrl: callback_url, + authConfigs: Object.keys(authConfigs).length > 0 ? authConfigs : undefined, + }; + } + + // No account_id: use the authenticated account + return { + accountId, + connector, + callbackUrl: callback_url, + }; +} diff --git a/lib/composio/connectors/validateDisconnectConnectorBody.ts b/lib/composio/connectors/validateDisconnectConnectorBody.ts index 70dfb150..52edafd8 100644 --- a/lib/composio/connectors/validateDisconnectConnectorBody.ts +++ b/lib/composio/connectors/validateDisconnectConnectorBody.ts @@ -3,24 +3,35 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { z } from "zod"; export const disconnectConnectorBodySchema = z.object({ - connected_account_id: z.string().min(1, "connected_account_id is required"), + connected_account_id: z + .string({ message: "connected_account_id is required" }) + .min(1, "connected_account_id cannot be empty"), + account_id: z.string().uuid("account_id must be a valid UUID").optional(), }); export type DisconnectConnectorBody = z.infer; /** - * Validates request body for POST /api/connectors/disconnect. + * Validates request body shape for DELETE /api/connectors. + * + * Only checks presence and format of fields (connected_account_id required, + * account_id optional UUID). Ownership and authorization checks are performed + * by validateDisconnectConnectorRequest. * * @param body - The request body * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. */ -export function validateDisconnectConnectorBody(body: unknown): NextResponse | DisconnectConnectorBody { +export function validateDisconnectConnectorBody( + body: unknown, +): NextResponse | DisconnectConnectorBody { const result = disconnectConnectorBodySchema.safeParse(body); if (!result.success) { const firstError = result.error.issues[0]; return NextResponse.json( { + status: "error", + missing_fields: firstError.path, error: firstError.message, }, { diff --git a/lib/composio/connectors/validateDisconnectConnectorRequest.ts b/lib/composio/connectors/validateDisconnectConnectorRequest.ts new file mode 100644 index 00000000..6716f285 --- /dev/null +++ b/lib/composio/connectors/validateDisconnectConnectorRequest.ts @@ -0,0 +1,70 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateDisconnectConnectorBody } from "./validateDisconnectConnectorBody"; +import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; +import { verifyConnectorOwnership } from "./verifyConnectorOwnership"; + +/** + * Validated params for disconnecting a connector. + */ +export interface DisconnectConnectorParams { + connectedAccountId: string; + targetAccountId?: string; +} + +/** + * Validates the full DELETE /api/connectors request. + * + * Handles: + * 1. Authentication (x-api-key or Bearer token) + * 2. Body validation (connected_account_id, account_id) + * 3. Access verification (account access or connector ownership) + * + * @param request - The incoming request + * @returns NextResponse error or validated params + */ +export async function validateDisconnectConnectorRequest( + request: NextRequest, +): Promise { + const headers = getCorsHeaders(); + + // 1. Validate authentication (supports x-api-key and Bearer token) + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + const { accountId } = authResult; + + // 2. Validate body + const body = await request.json(); + const validated = validateDisconnectConnectorBody(body); + if (validated instanceof NextResponse) { + return validated; + } + const { connected_account_id, account_id } = validated; + + // 3. Verify access + if (account_id) { + // Disconnecting for another account - verify access to that account + const accessResult = await checkAccountAccess(accountId, account_id); + if (!accessResult.hasAccess) { + return NextResponse.json({ error: "Access denied to this account" }, { status: 403, headers }); + } + } else { + // Disconnecting account's own connection - verify ownership + const isOwner = await verifyConnectorOwnership(accountId, connected_account_id); + if (!isOwner) { + return NextResponse.json( + { error: "Connected account not found or access denied" }, + { status: 403, headers }, + ); + } + } + + return { + connectedAccountId: connected_account_id, + targetAccountId: account_id, + }; +} diff --git a/lib/composio/connectors/validateGetConnectorsQuery.ts b/lib/composio/connectors/validateGetConnectorsQuery.ts new file mode 100644 index 00000000..c3b0dafa --- /dev/null +++ b/lib/composio/connectors/validateGetConnectorsQuery.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const getConnectorsQuerySchema = z.object({ + account_id: z.string().uuid("account_id must be a valid UUID").optional(), +}); + +export type GetConnectorsQuery = z.infer; + +/** + * Validates query params for GET /api/connectors. + * + * - No params: Returns connectors for the authenticated account + * - account_id=uuid: Returns connectors for that entity (after access check) + * + * @param searchParams - The URL search params + * @returns A NextResponse with an error if validation fails, or the validated query if validation passes. + */ +export function validateGetConnectorsQuery( + searchParams: URLSearchParams, +): NextResponse | GetConnectorsQuery { + const queryParams = { + account_id: searchParams.get("account_id") ?? undefined, + }; + + const result = getConnectorsQuerySchema.safeParse(queryParams); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/composio/connectors/validateGetConnectorsRequest.ts b/lib/composio/connectors/validateGetConnectorsRequest.ts new file mode 100644 index 00000000..916eb315 --- /dev/null +++ b/lib/composio/connectors/validateGetConnectorsRequest.ts @@ -0,0 +1,62 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateGetConnectorsQuery } from "./validateGetConnectorsQuery"; +import { checkAccountAccess } from "@/lib/auth/checkAccountAccess"; + +/** + * Validated params for getting connectors. + */ +export interface GetConnectorsParams { + accountId: string; +} + +/** + * Validates the full GET /api/connectors request. + * + * Unopinionated: returns all available connectors for any account type. + * Connector restrictions (e.g., which tools the AI agent uses) are handled + * at the tool router level, not the API level. + * + * Handles: + * 1. Authentication (x-api-key or Bearer token) + * 2. Query param validation (account_id) + * 3. Access verification (when account_id is provided) + * + * @param request - The incoming request + * @returns NextResponse error or validated params + */ +export async function validateGetConnectorsRequest( + request: NextRequest, +): Promise { + const headers = getCorsHeaders(); + + // 1. Validate authentication (supports x-api-key and Bearer token) + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + const { accountId } = authResult; + + // 2. Validate query params + const { searchParams } = new URL(request.url); + const validated = validateGetConnectorsQuery(searchParams); + if (validated instanceof NextResponse) { + return validated; + } + const { account_id } = validated; + + // 3. If account_id is provided, verify access and use that entity + if (account_id) { + const accessResult = await checkAccountAccess(accountId, account_id); + if (!accessResult.hasAccess) { + return NextResponse.json({ error: "Access denied to this account" }, { status: 403, headers }); + } + + return { accountId: account_id }; + } + + // No account_id: use the authenticated account + return { accountId }; +} diff --git a/lib/composio/connectors/verifyConnectorOwnership.ts b/lib/composio/connectors/verifyConnectorOwnership.ts index dc4f7a67..ff91e36d 100644 --- a/lib/composio/connectors/verifyConnectorOwnership.ts +++ b/lib/composio/connectors/verifyConnectorOwnership.ts @@ -1,23 +1,21 @@ import { getConnectors } from "./getConnectors"; /** - * Verifies that a connected account ID belongs to the specified user. + * Verifies that a connected account ID belongs to the specified account. * * Why: Before disconnecting a connector, we must verify ownership to prevent - * users from disconnecting other users' connectors (authorization bypass). + * accounts from disconnecting other accounts' connectors (authorization bypass). * - * @param accountId - The authenticated user's account ID + * @param accountId - The authenticated account ID * @param connectedAccountId - The connected account ID to verify - * @returns true if the connected account belongs to the user, false otherwise + * @returns true if the connected account belongs to this account, false otherwise */ export async function verifyConnectorOwnership( accountId: string, - connectedAccountId: string + connectedAccountId: string, ): Promise { const connectors = await getConnectors(accountId); - // Check if any of the user's connectors have this connected account ID - return connectors.some( - (connector) => connector.connectedAccountId === connectedAccountId - ); + // Check if any of the account's connectors have this connected account ID + return connectors.some(connector => connector.connectedAccountId === connectedAccountId); } diff --git a/lib/composio/toolRouter/__tests__/createToolRouterSession.test.ts b/lib/composio/toolRouter/__tests__/createToolRouterSession.test.ts new file mode 100644 index 00000000..802de374 --- /dev/null +++ b/lib/composio/toolRouter/__tests__/createToolRouterSession.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createToolRouterSession } from "../createToolRouterSession"; + +import { getComposioClient } from "../../client"; +import { getCallbackUrl } from "../../getCallbackUrl"; +import { getConnectors } from "../../connectors/getConnectors"; + +vi.mock("../../client", () => ({ + getComposioClient: vi.fn(), +})); + +vi.mock("../../getCallbackUrl", () => ({ + getCallbackUrl: vi.fn(), +})); + +vi.mock("../../connectors/getConnectors", () => ({ + getConnectors: vi.fn(), +})); + +describe("createToolRouterSession", () => { + const mockSession = { tools: vi.fn() }; + const mockComposio = { create: vi.fn(() => mockSession) }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getComposioClient).mockResolvedValue(mockComposio); + vi.mocked(getCallbackUrl).mockReturnValue("https://example.com/chat?connected=true"); + // Default: account has no connections + vi.mocked(getConnectors).mockResolvedValue([]); + }); + + it("should create session with enabled toolkits", async () => { + await createToolRouterSession("account-123"); + + expect(getComposioClient).toHaveBeenCalled(); + expect(mockComposio.create).toHaveBeenCalledWith("account-123", { + toolkits: ["googlesheets", "googledrive", "googledocs", "tiktok"], + manageConnections: { + callbackUrl: "https://example.com/chat?connected=true", + }, + connectedAccounts: undefined, + }); + }); + + it("should include roomId in callback URL", async () => { + await createToolRouterSession("account-123", "room-456"); + + expect(getCallbackUrl).toHaveBeenCalledWith({ + destination: "chat", + roomId: "room-456", + }); + }); + + it("should pass artist connections when account has no overlap", async () => { + // Account has Google Sheets connected but NOT TikTok + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "googlesheets", name: "Google Sheets", isConnected: true, connectedAccountId: "gs-123" }, + { slug: "tiktok", name: "TikTok", isConnected: false }, + ]); + + const artistConnections = { tiktok: "artist-tiktok-789" }; + await createToolRouterSession("account-123", undefined, artistConnections); + + // Artist's TikTok should pass through (account doesn't have it connected) + expect(mockComposio.create).toHaveBeenCalledWith("account-123", { + toolkits: ["googlesheets", "googledrive", "googledocs", "tiktok"], + manageConnections: { + callbackUrl: "https://example.com/chat?connected=true", + }, + connectedAccounts: { tiktok: "artist-tiktok-789" }, + }); + }); + + it("should filter out artist connections that overlap with account", async () => { + // Account already has TikTok connected + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "tiktok", name: "TikTok", isConnected: true, connectedAccountId: "account-tiktok" }, + ]); + + const artistConnections = { tiktok: "artist-tiktok-789" }; + await createToolRouterSession("account-123", undefined, artistConnections); + + // Artist's TikTok should be filtered out (account already has it) + expect(mockComposio.create).toHaveBeenCalledWith("account-123", { + toolkits: ["googlesheets", "googledrive", "googledocs", "tiktok"], + manageConnections: { + callbackUrl: "https://example.com/chat?connected=true", + }, + connectedAccounts: undefined, + }); + }); + + it("should return session object", async () => { + const result = await createToolRouterSession("account-123"); + + expect(result).toBe(mockSession); + }); + + it("should handle undefined roomId", async () => { + await createToolRouterSession("account-123", undefined); + + expect(getCallbackUrl).toHaveBeenCalledWith({ + destination: "chat", + roomId: undefined, + }); + }); +}); diff --git a/lib/composio/toolRouter/__tests__/getArtistConnectionsFromComposio.test.ts b/lib/composio/toolRouter/__tests__/getArtistConnectionsFromComposio.test.ts new file mode 100644 index 00000000..fc8d109d --- /dev/null +++ b/lib/composio/toolRouter/__tests__/getArtistConnectionsFromComposio.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getArtistConnectionsFromComposio } from "../getArtistConnectionsFromComposio"; + +import { getConnectors } from "../../connectors"; + +// Mock dependencies +vi.mock("../../connectors", () => ({ + getConnectors: vi.fn(), + ALLOWED_ARTIST_CONNECTORS: ["tiktok"], +})); + +describe("getArtistConnectionsFromComposio", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return empty object when no connectors are connected", async () => { + vi.mocked(getConnectors).mockResolvedValue([{ slug: "tiktok", connectedAccountId: null }]); + + const result = await getArtistConnectionsFromComposio("artist-123"); + + expect(getConnectors).toHaveBeenCalledWith("artist-123"); + expect(result).toEqual({}); + }); + + it("should return connections map for connected connectors", async () => { + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "tiktok", connectedAccountId: "tiktok-account-456" }, + ]); + + const result = await getArtistConnectionsFromComposio("artist-123"); + + expect(result).toEqual({ + tiktok: "tiktok-account-456", + }); + }); + + it("should filter out connectors without connectedAccountId", async () => { + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "tiktok", connectedAccountId: "tiktok-account-456" }, + { slug: "instagram", connectedAccountId: null }, + { slug: "youtube", connectedAccountId: undefined }, + ]); + + const result = await getArtistConnectionsFromComposio("artist-789"); + + expect(result).toEqual({ + tiktok: "tiktok-account-456", + }); + }); + + it("should only include allowed artist connectors", async () => { + vi.mocked(getConnectors).mockResolvedValue([ + { slug: "tiktok", connectedAccountId: "tiktok-account-1" }, + { slug: "instagram", connectedAccountId: "instagram-account-2" }, + ]); + + const result = await getArtistConnectionsFromComposio("artist-multi"); + + expect(result).toEqual({ + tiktok: "tiktok-account-1", + }); + expect(result).not.toHaveProperty("instagram"); + }); + + it("should return empty object when getConnectors returns empty array", async () => { + vi.mocked(getConnectors).mockResolvedValue([]); + + const result = await getArtistConnectionsFromComposio("artist-empty"); + + expect(result).toEqual({}); + }); +}); diff --git a/lib/composio/toolRouter/__tests__/getComposioTools.test.ts b/lib/composio/toolRouter/__tests__/getComposioTools.test.ts new file mode 100644 index 00000000..594717e5 --- /dev/null +++ b/lib/composio/toolRouter/__tests__/getComposioTools.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getComposioTools } from "../getTools"; + +import { createToolRouterSession } from "../createToolRouterSession"; +import { getArtistConnectionsFromComposio } from "../getArtistConnectionsFromComposio"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; + +// Mock dependencies +vi.mock("../createToolRouterSession", () => ({ + createToolRouterSession: vi.fn(), +})); + +vi.mock("../getArtistConnectionsFromComposio", () => ({ + getArtistConnectionsFromComposio: vi.fn(), +})); + +vi.mock("@/lib/artists/checkAccountArtistAccess", () => ({ + checkAccountArtistAccess: vi.fn(), +})); + +// Mock valid tool structure +const createMockTool = () => ({ + description: "Test tool", + inputSchema: { type: "object" }, + execute: vi.fn(), +}); + +describe("getComposioTools", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv, COMPOSIO_API_KEY: "test-api-key" }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("should return empty object when COMPOSIO_API_KEY is not set", async () => { + delete process.env.COMPOSIO_API_KEY; + + const result = await getComposioTools("account-123"); + + expect(result).toEqual({}); + expect(createToolRouterSession).not.toHaveBeenCalled(); + }); + + it("should not fetch artist connections when artistId is not provided", async () => { + const mockSession = { + tools: vi.fn().mockResolvedValue({ + COMPOSIO_MANAGE_CONNECTIONS: createMockTool(), + }), + }; + vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); + + await getComposioTools("account-123"); + + expect(getArtistConnectionsFromComposio).not.toHaveBeenCalled(); + expect(createToolRouterSession).toHaveBeenCalledWith("account-123", undefined, undefined); + }); + + it("should fetch and pass artist connections when artistId is provided and access is granted", async () => { + const mockConnections = { tiktok: "tiktok-account-456" }; + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + vi.mocked(getArtistConnectionsFromComposio).mockResolvedValue(mockConnections); + + const mockSession = { + tools: vi.fn().mockResolvedValue({ + COMPOSIO_MANAGE_CONNECTIONS: createMockTool(), + }), + }; + vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); + + await getComposioTools("account-123", "artist-456", "room-789"); + + expect(checkAccountArtistAccess).toHaveBeenCalledWith("account-123", "artist-456"); + expect(getArtistConnectionsFromComposio).toHaveBeenCalledWith("artist-456"); + expect(createToolRouterSession).toHaveBeenCalledWith("account-123", "room-789", mockConnections); + }); + + it("should skip artist connections when access is denied", async () => { + vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); + + const mockSession = { + tools: vi.fn().mockResolvedValue({ + COMPOSIO_MANAGE_CONNECTIONS: createMockTool(), + }), + }; + vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); + + await getComposioTools("account-123", "artist-456", "room-789"); + + expect(checkAccountArtistAccess).toHaveBeenCalledWith("account-123", "artist-456"); + expect(getArtistConnectionsFromComposio).not.toHaveBeenCalled(); + expect(createToolRouterSession).toHaveBeenCalledWith("account-123", "room-789", undefined); + }); + + it("should pass undefined when artist has no connections", async () => { + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + vi.mocked(getArtistConnectionsFromComposio).mockResolvedValue({}); + + const mockSession = { + tools: vi.fn().mockResolvedValue({ + COMPOSIO_MANAGE_CONNECTIONS: createMockTool(), + }), + }; + vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); + + await getComposioTools("account-123", "artist-no-connections"); + + expect(getArtistConnectionsFromComposio).toHaveBeenCalledWith("artist-no-connections"); + expect(createToolRouterSession).toHaveBeenCalledWith("account-123", undefined, undefined); + }); + + it("should filter tools to only ALLOWED_TOOLS", async () => { + const mockSession = { + tools: vi.fn().mockResolvedValue({ + COMPOSIO_MANAGE_CONNECTIONS: createMockTool(), + COMPOSIO_SEARCH_TOOLS: createMockTool(), + SOME_OTHER_TOOL: createMockTool(), + }), + }; + vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); + + const result = await getComposioTools("account-123"); + + expect(result).toHaveProperty("COMPOSIO_MANAGE_CONNECTIONS"); + expect(result).toHaveProperty("COMPOSIO_SEARCH_TOOLS"); + expect(result).not.toHaveProperty("SOME_OTHER_TOOL"); + }); + + it("should return empty object when session creation throws", async () => { + vi.mocked(createToolRouterSession).mockRejectedValue(new Error("Bundler incompatibility")); + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await getComposioTools("account-123"); + + expect(result).toEqual({}); + expect(consoleSpy).toHaveBeenCalledWith( + "Composio tools unavailable:", + "Bundler incompatibility", + ); + + consoleSpy.mockRestore(); + }); + + it("should skip invalid tools that lack required properties", async () => { + const mockSession = { + tools: vi.fn().mockResolvedValue({ + COMPOSIO_MANAGE_CONNECTIONS: createMockTool(), + COMPOSIO_SEARCH_TOOLS: { description: "No execute function" }, + }), + }; + vi.mocked(createToolRouterSession).mockResolvedValue(mockSession); + + const result = await getComposioTools("account-123"); + + expect(result).toHaveProperty("COMPOSIO_MANAGE_CONNECTIONS"); + expect(result).not.toHaveProperty("COMPOSIO_SEARCH_TOOLS"); + }); +}); diff --git a/lib/composio/toolRouter/createSession.ts b/lib/composio/toolRouter/createSession.ts deleted file mode 100644 index f5fad9b1..00000000 --- a/lib/composio/toolRouter/createSession.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { getComposioClient } from "../client"; -import { getCallbackUrl } from "../getCallbackUrl"; - -/** - * Toolkits available in Tool Router sessions. - * Add more toolkits here as we expand Composio integration. - */ -const ENABLED_TOOLKITS = ["googlesheets", "googledrive", "googledocs"]; - -/** - * Create a Composio Tool Router session for a user. - */ -export async function createToolRouterSession(userId: string, roomId?: string) { - const composio = await getComposioClient(); - - const callbackUrl = getCallbackUrl({ - destination: "chat", - roomId, - }); - - const session = await composio.create(userId, { - toolkits: ENABLED_TOOLKITS, - manageConnections: { - callbackUrl, - }, - }); - - return session; -} diff --git a/lib/composio/toolRouter/createToolRouterSession.ts b/lib/composio/toolRouter/createToolRouterSession.ts new file mode 100644 index 00000000..2ecf2ec7 --- /dev/null +++ b/lib/composio/toolRouter/createToolRouterSession.ts @@ -0,0 +1,72 @@ +import { getComposioClient } from "../client"; +import { getCallbackUrl } from "../getCallbackUrl"; +import { getConnectors } from "../connectors/getConnectors"; + +/** + * Toolkits available in Tool Router sessions. + * Add more toolkits here as we expand Composio integration. + */ +const ENABLED_TOOLKITS = ["googlesheets", "googledrive", "googledocs", "tiktok"]; + +/** + * Create a Composio Tool Router session for an account. + * + * This is the opinionated layer — it decides which connections the AI agent uses. + * When both the account and artist have the same toolkit connected, the account's + * connection is kept and the artist's is dropped to prevent tool collision + * (the AI wouldn't know which credentials to use). + * + * Artist connections only fill gaps where the account has no connection. + * + * @param accountId - Unique identifier for the account + * @param roomId - Optional chat room ID for OAuth redirect + * @param artistConnections - Optional mapping of toolkit slug to connected account ID for artist-specific connections + */ +export async function createToolRouterSession( + accountId: string, + roomId?: string, + artistConnections?: Record, +) { + const composio = await getComposioClient(); + + const callbackUrl = getCallbackUrl({ + destination: "chat", + roomId, + }); + + // Filter artist connections to prevent tool collision. + // If the account already has a toolkit connected, the account's connection wins. + // Artist connections only override toolkits the account hasn't connected. + let filteredConnections = artistConnections; + + if (artistConnections && Object.keys(artistConnections).length > 0) { + const accountConnectors = await getConnectors(accountId); + + // Find which toolkits the account already has active connections for + const accountConnectedSlugs = new Set( + accountConnectors.filter(c => c.isConnected).map(c => c.slug), + ); + + // Only keep artist connections for toolkits the account doesn't have + filteredConnections = Object.fromEntries( + Object.entries(artistConnections).filter( + ([slug]) => !accountConnectedSlugs.has(slug), + ), + ); + + // If nothing left after filtering, don't pass overrides at all + if (Object.keys(filteredConnections).length === 0) { + filteredConnections = undefined; + } + } + + const session = await composio.create(accountId, { + toolkits: ENABLED_TOOLKITS, + manageConnections: { + callbackUrl, + }, + connectedAccounts: filteredConnections, + }); + + return session; +} diff --git a/lib/composio/toolRouter/getArtistConnectionsFromComposio.ts b/lib/composio/toolRouter/getArtistConnectionsFromComposio.ts new file mode 100644 index 00000000..2730403d --- /dev/null +++ b/lib/composio/toolRouter/getArtistConnectionsFromComposio.ts @@ -0,0 +1,27 @@ +import { getConnectors, ALLOWED_ARTIST_CONNECTORS } from "../connectors"; + +/** + * Query Composio for an artist's connected accounts. + * + * Uses artistId as the Composio entity to get their connections. + * Only returns connections for ALLOWED_ARTIST_CONNECTORS (e.g., tiktok). + * + * @param artistId - The artist ID (Composio entity) + * @returns Map of toolkit slug to connected account ID + */ +export async function getArtistConnectionsFromComposio( + artistId: string, +): Promise> { + const connectors = await getConnectors(artistId); + + // Build connections map, filtered to allowed artist connectors + const allowed = new Set(ALLOWED_ARTIST_CONNECTORS); + const connections: Record = {}; + for (const connector of connectors) { + if (allowed.has(connector.slug) && connector.connectedAccountId) { + connections[connector.slug] = connector.connectedAccountId; + } + } + + return connections; +} diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts index 9855098c..697e4d03 100644 --- a/lib/composio/toolRouter/getTools.ts +++ b/lib/composio/toolRouter/getTools.ts @@ -1,4 +1,6 @@ -import { createToolRouterSession } from "./createSession"; +import { createToolRouterSession } from "./createToolRouterSession"; +import { getArtistConnectionsFromComposio } from "./getArtistConnectionsFromComposio"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; import type { Tool, ToolSet } from "ai"; /** @@ -36,7 +38,7 @@ function isValidTool(tool: unknown): tool is Tool { } /** - * Get Composio Tool Router tools for a user. + * Get Composio Tool Router tools for an account. * * Returns a filtered subset of meta-tools: * - COMPOSIO_MANAGE_CONNECTIONS - OAuth/auth management @@ -44,17 +46,22 @@ function isValidTool(tool: unknown): tool is Tool { * - COMPOSIO_GET_TOOL_SCHEMAS - Get parameter schemas * - COMPOSIO_MULTI_EXECUTE_TOOL - Execute actions * + * If artistId is provided, queries Composio for the artist's connections + * and passes them to the session via connectedAccounts override. + * * Gracefully returns empty ToolSet when: * - COMPOSIO_API_KEY is not set * - @composio packages fail to load (bundler incompatibility) * - * @param userId - Unique identifier for the user (accountId) + * @param accountId - Unique identifier for the account + * @param artistId - Optional artist ID to use artist-specific Composio connections * @param roomId - Optional chat room ID for OAuth redirect * @returns ToolSet containing filtered Vercel AI SDK tools */ export async function getComposioTools( - userId: string, - roomId?: string + accountId: string, + artistId?: string, + roomId?: string, ): Promise { // Skip Composio if API key is not configured if (!process.env.COMPOSIO_API_KEY) { @@ -62,7 +69,22 @@ export async function getComposioTools( } try { - const session = await createToolRouterSession(userId, roomId); + // Fetch artist-specific connections from Composio if artistId is provided + // Only fetch if the account has access to this artist + let artistConnections: Record | undefined; + if (artistId) { + const hasAccess = await checkAccountArtistAccess(accountId, artistId); + if (hasAccess) { + artistConnections = await getArtistConnectionsFromComposio(artistId); + // Only pass if there are actual connections + if (Object.keys(artistConnections).length === 0) { + artistConnections = undefined; + } + } + // If no access, silently skip artist connections (don't throw) + } + + const session = await createToolRouterSession(accountId, roomId, artistConnections); const allTools = await session.tools(); // Filter to only allowed tools with runtime validation diff --git a/lib/composio/toolRouter/index.ts b/lib/composio/toolRouter/index.ts index 0e3bb33c..bdcf3bb5 100644 --- a/lib/composio/toolRouter/index.ts +++ b/lib/composio/toolRouter/index.ts @@ -1,2 +1,2 @@ -export { createToolRouterSession } from "./createSession"; +export { createToolRouterSession } from "./createToolRouterSession"; export { getComposioTools } from "./getTools"; diff --git a/lib/supabase/account_artist_ids/__tests__/selectAccountArtistId.test.ts b/lib/supabase/account_artist_ids/__tests__/selectAccountArtistId.test.ts new file mode 100644 index 00000000..12c6935b --- /dev/null +++ b/lib/supabase/account_artist_ids/__tests__/selectAccountArtistId.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectAccountArtistId } from "../selectAccountArtistId"; + +vi.mock("../../serverClient", () => { + const mockFrom = vi.fn(); + return { + default: { from: mockFrom }, + }; +}); + +import supabase from "../../serverClient"; + +describe("selectAccountArtistId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return the row when account has direct access to artist", async () => { + const row = { artist_id: "artist-123" }; + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: row, error: null }), + }), + }), + }), + } as never); + + const result = await selectAccountArtistId("account-123", "artist-123"); + + expect(supabase.from).toHaveBeenCalledWith("account_artist_ids"); + expect(result).toEqual(row); + }); + + it("should return null when no direct access exists", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), + }), + }), + }), + } as never); + + const result = await selectAccountArtistId("account-123", "artist-123"); + + expect(result).toBeNull(); + }); + + it("should return null on database error", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: null, + error: new Error("DB error"), + }), + }), + }), + }), + } as never); + + const result = await selectAccountArtistId("account-123", "artist-123"); + + expect(result).toBeNull(); + }); +}); diff --git a/lib/supabase/account_artist_ids/selectAccountArtistId.ts b/lib/supabase/account_artist_ids/selectAccountArtistId.ts new file mode 100644 index 00000000..8031957e --- /dev/null +++ b/lib/supabase/account_artist_ids/selectAccountArtistId.ts @@ -0,0 +1,26 @@ +import supabase from "../serverClient"; + +/** + * Select a single account_artist_ids row for a specific account and artist. + * + * @param accountId - The account ID + * @param artistId - The artist ID + * @returns The row if found, null if not found or on error + */ +export async function selectAccountArtistId( + accountId: string, + artistId: string, +) { + const { data, error } = await supabase + .from("account_artist_ids") + .select("artist_id") + .eq("account_id", accountId) + .eq("artist_id", artistId) + .maybeSingle(); + + if (error) { + return null; + } + + return data; +} diff --git a/lib/supabase/account_organization_ids/__tests__/selectAccountOrganizationIds.test.ts b/lib/supabase/account_organization_ids/__tests__/selectAccountOrganizationIds.test.ts new file mode 100644 index 00000000..d4c7a595 --- /dev/null +++ b/lib/supabase/account_organization_ids/__tests__/selectAccountOrganizationIds.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectAccountOrganizationIds } from "../selectAccountOrganizationIds"; + +vi.mock("../../serverClient", () => { + const mockFrom = vi.fn(); + return { + default: { from: mockFrom }, + }; +}); + +import supabase from "../../serverClient"; + +describe("selectAccountOrganizationIds", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return matching organization IDs for an account", async () => { + const rows = [{ organization_id: "org-1" }]; + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + in: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue({ data: rows, error: null }), + }), + }), + }), + } as never); + + const result = await selectAccountOrganizationIds("account-123", ["org-1", "org-2"]); + + expect(supabase.from).toHaveBeenCalledWith("account_organization_ids"); + expect(result).toEqual(rows); + }); + + it("should return empty array when account has no matching orgs", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + in: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue({ data: [], error: null }), + }), + }), + }), + } as never); + + const result = await selectAccountOrganizationIds("account-123", ["org-1"]); + + expect(result).toEqual([]); + }); + + it("should return null on database error", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + in: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue({ + data: null, + error: new Error("DB error"), + }), + }), + }), + }), + } as never); + + const result = await selectAccountOrganizationIds("account-123", ["org-1"]); + + expect(result).toBeNull(); + }); + + it("should return empty array when orgIds is empty", async () => { + const result = await selectAccountOrganizationIds("account-123", []); + + expect(supabase.from).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); +}); diff --git a/lib/supabase/account_organization_ids/selectAccountOrganizationIds.ts b/lib/supabase/account_organization_ids/selectAccountOrganizationIds.ts new file mode 100644 index 00000000..68fd6618 --- /dev/null +++ b/lib/supabase/account_organization_ids/selectAccountOrganizationIds.ts @@ -0,0 +1,28 @@ +import supabase from "../serverClient"; + +/** + * Select account_organization_ids rows matching an account and a set of organization IDs. + * + * @param accountId - The account ID + * @param orgIds - Organization IDs to check membership against + * @returns Array of matching rows, or null on error + */ +export async function selectAccountOrganizationIds( + accountId: string, + orgIds: string[], +) { + if (!orgIds.length) return []; + + const { data, error } = await supabase + .from("account_organization_ids") + .select("organization_id") + .eq("account_id", accountId) + .in("organization_id", orgIds) + .limit(1); + + if (error) { + return null; + } + + return data || []; +} diff --git a/lib/supabase/account_workspace_ids/__tests__/selectAccountWorkspaceId.test.ts b/lib/supabase/account_workspace_ids/__tests__/selectAccountWorkspaceId.test.ts new file mode 100644 index 00000000..e3611d22 --- /dev/null +++ b/lib/supabase/account_workspace_ids/__tests__/selectAccountWorkspaceId.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectAccountWorkspaceId } from "../selectAccountWorkspaceId"; + +vi.mock("../../serverClient", () => { + const mockFrom = vi.fn(); + return { + default: { from: mockFrom }, + }; +}); + +import supabase from "../../serverClient"; + +describe("selectAccountWorkspaceId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return the row when account owns the workspace", async () => { + const row = { workspace_id: "ws-123" }; + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: row, error: null }), + }), + }), + }), + } as never); + + const result = await selectAccountWorkspaceId("account-123", "ws-123"); + + expect(supabase.from).toHaveBeenCalledWith("account_workspace_ids"); + expect(result).toEqual(row); + }); + + it("should return null when no ownership exists", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), + }), + }), + }), + } as never); + + const result = await selectAccountWorkspaceId("account-123", "ws-123"); + + expect(result).toBeNull(); + }); + + it("should return null on database error", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: null, + error: new Error("DB error"), + }), + }), + }), + }), + } as never); + + const result = await selectAccountWorkspaceId("account-123", "ws-123"); + + expect(result).toBeNull(); + }); +}); diff --git a/lib/supabase/account_workspace_ids/selectAccountWorkspaceId.ts b/lib/supabase/account_workspace_ids/selectAccountWorkspaceId.ts new file mode 100644 index 00000000..298a1dfb --- /dev/null +++ b/lib/supabase/account_workspace_ids/selectAccountWorkspaceId.ts @@ -0,0 +1,26 @@ +import supabase from "../serverClient"; + +/** + * Select a single account_workspace_ids row for a specific account and workspace. + * + * @param accountId - The account ID + * @param workspaceId - The workspace ID + * @returns The row if found, null if not found or on error + */ +export async function selectAccountWorkspaceId( + accountId: string, + workspaceId: string, +) { + const { data, error } = await supabase + .from("account_workspace_ids") + .select("workspace_id") + .eq("account_id", accountId) + .eq("workspace_id", workspaceId) + .maybeSingle(); + + if (error) { + return null; + } + + return data; +} diff --git a/lib/supabase/artist_organization_ids/__tests__/selectArtistOrganizationIds.test.ts b/lib/supabase/artist_organization_ids/__tests__/selectArtistOrganizationIds.test.ts new file mode 100644 index 00000000..b1df5714 --- /dev/null +++ b/lib/supabase/artist_organization_ids/__tests__/selectArtistOrganizationIds.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectArtistOrganizationIds } from "../selectArtistOrganizationIds"; + +vi.mock("../../serverClient", () => { + const mockFrom = vi.fn(); + return { + default: { from: mockFrom }, + }; +}); + +import supabase from "../../serverClient"; + +describe("selectArtistOrganizationIds", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return organization IDs for an artist", async () => { + const rows = [{ organization_id: "org-1" }, { organization_id: "org-2" }]; + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ data: rows, error: null }), + }), + } as never); + + const result = await selectArtistOrganizationIds("artist-123"); + + expect(supabase.from).toHaveBeenCalledWith("artist_organization_ids"); + expect(result).toEqual(rows); + }); + + it("should return empty array when artist has no organizations", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ data: [], error: null }), + }), + } as never); + + const result = await selectArtistOrganizationIds("artist-123"); + + expect(result).toEqual([]); + }); + + it("should return null on database error", async () => { + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ + data: null, + error: new Error("DB error"), + }), + }), + } as never); + + const result = await selectArtistOrganizationIds("artist-123"); + + expect(result).toBeNull(); + }); +}); diff --git a/lib/supabase/artist_organization_ids/selectArtistOrganizationIds.ts b/lib/supabase/artist_organization_ids/selectArtistOrganizationIds.ts new file mode 100644 index 00000000..71ed39e7 --- /dev/null +++ b/lib/supabase/artist_organization_ids/selectArtistOrganizationIds.ts @@ -0,0 +1,20 @@ +import supabase from "../serverClient"; + +/** + * Select all organization IDs for a given artist. + * + * @param artistId - The artist ID + * @returns Array of rows with organization_id, or null on error + */ +export async function selectArtistOrganizationIds(artistId: string) { + const { data, error } = await supabase + .from("artist_organization_ids") + .select("organization_id") + .eq("artist_id", artistId); + + if (error) { + return null; + } + + return data || []; +}