Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 30 additions & 0 deletions app/api/artists/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { deleteArtistHandler } from "@/lib/artists/deleteArtistHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A NextResponse with CORS headers.
*/
export async function OPTIONS() {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Module should export a single primary function whose name matches the filename

This module exports multiple top-level functions (OPTIONS and DELETE) instead of a single primary export matching the filename route, violating the single-export module rule. Split the handlers into separate files or rename the primary export to match the filename.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/api/artists/[id]/route.ts, line 10:

<comment>This module exports multiple top-level functions (`OPTIONS` and `DELETE`) instead of a single primary export matching the filename `route`, violating the single-export module rule. Split the handlers into separate files or rename the primary export to match the filename.</comment>

<file context>
@@ -0,0 +1,30 @@
+ *
+ * @returns A NextResponse with CORS headers.
+ */
+export async function OPTIONS() {
+  return new NextResponse(null, {
+    status: 200,
</file context>
Fix with Cubic

return new NextResponse(null, {
status: 200,
headers: getCorsHeaders(),
});
}

/**
* DELETE /api/artists/{id}
*
* Removes the authenticated account's direct artist link and deletes the artist
* account if that link was the last remaining owner association.
*
* @param request - The request object
* @param options - Route options containing params
* @param options.params - Route params containing the artist account ID
* @returns A NextResponse with the delete result
*/
export async function DELETE(request: NextRequest, options: { params: Promise<{ id: string }> }) {
return deleteArtistHandler(request, options.params);
}
62 changes: 62 additions & 0 deletions lib/artists/__tests__/deleteArtistHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";

import { deleteArtistHandler } from "../deleteArtistHandler";
import { validateDeleteArtistRequest } from "../validateDeleteArtistRequest";
import { deleteArtist } from "../deleteArtist";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));

vi.mock("../validateDeleteArtistRequest", () => ({
validateDeleteArtistRequest: vi.fn(),
}));

vi.mock("../deleteArtist", () => ({
deleteArtist: vi.fn(),
}));

describe("deleteArtistHandler", () => {
const artistId = "550e8400-e29b-41d4-a716-446655440000";
const requesterAccountId = "660e8400-e29b-41d4-a716-446655440000";

beforeEach(() => {
vi.clearAllMocks();
});

it("returns the validation response when request validation fails", async () => {
const validationError = NextResponse.json({ error: "Unauthorized" }, { status: 401 });
vi.mocked(validateDeleteArtistRequest).mockResolvedValue(validationError);

const request = new NextRequest(`http://localhost/api/artists/${artistId}`, {
method: "DELETE",
});

const response = await deleteArtistHandler(request, Promise.resolve({ id: artistId }));

expect(response).toBe(validationError);
expect(deleteArtist).not.toHaveBeenCalled();
});

it("returns success when the artist is deleted", async () => {
vi.mocked(validateDeleteArtistRequest).mockResolvedValue({
artistId,
requesterAccountId,
});
vi.mocked(deleteArtist).mockResolvedValue(artistId);

const request = new NextRequest(`http://localhost/api/artists/${artistId}`, {
method: "DELETE",
});

const response = await deleteArtistHandler(request, Promise.resolve({ id: artistId }));
const body = await response.json();

expect(response.status).toBe(200);
expect(body).toEqual({
success: true,
artistId,
});
});
});
128 changes: 128 additions & 0 deletions lib/artists/__tests__/validateDeleteArtistRequest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";

import { validateDeleteArtistRequest } from "../validateDeleteArtistRequest";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts";
import { checkAccountArtistAccess } from "../checkAccountArtistAccess";

vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: vi.fn(),
}));

vi.mock("@/lib/supabase/accounts/selectAccounts", () => ({
selectAccounts: vi.fn(),
}));

vi.mock("../checkAccountArtistAccess", () => ({
checkAccountArtistAccess: vi.fn(),
}));

describe("validateDeleteArtistRequest", () => {
const validArtistId = "550e8400-e29b-41d4-a716-446655440000";
const authenticatedAccountId = "660e8400-e29b-41d4-a716-446655440000";

beforeEach(() => {
vi.clearAllMocks();
});

it("returns a 400 response when the artist id is invalid", async () => {
const request = new NextRequest("http://localhost/api/artists/not-a-uuid", {
method: "DELETE",
headers: {
Authorization: "Bearer test-token",
},
});

const result = await validateDeleteArtistRequest(request, "not-a-uuid");

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(400);
expect(validateAuthContext).not.toHaveBeenCalled();
});

it("returns the auth error when authentication fails", async () => {
const authError = NextResponse.json({ error: "Unauthorized" }, { status: 401 });
vi.mocked(validateAuthContext).mockResolvedValue(authError);

const request = new NextRequest(`http://localhost/api/artists/${validArtistId}`, {
method: "DELETE",
headers: {
Authorization: "Bearer test-token",
},
});

const result = await validateDeleteArtistRequest(request, validArtistId);

expect(result).toBe(authError);
expect(validateAuthContext).toHaveBeenCalledWith(request);
});

it("returns 404 when the artist does not exist", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: authenticatedAccountId,
authToken: "test-token",
orgId: null,
});
vi.mocked(selectAccounts).mockResolvedValue([]);

const request = new NextRequest(`http://localhost/api/artists/${validArtistId}`, {
method: "DELETE",
headers: {
Authorization: "Bearer test-token",
},
});

const result = await validateDeleteArtistRequest(request, validArtistId);

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(404);
expect(checkAccountArtistAccess).not.toHaveBeenCalled();
});

