Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
3c18d64
feat: [US-002] Create Supabase functions for artist_composio_connections
sidneyswift Jan 29, 2026
fd74598
feat: [US-003] Create GET /api/artist-connectors endpoint
sidneyswift Jan 29, 2026
ca25ef5
feat: [US-004] Create POST /api/artist-connectors/authorize endpoint
sidneyswift Jan 29, 2026
cceffd4
feat: [US-005] Add DELETE /api/artist-connectors endpoint
sidneyswift Jan 29, 2026
5445abb
feat: [US-006] Add artist-connectors callback URL destination
sidneyswift Jan 29, 2026
ac6da1d
feat: [US-007] Add TikTok to enabled toolkits
sidneyswift Jan 29, 2026
b276a7a
feat: [US-008] Modify createSession to accept connectedAccounts
sidneyswift Jan 29, 2026
e359693
feat: [US-009] Wire up artistId in chat tool setup
sidneyswift Jan 29, 2026
e39c59d
feat: [US-014] Handle OAuth callback with complete endpoint
sidneyswift Jan 29, 2026
6f51f40
refactor: use artistId as Composio entity for artist connections
sidneyswift Jan 29, 2026
933a702
fix: resolve TypeScript errors with readonly array types
sidneyswift Jan 29, 2026
23248d4
refactor: DRY up composio connectors - unified authorizeConnector, ge…
sidneyswift Jan 31, 2026
c7e4773
refactor: unify /api/connectors with entity_type support, delete /api…
sidneyswift Jan 31, 2026
3b404a4
refactor: address code review - SRP handlers, unified validators, tes…
sidneyswift Feb 1, 2026
3a9858b
docs: add API route patterns and reviewer principles to CLAUDE.md
sidneyswift Feb 1, 2026
c12250c
Merge remote-tracking branch 'origin/test' into feat/artist-composio-…
sidneyswift Feb 2, 2026
fbf424d
fix: update setupToolsForRequest test for new artistId parameter
sidneyswift Feb 2, 2026
fa577e7
refactor: extract getArtistConnectionsFromComposio to own file (SRP)
sidneyswift Feb 4, 2026
93639d9
refactor: rename createSession.ts to createToolRouterSession.ts
sidneyswift Feb 4, 2026
7eec10a
test: add unit tests for composio toolRouter changes
sidneyswift Feb 4, 2026
cf0616e
test: add comprehensive unit tests for all changed files
sidneyswift Feb 4, 2026
92b1733
fix: revert validateCreateChatBody.test.ts to original (no changes ne…
sidneyswift Feb 5, 2026
426382f
refactor: remove entity_type, use entity_id presence for connection type
sidneyswift Feb 5, 2026
f066eb2
fix: remove unrelated changes, keep only composio feature
sidneyswift Feb 5, 2026
fd93442
Merge remote-tracking branch 'origin/main' into feat/artist-composio-…
sidneyswift Feb 5, 2026
76fcfd4
fix: remove entity_type from JSDoc comments in route files
sidneyswift Feb 5, 2026
42d7b85
fix: replace 'user' with 'account' in all composio JSDoc comments
sidneyswift Feb 5, 2026
26a30e6
refactor: use validateAuthContext instead of validateAccountIdHeaders
sidneyswift Feb 5, 2026
9cef620
fix: restore artistId wiring and add access check in getComposioTools
sidneyswift Feb 5, 2026
ddc5eab
refactor: remove entity-connectors callback URL logic (deferred to se…
sidneyswift Feb 5, 2026
3b8e628
Merge remote-tracking branch 'origin/test' into feat/artist-composio-…
sweetmantech Feb 5, 2026
29b1cdf
refactor: rename entity_id to account_id in connectors API
sidneyswift Feb 5, 2026
0752c37
feat: broaden account_id access to support self, artist, workspace, a…
sidneyswift Feb 10, 2026
1d223fa
docs: add terminology and flat response shape rules to CLAUDE.md
sidneyswift Feb 10, 2026
74265d0
refactor: move connector restrictions from API to tool router level
sidneyswift Feb 10, 2026
ac82877
Merge pull request #220 from recoupable/test
sweetmantech Feb 12, 2026
c1130ca
Merge remote-tracking branch 'origin/main' into feat/artist-composio-…
sweetmantech Feb 12, 2026
f9ef320
refactor: move POST /api/connectors/authorize to POST /api/connectors
sweetmantech Feb 12, 2026
20389c6
refactor: extract standalone supabase queries from checkAccountArtist…
sweetmantech Feb 12, 2026
ab95783
refactor: extract selectAccountWorkspaceId from checkAccountWorkspace…
sweetmantech Feb 12, 2026
f077d25
refactor: rename entityId to accountId across all connector code
sweetmantech Feb 12, 2026
0f74641
refactor: remove unused allowedToolkits filtering from getConnectors
sweetmantech Feb 12, 2026
f380a4b
fix: correct misleading docstring in validateDisconnectConnectorBody
sweetmantech Feb 12, 2026
a213e31
refactor: move checkAccountArtistAccess to lib/artists/
sweetmantech Feb 12, 2026
bbd5415
refactor: delete checkAccountWorkspaceAccess wrapper, use selectAccou…
sweetmantech Feb 12, 2026
f8e8bbc
fix: filter artist connections locally after allowedToolkits removal
sweetmantech Feb 12, 2026
acf019d
fix: add missing_fields to connector validation error responses
sweetmantech Feb 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
67 changes: 0 additions & 67 deletions app/api/connectors/authorize/route.ts

This file was deleted.

110 changes: 35 additions & 75 deletions app/api/connectors/route.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -20,90 +18,52 @@ 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<NextResponse> {
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);
}

/**
* DELETE /api/connectors
*
* 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<NextResponse> {
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);
}
81 changes: 81 additions & 0 deletions lib/artists/__tests__/checkAccountArtistAccess.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
44 changes: 44 additions & 0 deletions lib/artists/checkAccountArtistAccess.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
// 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;
}
Loading