Skip to content

Commit 06a8b9a

Browse files
sidneyswiftclaudesweetmantech
authored
feat: Artist TikTok Connections via Composio (#170)
* feat: [US-002] Create Supabase functions for artist_composio_connections - Add artist_composio_connections type to database.types.ts - Create selectArtistComposioConnection.ts (single lookup by artist+toolkit) - Create selectArtistComposioConnections.ts (all connections for artist) - Create insertArtistComposioConnection.ts (upsert on unique constraint) - Create deleteArtistComposioConnection.ts (delete by id) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: [US-003] Create GET /api/artist-connectors endpoint - Add ALLOWED_ARTIST_CONNECTORS constant with 'tiktok' as first connector - Create checkAccountArtistAccess function in Recoup-API (migrated from Recoup-Chat) - Create getArtistConnectors function to return connector status for artists - Create GET /api/artist-connectors endpoint with: - Bearer token and API key auth via validateAuthContext - Artist access validation via checkAccountArtistAccess - Returns list of allowed connectors with connection status Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: [US-004] Create POST /api/artist-connectors/authorize endpoint - Add validateAuthorizeArtistConnectorBody.ts with Zod schema for request validation - Add authorizeArtistConnector.ts to generate OAuth URLs via Composio - Add POST /api/artist-connectors/authorize route with auth and access control - Callback URL redirects to /chat?artist_connected={artistId}&toolkit={slug} Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: [US-005] Add DELETE /api/artist-connectors endpoint - Add DELETE handler to disconnect an artist's connector from Composio - Create validateDisconnectArtistConnectorBody.ts with Zod schema - Create verifyArtistConnectorOwnership.ts to check connection ownership - Create disconnectArtistConnector.ts to remove from Composio and DB Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: [US-006] Add artist-connectors callback URL destination - Add 'artist-connectors' to CallbackDestination type in getCallbackUrl.ts - Add artistId and toolkit to CallbackOptions interface - Handle artist-connectors destination returning /chat?artist_connected={artistId}&toolkit={toolkit} - Update authorizeArtistConnector.ts to use getCallbackUrl instead of local function Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: [US-007] Add TikTok to enabled toolkits Add 'tiktok' to the ENABLED_TOOLKITS array so TikTok tools are available in Tool Router sessions, enabling the LLM to access TikTok data. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: [US-008] Modify createSession to accept connectedAccounts - Add artistConnections parameter to createToolRouterSession (Record<string, string> | undefined) - Pass connectedAccounts option to composio.create() call - Update getComposioTools to accept and pass artistConnections parameter - Add JSDoc documentation for the new parameter This enables artist-specific Composio connections to be used when creating Tool Router sessions, allowing the LLM to use the correct TikTok account for the selected artist. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: [US-009] Wire up artistId in chat tool setup - Modified getComposioTools to accept artistId parameter - If artistId provided, fetches artist_composio_connections from DB - Transforms connections to Record<string, string> format - Passes artistConnections to createToolRouterSession - Modified setupToolsForRequest to extract artistId from body - Passes artistId through to getComposioTools Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: [US-014] Handle OAuth callback with complete endpoint Add POST /api/artist-connectors/complete endpoint to finalize OAuth flow: - Query Composio for the user's connected account after OAuth redirect - Store the connection mapping in artist_composio_connections table - Add Zod validation for request body (artist_id, toolkit_slug) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: use artistId as Composio entity for artist connections - Remove artist_composio_connections table and related code - Remove /complete endpoint (no longer needed) - Use artistId directly as Composio entity when connecting - Query Composio at chat time for artist connections - Pass connections to user session via connectedAccounts Composio is now the source of truth for artist connections. * fix: resolve TypeScript errors with readonly array types * refactor: DRY up composio connectors - unified authorizeConnector, getConnectors, disconnectConnector * refactor: unify /api/connectors with entity_type support, delete /api/artist-connectors * refactor: address code review - SRP handlers, unified validators, tests, file rename * docs: add API route patterns and reviewer principles to CLAUDE.md - Add TDD to code principles - Document thin route files pattern (follow /api/pulses) - Document handler functions pattern - Document combined request validators (validateXxxRequest) - Add DRY guidance for entity types (use options, not duplicate files) - Add file naming convention (name after function, not constant) - Add testing requirements for API changes * fix: update setupToolsForRequest test for new artistId parameter - Merge test branch to sync with base - Update test to expect (accountId, artistId, roomId) signature - Add test case for when artistId is provided * refactor: extract getArtistConnectionsFromComposio to own file (SRP) * refactor: rename createSession.ts to createToolRouterSession.ts * test: add unit tests for composio toolRouter changes * test: add comprehensive unit tests for all changed files Added tests for: - Handlers: authorizeConnectorHandler, disconnectConnectorHandler, getConnectorsHandler - Validators: validateGetConnectorsQuery, validateAuthorizeConnectorBody, validateDisconnectConnectorBody, validateCreateChatBody - Core functions: authorizeConnector, disconnectConnector, getConnectors, isAllowedArtistConnector - Utilities: getCallbackUrl, checkAccountArtistAccess, createNewRoom, createToolRouterSession Total: 120 tests passing * fix: revert validateCreateChatBody.test.ts to original (no changes needed) * refactor: remove entity_type, use entity_id presence for connection type - Remove entity_type parameter from API (authorize, disconnect, get connectors) - Infer isEntityConnection from entity_id presence instead - Update callback URLs: entity connections → /chat?artist_connected=... - Rename 'user' → 'account' in tests for consistency - All 892 tests pass * fix: remove unrelated changes, keep only composio feature Reverted all non-composio files to main: - Removed createNewRoom.test.ts (unrelated) - Removed evals.yml (unrelated) - Restored all lib/chat, lib/chats, lib/sandbox, lib/tasks, lib/trigger files - Restored package.json, pnpm-lock.yaml, types/database.types.ts PR now contains only: - lib/composio/* (connectors + toolRouter) - app/api/connectors/* - lib/supabase/account_artist_ids/* * fix: remove entity_type from JSDoc comments in route files * fix: replace 'user' with 'account' in all composio JSDoc comments * refactor: use validateAuthContext instead of validateAccountIdHeaders Updated all connector validation files to use validateAuthContext() for consistent auth handling across the codebase: - validateAuthorizeConnectorRequest.ts - validateDisconnectConnectorRequest.ts - validateGetConnectorsRequest.ts - Updated corresponding test files validateAuthContext supports both x-api-key and Bearer token auth, plus account_id override and organization access validation. * fix: restore artistId wiring and add access check in getComposioTools - Restored artistId parameter in setupToolsForRequest (accidentally reverted) - Added checkAccountArtistAccess() before fetching artist connections - If access denied, silently skips artist connections (no throw) - Updated tests for both setupToolsForRequest and getComposioTools * refactor: remove entity-connectors callback URL logic (deferred to separate PR) * refactor: rename entity_id to account_id in connectors API * feat: broaden account_id access to support self, artist, workspace, and org entities - Create checkAccountAccess in lib/auth for unified access check - Create checkAccountWorkspaceAccess in supabase lib - Move artist connector restriction from body validator to request validator - Add message field to disconnect response - Update all tests for broadened access patterns * docs: add terminology and flat response shape rules to CLAUDE.md * refactor: move connector restrictions from API to tool router level - API is now unopinionated: any account can connect any service - Tool router handles collision prevention in createToolRouterSession - Account connections always win over artist connections (no duplicates) - Remove allowedToolkits from GET/authorize validation - Update tests for new architecture * refactor: move POST /api/connectors/authorize to POST /api/connectors Consolidates the authorize endpoint into the main connectors route to match the updated API docs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract standalone supabase queries from checkAccountArtistAccess SRP: each query is now its own file with dedicated tests: - selectAccountArtistId (account_artist_ids) - selectArtistOrganizationIds (artist_organization_ids) - selectAccountOrganizationIds (account_organization_ids) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract selectAccountWorkspaceId from checkAccountWorkspaceAccess SRP: standalone supabase query with dedicated tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: rename entityId to accountId across all connector code Use "account" terminology consistently per codebase conventions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: remove unused allowedToolkits filtering from getConnectors No caller uses this option — YAGNI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct misleading docstring in validateDisconnectConnectorBody The comment claimed ownership verification that the function doesn't do. Updated to reflect it only validates request shape. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: move checkAccountArtistAccess to lib/artists/ Not a direct Supabase query — it aggregates supabase calls, so it belongs in the domain layer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: delete checkAccountWorkspaceAccess wrapper, use selectAccountWorkspaceId directly YAGNI — the wrapper was just !!data. The sole consumer now calls the supabase query directly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: filter artist connections locally after allowedToolkits removal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add missing_fields to connector validation error responses Adds custom Zod messages and missing_fields/status fields to error responses for both POST and DELETE /api/connectors validation, matching the standard pattern used across other API endpoints. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Sweets Sweetman <sweetmantech@gmail.com>
1 parent 322cc70 commit 06a8b9a

File tree

52 files changed

+2935
-224
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+2935
-224
lines changed

CLAUDE.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,25 @@ export async function selectTableName({
144144
- All API routes should have JSDoc comments
145145
- Run `pnpm lint` before committing
146146

147+
### Terminology
148+
149+
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:
150+
151+
-`account_id`, "artist", "workspace", "organization"
152+
-`entity_id`, "entity", "user"
153+
154+
### API Response Shapes
155+
156+
Keep response bodies **flat** — put fields at the root level, not nested inside a `data` wrapper:
157+
158+
```typescript
159+
// ✅ Correct — flat response
160+
{ success: true, connectors: [...] }
161+
162+
// ❌ Wrong — unnecessary nesting
163+
{ success: true, data: { connectors: [...] } }
164+
```
165+
147166
## Test-Driven Development (TDD)
148167

149168
**CRITICAL: Always write tests BEFORE implementing new features or fixing bugs.**

app/api/connectors/authorize/route.ts

Lines changed: 0 additions & 67 deletions
This file was deleted.

app/api/connectors/route.ts

Lines changed: 35 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import type { NextRequest } from "next/server";
22
import { NextResponse } from "next/server";
33
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
4-
import { getConnectors } from "@/lib/composio/connectors";
5-
import { disconnectConnector } from "@/lib/composio/connectors/disconnectConnector";
6-
import { validateDisconnectConnectorBody } from "@/lib/composio/connectors/validateDisconnectConnectorBody";
7-
import { verifyConnectorOwnership } from "@/lib/composio/connectors/verifyConnectorOwnership";
8-
import { validateAccountIdHeaders } from "@/lib/accounts/validateAccountIdHeaders";
4+
import { getConnectorsHandler } from "@/lib/composio/connectors/getConnectorsHandler";
5+
import { authorizeConnectorHandler } from "@/lib/composio/connectors/authorizeConnectorHandler";
6+
import { disconnectConnectorHandler } from "@/lib/composio/connectors/disconnectConnectorHandler";
97

108
/**
119
* OPTIONS handler for CORS preflight requests.
@@ -20,90 +18,52 @@ export async function OPTIONS() {
2018
/**
2119
* GET /api/connectors
2220
*
23-
* List all available connectors and their connection status for a user.
21+
* List all available connectors and their connection status.
22+
*
23+
* Query params:
24+
* - account_id (optional): Entity ID for entity-specific connections (e.g., artist ID)
2425
*
2526
* Authentication: x-api-key OR Authorization Bearer token required.
2627
*
28+
* @param request
2729
* @returns List of connectors with connection status
2830
*/
29-
export async function GET(request: NextRequest): Promise<NextResponse> {
30-
const headers = getCorsHeaders();
31-
32-
try {
33-
const authResult = await validateAccountIdHeaders(request);
34-
if (authResult instanceof NextResponse) {
35-
return authResult;
36-
}
37-
38-
const { accountId } = authResult;
39-
40-
const connectors = await getConnectors(accountId);
31+
export async function GET(request: NextRequest) {
32+
return getConnectorsHandler(request);
33+
}
4134

42-
return NextResponse.json(
43-
{
44-
success: true,
45-
data: {
46-
connectors,
47-
},
48-
},
49-
{ status: 200, headers },
50-
);
51-
} catch (error) {
52-
const message =
53-
error instanceof Error ? error.message : "Failed to fetch connectors";
54-
return NextResponse.json({ error: message }, { status: 500, headers });
55-
}
35+
/**
36+
* POST /api/connectors
37+
*
38+
* Generate an OAuth authorization URL for a specific connector.
39+
*
40+
* Authentication: x-api-key OR Authorization Bearer token required.
41+
*
42+
* Request body:
43+
* - connector: The connector slug, e.g., "googlesheets" or "tiktok" (required)
44+
* - callback_url: Optional custom callback URL after OAuth
45+
* - account_id: Optional account ID for account-specific connections
46+
*
47+
* @param request
48+
* @returns The redirect URL for OAuth authorization
49+
*/
50+
export async function POST(request: NextRequest) {
51+
return authorizeConnectorHandler(request);
5652
}
5753

5854
/**
5955
* DELETE /api/connectors
6056
*
6157
* Disconnect a connected account from Composio.
6258
*
59+
* Body:
60+
* - connected_account_id (required): The connected account ID to disconnect
61+
* - account_id (optional): Entity ID for ownership verification (e.g., artist ID)
62+
*
6363
* Authentication: x-api-key OR Authorization Bearer token required.
6464
*
65-
* Body: { connected_account_id: string }
65+
* @param request
6666
*/
67-
export async function DELETE(request: NextRequest): Promise<NextResponse> {
68-
const headers = getCorsHeaders();
69-
70-
try {
71-
const authResult = await validateAccountIdHeaders(request);
72-
if (authResult instanceof NextResponse) {
73-
return authResult;
74-
}
75-
76-
const { accountId } = authResult;
77-
const body = await request.json();
78-
79-
const validated = validateDisconnectConnectorBody(body);
80-
if (validated instanceof NextResponse) {
81-
return validated;
82-
}
83-
84-
const { connected_account_id } = validated;
85-
86-
// Verify the connected account belongs to the authenticated user
87-
const isOwner = await verifyConnectorOwnership(accountId, connected_account_id);
88-
if (!isOwner) {
89-
return NextResponse.json(
90-
{ error: "Connected account not found or does not belong to this user" },
91-
{ status: 403, headers }
92-
);
93-
}
94-
95-
const result = await disconnectConnector(connected_account_id);
96-
97-
return NextResponse.json(
98-
{
99-
success: true,
100-
data: result,
101-
},
102-
{ status: 200, headers },
103-
);
104-
} catch (error) {
105-
const message =
106-
error instanceof Error ? error.message : "Failed to disconnect connector";
107-
return NextResponse.json({ error: message }, { status: 500, headers });
108-
}
67+
export async function DELETE(request: NextRequest) {
68+
return disconnectConnectorHandler(request);
10969
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { checkAccountArtistAccess } from "../checkAccountArtistAccess";
3+
4+
vi.mock("@/lib/supabase/account_artist_ids/selectAccountArtistId", () => ({
5+
selectAccountArtistId: vi.fn(),
6+
}));
7+
8+
vi.mock("@/lib/supabase/artist_organization_ids/selectArtistOrganizationIds", () => ({
9+
selectArtistOrganizationIds: vi.fn(),
10+
}));
11+
12+
vi.mock("@/lib/supabase/account_organization_ids/selectAccountOrganizationIds", () => ({
13+
selectAccountOrganizationIds: vi.fn(),
14+
}));
15+
16+
import { selectAccountArtistId } from "@/lib/supabase/account_artist_ids/selectAccountArtistId";
17+
import { selectArtistOrganizationIds } from "@/lib/supabase/artist_organization_ids/selectArtistOrganizationIds";
18+
import { selectAccountOrganizationIds } from "@/lib/supabase/account_organization_ids/selectAccountOrganizationIds";
19+
20+
describe("checkAccountArtistAccess", () => {
21+
beforeEach(() => {
22+
vi.clearAllMocks();
23+
});
24+
25+
it("should return true when account has direct access to artist", async () => {
26+
vi.mocked(selectAccountArtistId).mockResolvedValue({ artist_id: "artist-123" });
27+
28+
const result = await checkAccountArtistAccess("account-123", "artist-123");
29+
30+
expect(selectAccountArtistId).toHaveBeenCalledWith("account-123", "artist-123");
31+
expect(result).toBe(true);
32+
expect(selectArtistOrganizationIds).not.toHaveBeenCalled();
33+
});
34+
35+
it("should return true when account and artist share an organization", async () => {
36+
vi.mocked(selectAccountArtistId).mockResolvedValue(null);
37+
vi.mocked(selectArtistOrganizationIds).mockResolvedValue([
38+
{ organization_id: "org-1" },
39+
]);
40+
vi.mocked(selectAccountOrganizationIds).mockResolvedValue([
41+
{ organization_id: "org-1" },
42+
]);
43+
44+
const result = await checkAccountArtistAccess("account-123", "artist-456");
45+
46+
expect(selectArtistOrganizationIds).toHaveBeenCalledWith("artist-456");
47+
expect(selectAccountOrganizationIds).toHaveBeenCalledWith("account-123", ["org-1"]);
48+
expect(result).toBe(true);
49+
});
50+
51+
it("should return false when artist org lookup errors (fail closed)", async () => {
52+
vi.mocked(selectAccountArtistId).mockResolvedValue(null);
53+
vi.mocked(selectArtistOrganizationIds).mockResolvedValue(null);
54+
55+
const result = await checkAccountArtistAccess("account-123", "artist-123");
56+
57+
expect(result).toBe(false);
58+
});
59+
60+
it("should return false when account has no access", async () => {
61+
vi.mocked(selectAccountArtistId).mockResolvedValue(null);
62+
vi.mocked(selectArtistOrganizationIds).mockResolvedValue([]);
63+
64+
const result = await checkAccountArtistAccess("account-123", "artist-456");
65+
66+
expect(result).toBe(false);
67+
expect(selectAccountOrganizationIds).not.toHaveBeenCalled();
68+
});
69+
70+
it("should return false when account org lookup errors (fail closed)", async () => {
71+
vi.mocked(selectAccountArtistId).mockResolvedValue(null);
72+
vi.mocked(selectArtistOrganizationIds).mockResolvedValue([
73+
{ organization_id: "org-1" },
74+
]);
75+
vi.mocked(selectAccountOrganizationIds).mockResolvedValue(null);
76+
77+
const result = await checkAccountArtistAccess("account-123", "artist-456");
78+
79+
expect(result).toBe(false);
80+
});
81+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { selectAccountArtistId } from "@/lib/supabase/account_artist_ids/selectAccountArtistId";
2+
import { selectArtistOrganizationIds } from "@/lib/supabase/artist_organization_ids/selectArtistOrganizationIds";
3+
import { selectAccountOrganizationIds } from "@/lib/supabase/account_organization_ids/selectAccountOrganizationIds";
4+
5+
/**
6+
* Check if an account has access to a specific artist.
7+
*
8+
* Access is granted if:
9+
* 1. Account has direct access via account_artist_ids, OR
10+
* 2. Account and artist share an organization
11+
*
12+
* Fails closed: returns false on any database error to deny access safely.
13+
*
14+
* @param accountId - The account ID to check
15+
* @param artistId - The artist ID to check access for
16+
* @returns true if the account has access to the artist, false otherwise
17+
*/
18+
export async function checkAccountArtistAccess(
19+
accountId: string,
20+
artistId: string,
21+
): Promise<boolean> {
22+
// 1. Check direct access via account_artist_ids
23+
const directAccess = await selectAccountArtistId(accountId, artistId);
24+
25+
if (directAccess) return true;
26+
27+
// 2. Check organization access: account and artist share an org
28+
const artistOrgs = await selectArtistOrganizationIds(artistId);
29+
30+
if (!artistOrgs) return false; // Fail closed on error
31+
32+
if (!artistOrgs.length) return false;
33+
34+
const orgIds = artistOrgs
35+
.map((o) => o.organization_id)
36+
.filter((id): id is string => Boolean(id));
37+
if (!orgIds.length) return false;
38+
39+
const userOrgAccess = await selectAccountOrganizationIds(accountId, orgIds);
40+
41+
if (!userOrgAccess) return false; // Fail closed on error
42+
43+
return !!userOrgAccess.length;
44+
}

0 commit comments

Comments
 (0)