it("returns 403 when the requester cannot access the artist", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: authenticatedAccountId,
authToken: "test-token",
orgId: null,
});
vi.mocked(selectAccounts).mockResolvedValue([{ id: validArtistId }] as never);
vi.mocked(checkAccountArtistAccess).mockResolvedValue(false);

const request = new NextRequest(`http://localhost/api/artists/${validArtistId}`, {
method: "DELETE",
headers: {
Authorization: "Bearer test-token",
},
});

const result = await validateDeleteArtistRequest(request, validArtistId);

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(403);
});

it("returns the validated artist and requester account ids", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: authenticatedAccountId,
authToken: "test-token",
orgId: null,
});
vi.mocked(selectAccounts).mockResolvedValue([{ id: validArtistId }] as never);
vi.mocked(checkAccountArtistAccess).mockResolvedValue(true);

const request = new NextRequest(`http://localhost/api/artists/${validArtistId}`, {
method: "DELETE",
headers: {
Authorization: "Bearer test-token",
},
});

const result = await validateDeleteArtistRequest(request, validArtistId);

expect(result).toEqual({
artistId: validArtistId,
requesterAccountId: authenticatedAccountId,
});
});
});
40 changes: 40 additions & 0 deletions lib/artists/deleteArtist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { deleteAccountArtistId } from "@/lib/supabase/account_artist_ids/deleteAccountArtistId";
import { getAccountArtistIds } from "@/lib/supabase/account_artist_ids/getAccountArtistIds";
import { deleteAccountById } from "@/lib/supabase/accounts/deleteAccountById";

export interface DeleteArtistParams {
artistId: string;
requesterAccountId: string;
}

/**
* Deletes an artist for an already validated requester.
*
* The validator is responsible for existence and access checks. This helper
* only removes the direct owner link and deletes the artist account if that
* link was the last remaining association.
*
* @param params - Delete artist parameters
* @param params.artistId - Artist account ID to remove
* @param params.requesterAccountId - Authenticated account performing the delete
* @returns The deleted artist account ID
*/
export async function deleteArtist({
artistId,
requesterAccountId,
}: DeleteArtistParams): Promise<string> {
const deletedLinks = await deleteAccountArtistId(requesterAccountId, artistId);
if (!deletedLinks.length) {
throw new Error("Failed to delete artist link");
}

const remainingLinks = await getAccountArtistIds({
artistIds: [artistId],
});

if (remainingLinks.length === 0) {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: getAccountArtistIds returns [] on query errors, but this code treats an empty result as “no remaining links” and deletes the artist account. A transient DB error would incorrectly hard-delete an artist. Consider surfacing errors (throw/return null) and aborting the delete when the lookup fails.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/artists/deleteArtist.ts, line 60:

<comment>`getAccountArtistIds` returns `[]` on query errors, but this code treats an empty result as “no remaining links” and deletes the artist account. A transient DB error would incorrectly hard-delete an artist. Consider surfacing errors (throw/return null) and aborting the delete when the lookup fails.</comment>

<file context>
@@ -0,0 +1,68 @@
+    artistIds: [artistId],
+  });
+
+  if (remainingLinks.length === 0) {
+    await deleteAccountById(artistId);
+  }
</file context>
Fix with Cubic

await deleteAccountById(artistId);
}
Comment on lines +31 to +37
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make orphan-check + account delete atomic.

Line 31 and Line 35-36 perform a read-then-delete sequence without transaction boundaries. Under concurrency, duplicate or stale decisions are possible. Move this into one atomic DB operation (transaction/RPC) so “delete if no remaining links” is evaluated and executed together.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/artists/deleteArtist.ts` around lines 31 - 37, The current
read-then-delete using getAccountArtistIds(...) and deleteAccountById(artistId)
is racy; make the orphan-check and deletion atomic by moving the logic into a
single transactional DB operation or RPC. Replace the two-step flow with one DB
call (e.g., add a new function like deleteAccountIfNoArtistLinks(accountId) or a
transaction wrapper used by deleteArtist) that rechecks the related links and
performs DELETE only when none exist (for example: a transaction or single SQL
DELETE ... WHERE id = $1 AND NOT EXISTS (SELECT 1 FROM account_artists WHERE
artist_id = $1)). Ensure the new function is used instead of calling
getAccountArtistIds and deleteAccountById separately to avoid concurrency races.


return artistId;
}
53 changes: 53 additions & 0 deletions lib/artists/deleteArtistHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { deleteArtist } from "@/lib/artists/deleteArtist";
import { validateDeleteArtistRequest } from "@/lib/artists/validateDeleteArtistRequest";

/**
* Handler for DELETE /api/artists/{id}.
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: API Design Consistency and Maintainability

Rule 1 requires POST for actions/mutations, but this new handler is explicitly for a DELETE mutation (DELETE /api/artists/{id}). Use POST for this mutation (or rename the endpoint/handler accordingly) to keep HTTP methods consistent with the API design rule.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/artists/deleteArtistHandler.ts, line 7:

<comment>Rule 1 requires POST for actions/mutations, but this new handler is explicitly for a DELETE mutation (`DELETE /api/artists/{id}`). Use POST for this mutation (or rename the endpoint/handler accordingly) to keep HTTP methods consistent with the API design rule.</comment>

<file context>
@@ -0,0 +1,79 @@
+import { validateDeleteArtistRequest } from "@/lib/artists/validateDeleteArtistRequest";
+
+/**
+ * Handler for DELETE /api/artists/{id}.
+ *
+ * Removes the authenticated account's direct link to an artist. If that link
</file context>
Fix with Cubic

*
* Removes the authenticated account's direct link to an artist. If that link
* was the last remaining owner link, the artist account is deleted as well.
*
* @param request - The incoming request
* @param params - Route params containing the artist account ID
* @returns A NextResponse with the delete result or an error
*/
export async function deleteArtistHandler(
request: NextRequest,
params: Promise<{ id: string }>,
): Promise<NextResponse> {
try {
const { id } = await params;

const validated = await validateDeleteArtistRequest(request, id);
if (validated instanceof NextResponse) {
return validated;
}

const artistId = await deleteArtist(validated);

return NextResponse.json(
{
success: true,
artistId,
},
{
status: 200,
headers: getCorsHeaders(),
},
);
} catch (error) {
console.error("[ERROR] deleteArtistHandler:", error);
return NextResponse.json(
{
status: "error",
error: error instanceof Error ? error.message : "Internal server error",
},
{
status: 500,
headers: getCorsHeaders(),
},
);
}
}
69 changes: 69 additions & 0 deletions lib/artists/validateDeleteArtistRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
import { validateAccountParams } from "@/lib/accounts/validateAccountParams";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess";
import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts";

export interface DeleteArtistRequest {
artistId: string;
requesterAccountId: string;
}

/**
* Validates DELETE /api/artists/{id} path params and authentication.
*
* @param request - The incoming request
* @param id - The artist account ID from the route
* @returns The validated artist ID plus requester context, or a NextResponse error
*/
export async function validateDeleteArtistRequest(
request: NextRequest,
id: string,
): Promise<DeleteArtistRequest | NextResponse> {
const validatedParams = validateAccountParams(id);
if (validatedParams instanceof NextResponse) {
return validatedParams;
}

const authResult = await validateAuthContext(request);
if (authResult instanceof NextResponse) {
return authResult;
}

const artistId = validatedParams.id;
const requesterAccountId = authResult.accountId;

const existingArtist = await selectAccounts(artistId);
if (!existingArtist.length) {
return NextResponse.json(
{
status: "error",
error: "Artist not found",
},
{
status: 404,
headers: getCorsHeaders(),
},
);
}

const hasAccess = await checkAccountArtistAccess(requesterAccountId, artistId);
if (!hasAccess) {
return NextResponse.json(
{
status: "error",
error: "Unauthorized delete attempt",
},
{
status: 403,
headers: getCorsHeaders(),
},
);
}
Comment on lines +51 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Enforce direct-link ownership here, not broad artist access.

checkAccountArtistAccess allows organization-based access, but delete semantics require a removable direct link. This can pass validation and still fail in delete with a 500 path. Return 403 here when no direct account↔artist link exists.

Proposed change
-import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess";
+import { selectAccountArtistId } from "@/lib/supabase/account_artist_ids/selectAccountArtistId";
@@
-  const hasAccess = await checkAccountArtistAccess(requesterAccountId, artistId);
-  if (!hasAccess) {
+  const directLink = await selectAccountArtistId(requesterAccountId, artistId);
+  if (!directLink) {
     return NextResponse.json(
       {
         status: "error",
         error: "Unauthorized delete attempt",
       },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/artists/validateDeleteArtistRequest.ts` around lines 51 - 63, The current
validation uses checkAccountArtistAccess(requesterAccountId, artistId) which
permits org-level access; change it to enforce a direct account↔artist link by
calling a direct-link check (e.g.,
checkAccountArtistDirectLink(requesterAccountId, artistId)) or extend
checkAccountArtistAccess with a flag to requireDirect=true, and if that
direct-link check returns false return the same 403 JSON error response
(status:"error", error:"Unauthorized delete attempt") with CORS headers; update
any imports or helper usage in validateDeleteArtistRequest to reference the
direct-link helper name used.


return {
artistId,
requesterAccountId,
};
}
Loading
Loading