From 3a94e88123c817dcb8a4daaf35a10ed74ee9c699 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 08:56:06 -0500 Subject: [PATCH 1/8] feat: migrate /api/artist/create REST endpoint from Recoup-Chat to recoup-api Add new GET endpoint for creating artists with query parameters: - validateCreateArtistQuery.ts: Zod validation for name + account_id - createArtistHandler.ts: handler with CORS, validation, and createArtistInDb - app/api/artist/create/route.ts: GET endpoint with OPTIONS for CORS Reuses createArtistInDb from MYC-3923 migration. Tests: 13 new unit tests (473 total) Co-Authored-By: Claude Opus 4.5 --- app/api/artist/create/route.ts | 35 ++++ .../__tests__/createArtistHandler.test.ts | 152 ++++++++++++++++++ .../validateCreateArtistQuery.test.ts | 98 +++++++++++ lib/artists/createArtistHandler.ts | 59 +++++++ lib/artists/validateCreateArtistQuery.ts | 44 +++++ 5 files changed, 388 insertions(+) create mode 100644 app/api/artist/create/route.ts create mode 100644 lib/artists/__tests__/createArtistHandler.test.ts create mode 100644 lib/artists/__tests__/validateCreateArtistQuery.test.ts create mode 100644 lib/artists/createArtistHandler.ts create mode 100644 lib/artists/validateCreateArtistQuery.ts diff --git a/app/api/artist/create/route.ts b/app/api/artist/create/route.ts new file mode 100644 index 00000000..c34dcf85 --- /dev/null +++ b/app/api/artist/create/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createArtistHandler } from "@/lib/artists/createArtistHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/artist/create + * + * Creates a new artist account and associates it with an owner account. + * + * Query parameters: + * - name (required): The name of the artist to create + * - account_id (required): The ID of the owner account (UUID) + * + * @param request - The request object containing query parameters + * @returns A NextResponse with the created artist data + */ +export async function GET(request: NextRequest) { + return createArtistHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/artists/__tests__/createArtistHandler.test.ts b/lib/artists/__tests__/createArtistHandler.test.ts new file mode 100644 index 00000000..a63e6714 --- /dev/null +++ b/lib/artists/__tests__/createArtistHandler.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockCreateArtistInDb = vi.fn(); +const mockValidateCreateArtistQuery = vi.fn(); + +vi.mock("@/lib/artists/createArtistInDb", () => ({ + createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args), +})); + +vi.mock("@/lib/artists/validateCreateArtistQuery", () => ({ + validateCreateArtistQuery: (...args: unknown[]) => + mockValidateCreateArtistQuery(...args), +})); + +import { createArtistHandler } from "../createArtistHandler"; + +describe("createArtistHandler", () => { + const mockArtist = { + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + created_at: "2026-01-15T00:00:00Z", + updated_at: "2026-01-15T00:00:00Z", + image: null, + instruction: null, + knowledges: null, + label: null, + organization: null, + company_name: null, + job_title: null, + role_type: null, + onboarding_status: null, + onboarding_data: null, + account_info: [], + account_socials: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 200 with artist data on successful creation", async () => { + const validatedQuery = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + const request = new NextRequest( + "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", + ); + + const response = await createArtistHandler(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.artist).toEqual(mockArtist); + expect(mockCreateArtistInDb).toHaveBeenCalledWith("Test Artist", "owner-456"); + }); + + it("returns validation error response when validation fails", async () => { + const { NextResponse } = await import("next/server"); + const errorResponse = NextResponse.json( + { status: "error", error: "name is required" }, + { status: 400 }, + ); + mockValidateCreateArtistQuery.mockReturnValue(errorResponse); + + const request = new NextRequest( + "http://localhost/api/artist/create?account_id=owner-456", + ); + + const response = await createArtistHandler(request); + + expect(response.status).toBe(400); + expect(mockCreateArtistInDb).not.toHaveBeenCalled(); + }); + + it("returns 500 when createArtistInDb returns null", async () => { + const validatedQuery = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); + mockCreateArtistInDb.mockResolvedValue(null); + + const request = new NextRequest( + "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", + ); + + const response = await createArtistHandler(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.message).toBe("Failed to create artist"); + }); + + it("returns 400 with error message when createArtistInDb throws", async () => { + const validatedQuery = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); + mockCreateArtistInDb.mockRejectedValue(new Error("Database error")); + + const request = new NextRequest( + "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", + ); + + const response = await createArtistHandler(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.message).toBe("Database error"); + }); + + it("includes CORS headers in successful response", async () => { + const validatedQuery = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + const request = new NextRequest( + "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", + ); + + const response = await createArtistHandler(request); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + it("includes CORS headers in error response", async () => { + const validatedQuery = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); + mockCreateArtistInDb.mockResolvedValue(null); + + const request = new NextRequest( + "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", + ); + + const response = await createArtistHandler(request); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); +}); diff --git a/lib/artists/__tests__/validateCreateArtistQuery.test.ts b/lib/artists/__tests__/validateCreateArtistQuery.test.ts new file mode 100644 index 00000000..32b76b08 --- /dev/null +++ b/lib/artists/__tests__/validateCreateArtistQuery.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { NextResponse } from "next/server"; +import { + validateCreateArtistQuery, + createArtistQuerySchema, +} from "../validateCreateArtistQuery"; + +describe("validateCreateArtistQuery", () => { + describe("name validation", () => { + it("accepts valid name parameter", () => { + const searchParams = new URLSearchParams({ + name: "Test Artist", + account_id: "123e4567-e89b-12d3-a456-426614174000", + }); + + const result = validateCreateArtistQuery(searchParams); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).name).toBe("Test Artist"); + }); + + it("rejects missing name parameter", () => { + const searchParams = new URLSearchParams({ + account_id: "123e4567-e89b-12d3-a456-426614174000", + }); + + const result = validateCreateArtistQuery(searchParams); + + expect(result).toBeInstanceOf(NextResponse); + }); + + it("rejects empty name parameter", () => { + const searchParams = new URLSearchParams({ + name: "", + account_id: "123e4567-e89b-12d3-a456-426614174000", + }); + + const result = validateCreateArtistQuery(searchParams); + + expect(result).toBeInstanceOf(NextResponse); + }); + }); + + describe("account_id validation", () => { + it("accepts valid UUID for account_id", () => { + const searchParams = new URLSearchParams({ + name: "Test Artist", + account_id: "123e4567-e89b-12d3-a456-426614174000", + }); + + const result = validateCreateArtistQuery(searchParams); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).account_id).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + }); + + it("rejects missing account_id parameter", () => { + const searchParams = new URLSearchParams({ + name: "Test Artist", + }); + + const result = validateCreateArtistQuery(searchParams); + + expect(result).toBeInstanceOf(NextResponse); + }); + + it("rejects invalid UUID for account_id", () => { + const searchParams = new URLSearchParams({ + name: "Test Artist", + account_id: "invalid-uuid", + }); + + const result = validateCreateArtistQuery(searchParams); + + expect(result).toBeInstanceOf(NextResponse); + }); + }); + + describe("schema type inference", () => { + it("schema should require both name and account_id", () => { + const validParams = { + name: "Test Artist", + account_id: "123e4567-e89b-12d3-a456-426614174000", + }; + + const result = createArtistQuerySchema.safeParse(validParams); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe("Test Artist"); + expect(result.data.account_id).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + } + }); + }); +}); diff --git a/lib/artists/createArtistHandler.ts b/lib/artists/createArtistHandler.ts new file mode 100644 index 00000000..7f502842 --- /dev/null +++ b/lib/artists/createArtistHandler.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateCreateArtistQuery } from "@/lib/artists/validateCreateArtistQuery"; +import { createArtistInDb } from "@/lib/artists/createArtistInDb"; + +/** + * Handler for creating a new artist. + * + * Query parameters: + * - name (required): The name of the artist to create + * - account_id (required): The ID of the owner account (UUID) + * + * @param request - The request object containing query parameters + * @returns A NextResponse with artist data or error + */ +export async function createArtistHandler( + request: NextRequest, +): Promise { + const { searchParams } = new URL(request.url); + + const validatedQuery = validateCreateArtistQuery(searchParams); + if (validatedQuery instanceof NextResponse) { + return validatedQuery; + } + + try { + const artist = await createArtistInDb( + validatedQuery.name, + validatedQuery.account_id, + ); + + if (!artist) { + return NextResponse.json( + { message: "Failed to create artist" }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } + + return NextResponse.json( + { artist }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "failed"; + return NextResponse.json( + { message }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } +} diff --git a/lib/artists/validateCreateArtistQuery.ts b/lib/artists/validateCreateArtistQuery.ts new file mode 100644 index 00000000..a0638114 --- /dev/null +++ b/lib/artists/validateCreateArtistQuery.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const createArtistQuerySchema = z.object({ + name: z + .string({ message: "name is required" }) + .min(1, "name cannot be empty"), + account_id: z + .string({ message: "account_id is required" }) + .uuid("account_id must be a valid UUID"), +}); + +export type CreateArtistQuery = z.infer; + +/** + * Validates query parameters for GET /api/artist/create. + * + * @param searchParams - The URL search parameters + * @returns A NextResponse with an error if validation fails, or the validated query if validation passes. + */ +export function validateCreateArtistQuery( + searchParams: URLSearchParams, +): NextResponse | CreateArtistQuery { + const params = Object.fromEntries(searchParams.entries()); + const result = createArtistQuerySchema.safeParse(params); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} From 1a9bc27700c23bd20fa795a0c2864322db34b0f0 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 11:46:52 -0500 Subject: [PATCH 2/8] feat: add POST /api/artists endpoint per API docs - Add validateCreateArtistBody.ts with Zod schema (name required, account_id and organization_id optional) - Add createArtistPostHandler.ts for POST requests with JSON body - Update app/api/artists/route.ts to include POST handler - Returns 201 on success per REST conventions - Add 16 unit tests for validation and handler Co-Authored-By: Claude Opus 4.5 --- app/api/artists/route.ts | 19 ++ .../__tests__/createArtistPostHandler.test.ts | 166 ++++++++++++++++++ .../validateCreateArtistBody.test.ts | 72 ++++++++ lib/artists/createArtistPostHandler.ts | 77 ++++++++ lib/artists/validateCreateArtistBody.ts | 48 +++++ 5 files changed, 382 insertions(+) create mode 100644 lib/artists/__tests__/createArtistPostHandler.test.ts create mode 100644 lib/artists/__tests__/validateCreateArtistBody.test.ts create mode 100644 lib/artists/createArtistPostHandler.ts create mode 100644 lib/artists/validateCreateArtistBody.ts diff --git a/app/api/artists/route.ts b/app/api/artists/route.ts index f6eb53bc..64b65a42 100644 --- a/app/api/artists/route.ts +++ b/app/api/artists/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getArtistsHandler } from "@/lib/artists/getArtistsHandler"; +import { createArtistPostHandler } from "@/lib/artists/createArtistPostHandler"; /** * OPTIONS handler for CORS preflight requests. @@ -36,3 +37,21 @@ export async function GET(request: NextRequest) { return getArtistsHandler(request); } +/** + * POST /api/artists + * + * Creates a new artist account. + * + * Request body: + * - name (required): The name of the artist to create + * - account_id (optional): The ID of the account to create the artist for (UUID). + * Only required for organization API keys creating artists on behalf of other accounts. + * - organization_id (optional): The organization ID to link the new artist to (UUID) + * + * @param request - The request object containing JSON body + * @returns A NextResponse with the created artist data (201) or error + */ +export async function POST(request: NextRequest) { + return createArtistPostHandler(request); +} + diff --git a/lib/artists/__tests__/createArtistPostHandler.test.ts b/lib/artists/__tests__/createArtistPostHandler.test.ts new file mode 100644 index 00000000..a3510a87 --- /dev/null +++ b/lib/artists/__tests__/createArtistPostHandler.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockCreateArtistInDb = vi.fn(); + +vi.mock("@/lib/artists/createArtistInDb", () => ({ + createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args), +})); + +import { createArtistPostHandler } from "../createArtistPostHandler"; + +function createRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/artists", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("createArtistPostHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates artist and returns 201 with artist data", async () => { + const mockArtist = { + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + account_info: [{ image: null }], + account_socials: [], + }; + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + const request = createRequest({ + name: "Test Artist", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + + const response = await createArtistPostHandler(request); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.artist).toEqual(mockArtist); + expect(mockCreateArtistInDb).toHaveBeenCalledWith( + "Test Artist", + "550e8400-e29b-41d4-a716-446655440000", + undefined, + ); + }); + + it("passes organization_id to createArtistInDb", async () => { + const mockArtist = { + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + account_info: [{ image: null }], + account_socials: [], + }; + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + const request = createRequest({ + name: "Test Artist", + account_id: "550e8400-e29b-41d4-a716-446655440000", + organization_id: "660e8400-e29b-41d4-a716-446655440001", + }); + + await createArtistPostHandler(request); + + expect(mockCreateArtistInDb).toHaveBeenCalledWith( + "Test Artist", + "550e8400-e29b-41d4-a716-446655440000", + "660e8400-e29b-41d4-a716-446655440001", + ); + }); + + it("uses accountId from context when not in body", async () => { + const mockArtist = { + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + account_info: [{ image: null }], + account_socials: [], + }; + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + const request = createRequest({ name: "Test Artist" }); + + const response = await createArtistPostHandler( + request, + "context-account-id", + ); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(mockCreateArtistInDb).toHaveBeenCalledWith( + "Test Artist", + "context-account-id", + undefined, + ); + }); + + it("returns 400 when account_id missing and no context", async () => { + const request = createRequest({ name: "Test Artist" }); + + const response = await createArtistPostHandler(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("account_id is required"); + }); + + it("returns 400 when name is missing", async () => { + const request = createRequest({ + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + + const response = await createArtistPostHandler(request); + + expect(response.status).toBe(400); + }); + + it("returns 400 for invalid JSON body", async () => { + const request = new NextRequest("http://localhost/api/artists", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "invalid json", + }); + + const response = await createArtistPostHandler(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Invalid JSON body"); + }); + + it("returns 500 when artist creation fails", async () => { + mockCreateArtistInDb.mockResolvedValue(null); + + const request = createRequest({ + name: "Test Artist", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + + const response = await createArtistPostHandler(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe("Failed to create artist"); + }); + + it("returns 500 with error message when exception thrown", async () => { + mockCreateArtistInDb.mockRejectedValue(new Error("Database error")); + + const request = createRequest({ + name: "Test Artist", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + + const response = await createArtistPostHandler(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe("Database error"); + }); +}); diff --git a/lib/artists/__tests__/validateCreateArtistBody.test.ts b/lib/artists/__tests__/validateCreateArtistBody.test.ts new file mode 100644 index 00000000..3b2bacd7 --- /dev/null +++ b/lib/artists/__tests__/validateCreateArtistBody.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; +import { NextResponse } from "next/server"; +import { validateCreateArtistBody } from "../validateCreateArtistBody"; + +describe("validateCreateArtistBody", () => { + it("returns validated body when name is provided", () => { + const body = { name: "Test Artist" }; + const result = validateCreateArtistBody(body); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ name: "Test Artist" }); + }); + + it("returns validated body with all optional fields", () => { + const body = { + name: "Test Artist", + account_id: "550e8400-e29b-41d4-a716-446655440000", + organization_id: "660e8400-e29b-41d4-a716-446655440001", + }; + const result = validateCreateArtistBody(body); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual(body); + }); + + it("returns error when name is missing", () => { + const body = {}; + const result = validateCreateArtistBody(body); + + expect(result).toBeInstanceOf(NextResponse); + }); + + it("returns error when name is empty", () => { + const body = { name: "" }; + const result = validateCreateArtistBody(body); + + expect(result).toBeInstanceOf(NextResponse); + }); + + it("returns error when account_id is not a valid UUID", () => { + const body = { name: "Test Artist", account_id: "invalid-uuid" }; + const result = validateCreateArtistBody(body); + + expect(result).toBeInstanceOf(NextResponse); + }); + + it("returns error when organization_id is not a valid UUID", () => { + const body = { name: "Test Artist", organization_id: "invalid-uuid" }; + const result = validateCreateArtistBody(body); + + expect(result).toBeInstanceOf(NextResponse); + }); + + it("allows account_id to be omitted", () => { + const body = { name: "Test Artist" }; + const result = validateCreateArtistBody(body); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ name: "Test Artist" }); + }); + + it("allows organization_id to be omitted", () => { + const body = { + name: "Test Artist", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }; + const result = validateCreateArtistBody(body); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual(body); + }); +}); diff --git a/lib/artists/createArtistPostHandler.ts b/lib/artists/createArtistPostHandler.ts new file mode 100644 index 00000000..3fcdf770 --- /dev/null +++ b/lib/artists/createArtistPostHandler.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateCreateArtistBody } from "@/lib/artists/validateCreateArtistBody"; +import { createArtistInDb } from "@/lib/artists/createArtistInDb"; + +/** + * Handler for POST /api/artists. + * + * Creates a new artist account. + * + * Request body: + * - name (required): The name of the artist to create + * - account_id (optional): The ID of the account to create the artist for (UUID). + * Only required for organization API keys creating artists on behalf of other accounts. + * - organization_id (optional): The organization ID to link the new artist to (UUID) + * + * @param request - The request object containing JSON body + * @param accountId - The account ID from API key context (used if account_id not in body) + * @returns A NextResponse with artist data or error + */ +export async function createArtistPostHandler( + request: NextRequest, + accountId?: string, +): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Invalid JSON body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const validated = validateCreateArtistBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + // Use account_id from body, or fall back to API key context + const ownerAccountId = validated.account_id ?? accountId; + if (!ownerAccountId) { + return NextResponse.json( + { + status: "error", + error: "account_id is required", + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + try { + const artist = await createArtistInDb( + validated.name, + ownerAccountId, + validated.organization_id, + ); + + if (!artist) { + return NextResponse.json( + { status: "error", error: "Failed to create artist" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { artist }, + { status: 201, headers: getCorsHeaders() }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create artist"; + return NextResponse.json( + { status: "error", error: message }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/artists/validateCreateArtistBody.ts b/lib/artists/validateCreateArtistBody.ts new file mode 100644 index 00000000..b4af1d12 --- /dev/null +++ b/lib/artists/validateCreateArtistBody.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const createArtistBodySchema = z.object({ + name: z + .string({ message: "name is required" }) + .min(1, "name cannot be empty"), + account_id: z + .string() + .uuid("account_id must be a valid UUID") + .optional(), + organization_id: z + .string() + .uuid("organization_id must be a valid UUID") + .optional(), +}); + +export type CreateArtistBody = z.infer; + +/** + * Validates request body for POST /api/artists. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateCreateArtistBody( + body: unknown, +): NextResponse | CreateArtistBody { + const result = createArtistBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} From 28c5afc76957ba1483f3981f195f7a3fefce57e9 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 12:41:22 -0500 Subject: [PATCH 3/8] chore: delete old GET /api/artist/create endpoint Removed: - app/api/artist/create/route.ts - lib/artists/createArtistHandler.ts - lib/artists/validateCreateArtistQuery.ts - Related test files Replaced by POST /api/artists endpoint Co-Authored-By: Claude Opus 4.5 --- app/api/artist/create/route.ts | 35 ---- .../__tests__/createArtistHandler.test.ts | 152 ------------------ .../validateCreateArtistQuery.test.ts | 98 ----------- lib/artists/createArtistHandler.ts | 59 ------- lib/artists/validateCreateArtistQuery.ts | 44 ----- 5 files changed, 388 deletions(-) delete mode 100644 app/api/artist/create/route.ts delete mode 100644 lib/artists/__tests__/createArtistHandler.test.ts delete mode 100644 lib/artists/__tests__/validateCreateArtistQuery.test.ts delete mode 100644 lib/artists/createArtistHandler.ts delete mode 100644 lib/artists/validateCreateArtistQuery.ts diff --git a/app/api/artist/create/route.ts b/app/api/artist/create/route.ts deleted file mode 100644 index c34dcf85..00000000 --- a/app/api/artist/create/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { createArtistHandler } from "@/lib/artists/createArtistHandler"; - -/** - * OPTIONS handler for CORS preflight requests. - * - * @returns A NextResponse with CORS headers. - */ -export async function OPTIONS() { - return new NextResponse(null, { - status: 200, - headers: getCorsHeaders(), - }); -} - -/** - * GET /api/artist/create - * - * Creates a new artist account and associates it with an owner account. - * - * Query parameters: - * - name (required): The name of the artist to create - * - account_id (required): The ID of the owner account (UUID) - * - * @param request - The request object containing query parameters - * @returns A NextResponse with the created artist data - */ -export async function GET(request: NextRequest) { - return createArtistHandler(request); -} - -export const dynamic = "force-dynamic"; -export const fetchCache = "force-no-store"; -export const revalidate = 0; diff --git a/lib/artists/__tests__/createArtistHandler.test.ts b/lib/artists/__tests__/createArtistHandler.test.ts deleted file mode 100644 index a63e6714..00000000 --- a/lib/artists/__tests__/createArtistHandler.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -const mockCreateArtistInDb = vi.fn(); -const mockValidateCreateArtistQuery = vi.fn(); - -vi.mock("@/lib/artists/createArtistInDb", () => ({ - createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args), -})); - -vi.mock("@/lib/artists/validateCreateArtistQuery", () => ({ - validateCreateArtistQuery: (...args: unknown[]) => - mockValidateCreateArtistQuery(...args), -})); - -import { createArtistHandler } from "../createArtistHandler"; - -describe("createArtistHandler", () => { - const mockArtist = { - id: "artist-123", - account_id: "artist-123", - name: "Test Artist", - created_at: "2026-01-15T00:00:00Z", - updated_at: "2026-01-15T00:00:00Z", - image: null, - instruction: null, - knowledges: null, - label: null, - organization: null, - company_name: null, - job_title: null, - role_type: null, - onboarding_status: null, - onboarding_data: null, - account_info: [], - account_socials: [], - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns 200 with artist data on successful creation", async () => { - const validatedQuery = { - name: "Test Artist", - account_id: "owner-456", - }; - mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); - mockCreateArtistInDb.mockResolvedValue(mockArtist); - - const request = new NextRequest( - "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", - ); - - const response = await createArtistHandler(request); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.artist).toEqual(mockArtist); - expect(mockCreateArtistInDb).toHaveBeenCalledWith("Test Artist", "owner-456"); - }); - - it("returns validation error response when validation fails", async () => { - const { NextResponse } = await import("next/server"); - const errorResponse = NextResponse.json( - { status: "error", error: "name is required" }, - { status: 400 }, - ); - mockValidateCreateArtistQuery.mockReturnValue(errorResponse); - - const request = new NextRequest( - "http://localhost/api/artist/create?account_id=owner-456", - ); - - const response = await createArtistHandler(request); - - expect(response.status).toBe(400); - expect(mockCreateArtistInDb).not.toHaveBeenCalled(); - }); - - it("returns 500 when createArtistInDb returns null", async () => { - const validatedQuery = { - name: "Test Artist", - account_id: "owner-456", - }; - mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); - mockCreateArtistInDb.mockResolvedValue(null); - - const request = new NextRequest( - "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", - ); - - const response = await createArtistHandler(request); - const data = await response.json(); - - expect(response.status).toBe(500); - expect(data.message).toBe("Failed to create artist"); - }); - - it("returns 400 with error message when createArtistInDb throws", async () => { - const validatedQuery = { - name: "Test Artist", - account_id: "owner-456", - }; - mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); - mockCreateArtistInDb.mockRejectedValue(new Error("Database error")); - - const request = new NextRequest( - "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", - ); - - const response = await createArtistHandler(request); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data.message).toBe("Database error"); - }); - - it("includes CORS headers in successful response", async () => { - const validatedQuery = { - name: "Test Artist", - account_id: "owner-456", - }; - mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); - mockCreateArtistInDb.mockResolvedValue(mockArtist); - - const request = new NextRequest( - "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", - ); - - const response = await createArtistHandler(request); - - expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); - }); - - it("includes CORS headers in error response", async () => { - const validatedQuery = { - name: "Test Artist", - account_id: "owner-456", - }; - mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); - mockCreateArtistInDb.mockResolvedValue(null); - - const request = new NextRequest( - "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", - ); - - const response = await createArtistHandler(request); - - expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); - }); -}); diff --git a/lib/artists/__tests__/validateCreateArtistQuery.test.ts b/lib/artists/__tests__/validateCreateArtistQuery.test.ts deleted file mode 100644 index 32b76b08..00000000 --- a/lib/artists/__tests__/validateCreateArtistQuery.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { NextResponse } from "next/server"; -import { - validateCreateArtistQuery, - createArtistQuerySchema, -} from "../validateCreateArtistQuery"; - -describe("validateCreateArtistQuery", () => { - describe("name validation", () => { - it("accepts valid name parameter", () => { - const searchParams = new URLSearchParams({ - name: "Test Artist", - account_id: "123e4567-e89b-12d3-a456-426614174000", - }); - - const result = validateCreateArtistQuery(searchParams); - - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).name).toBe("Test Artist"); - }); - - it("rejects missing name parameter", () => { - const searchParams = new URLSearchParams({ - account_id: "123e4567-e89b-12d3-a456-426614174000", - }); - - const result = validateCreateArtistQuery(searchParams); - - expect(result).toBeInstanceOf(NextResponse); - }); - - it("rejects empty name parameter", () => { - const searchParams = new URLSearchParams({ - name: "", - account_id: "123e4567-e89b-12d3-a456-426614174000", - }); - - const result = validateCreateArtistQuery(searchParams); - - expect(result).toBeInstanceOf(NextResponse); - }); - }); - - describe("account_id validation", () => { - it("accepts valid UUID for account_id", () => { - const searchParams = new URLSearchParams({ - name: "Test Artist", - account_id: "123e4567-e89b-12d3-a456-426614174000", - }); - - const result = validateCreateArtistQuery(searchParams); - - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).account_id).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); - }); - - it("rejects missing account_id parameter", () => { - const searchParams = new URLSearchParams({ - name: "Test Artist", - }); - - const result = validateCreateArtistQuery(searchParams); - - expect(result).toBeInstanceOf(NextResponse); - }); - - it("rejects invalid UUID for account_id", () => { - const searchParams = new URLSearchParams({ - name: "Test Artist", - account_id: "invalid-uuid", - }); - - const result = validateCreateArtistQuery(searchParams); - - expect(result).toBeInstanceOf(NextResponse); - }); - }); - - describe("schema type inference", () => { - it("schema should require both name and account_id", () => { - const validParams = { - name: "Test Artist", - account_id: "123e4567-e89b-12d3-a456-426614174000", - }; - - const result = createArtistQuerySchema.safeParse(validParams); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.name).toBe("Test Artist"); - expect(result.data.account_id).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); - } - }); - }); -}); diff --git a/lib/artists/createArtistHandler.ts b/lib/artists/createArtistHandler.ts deleted file mode 100644 index 7f502842..00000000 --- a/lib/artists/createArtistHandler.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateCreateArtistQuery } from "@/lib/artists/validateCreateArtistQuery"; -import { createArtistInDb } from "@/lib/artists/createArtistInDb"; - -/** - * Handler for creating a new artist. - * - * Query parameters: - * - name (required): The name of the artist to create - * - account_id (required): The ID of the owner account (UUID) - * - * @param request - The request object containing query parameters - * @returns A NextResponse with artist data or error - */ -export async function createArtistHandler( - request: NextRequest, -): Promise { - const { searchParams } = new URL(request.url); - - const validatedQuery = validateCreateArtistQuery(searchParams); - if (validatedQuery instanceof NextResponse) { - return validatedQuery; - } - - try { - const artist = await createArtistInDb( - validatedQuery.name, - validatedQuery.account_id, - ); - - if (!artist) { - return NextResponse.json( - { message: "Failed to create artist" }, - { - status: 500, - headers: getCorsHeaders(), - }, - ); - } - - return NextResponse.json( - { artist }, - { - status: 200, - headers: getCorsHeaders(), - }, - ); - } catch (error) { - const message = error instanceof Error ? error.message : "failed"; - return NextResponse.json( - { message }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); - } -} diff --git a/lib/artists/validateCreateArtistQuery.ts b/lib/artists/validateCreateArtistQuery.ts deleted file mode 100644 index a0638114..00000000 --- a/lib/artists/validateCreateArtistQuery.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { z } from "zod"; - -export const createArtistQuerySchema = z.object({ - name: z - .string({ message: "name is required" }) - .min(1, "name cannot be empty"), - account_id: z - .string({ message: "account_id is required" }) - .uuid("account_id must be a valid UUID"), -}); - -export type CreateArtistQuery = z.infer; - -/** - * Validates query parameters for GET /api/artist/create. - * - * @param searchParams - The URL search parameters - * @returns A NextResponse with an error if validation fails, or the validated query if validation passes. - */ -export function validateCreateArtistQuery( - searchParams: URLSearchParams, -): NextResponse | CreateArtistQuery { - const params = Object.fromEntries(searchParams.entries()); - const result = createArtistQuerySchema.safeParse(params); - - if (!result.success) { - const firstError = result.error.issues[0]; - return NextResponse.json( - { - status: "error", - missing_fields: firstError.path, - error: firstError.message, - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); - } - - return result.data; -} From fe262d0fccd2803166d23678972a6317afa976e7 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 13:16:43 -0500 Subject: [PATCH 4/8] refactor: use API key authentication in createArtistPostHandler - Remove accountId parameter, use only request - Get account_id from x-api-key header via getApiKeyAccountId - Support account_id override for org API keys via validateOverrideAccountId - Update tests to mock auth functions Co-Authored-By: Claude Opus 4.5 --- .../__tests__/createArtistPostHandler.test.ts | 118 +++++++++++------- lib/artists/createArtistPostHandler.ts | 39 +++--- 2 files changed, 97 insertions(+), 60 deletions(-) diff --git a/lib/artists/__tests__/createArtistPostHandler.test.ts b/lib/artists/__tests__/createArtistPostHandler.test.ts index a3510a87..51bb7b4a 100644 --- a/lib/artists/__tests__/createArtistPostHandler.test.ts +++ b/lib/artists/__tests__/createArtistPostHandler.test.ts @@ -1,18 +1,32 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; const mockCreateArtistInDb = vi.fn(); +const mockGetApiKeyAccountId = vi.fn(); +const mockValidateOverrideAccountId = vi.fn(); vi.mock("@/lib/artists/createArtistInDb", () => ({ createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args), })); +vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ + getApiKeyAccountId: (...args: unknown[]) => mockGetApiKeyAccountId(...args), +})); + +vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ + validateOverrideAccountId: (...args: unknown[]) => + mockValidateOverrideAccountId(...args), +})); + import { createArtistPostHandler } from "../createArtistPostHandler"; -function createRequest(body: unknown): NextRequest { +function createRequest(body: unknown, apiKey = "test-api-key"): NextRequest { return new NextRequest("http://localhost/api/artists", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, body: JSON.stringify(body), }); } @@ -20,9 +34,10 @@ function createRequest(body: unknown): NextRequest { describe("createArtistPostHandler", () => { beforeEach(() => { vi.clearAllMocks(); + mockGetApiKeyAccountId.mockResolvedValue("api-key-account-id"); }); - it("creates artist and returns 201 with artist data", async () => { + it("creates artist using account_id from API key", async () => { const mockArtist = { id: "artist-123", account_id: "artist-123", @@ -32,11 +47,7 @@ describe("createArtistPostHandler", () => { }; mockCreateArtistInDb.mockResolvedValue(mockArtist); - const request = createRequest({ - name: "Test Artist", - account_id: "550e8400-e29b-41d4-a716-446655440000", - }); - + const request = createRequest({ name: "Test Artist" }); const response = await createArtistPostHandler(request); const data = await response.json(); @@ -44,12 +55,12 @@ describe("createArtistPostHandler", () => { expect(data.artist).toEqual(mockArtist); expect(mockCreateArtistInDb).toHaveBeenCalledWith( "Test Artist", - "550e8400-e29b-41d4-a716-446655440000", + "api-key-account-id", undefined, ); }); - it("passes organization_id to createArtistInDb", async () => { + it("uses account_id override for org API keys", async () => { const mockArtist = { id: "artist-123", account_id: "artist-123", @@ -58,23 +69,46 @@ describe("createArtistPostHandler", () => { account_socials: [], }; mockCreateArtistInDb.mockResolvedValue(mockArtist); + mockValidateOverrideAccountId.mockResolvedValue({ + accountId: "550e8400-e29b-41d4-a716-446655440000", + }); const request = createRequest({ name: "Test Artist", account_id: "550e8400-e29b-41d4-a716-446655440000", - organization_id: "660e8400-e29b-41d4-a716-446655440001", }); + const response = await createArtistPostHandler(request); - await createArtistPostHandler(request); - + expect(mockValidateOverrideAccountId).toHaveBeenCalledWith({ + apiKey: "test-api-key", + targetAccountId: "550e8400-e29b-41d4-a716-446655440000", + }); expect(mockCreateArtistInDb).toHaveBeenCalledWith( "Test Artist", "550e8400-e29b-41d4-a716-446655440000", - "660e8400-e29b-41d4-a716-446655440001", + undefined, + ); + expect(response.status).toBe(201); + }); + + it("returns 403 when org API key lacks access to account_id", async () => { + mockValidateOverrideAccountId.mockResolvedValue( + NextResponse.json( + { status: "error", message: "Access denied" }, + { status: 403 }, + ), ); + + const request = createRequest({ + name: "Test Artist", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }); + const response = await createArtistPostHandler(request); + + expect(response.status).toBe(403); }); - it("uses accountId from context when not in body", async () => { + it("passes organization_id to createArtistInDb", async () => { const mockArtist = { id: "artist-123", account_id: "artist-123", @@ -84,37 +118,36 @@ describe("createArtistPostHandler", () => { }; mockCreateArtistInDb.mockResolvedValue(mockArtist); - const request = createRequest({ name: "Test Artist" }); + const request = createRequest({ + name: "Test Artist", + organization_id: "660e8400-e29b-41d4-a716-446655440001", + }); - const response = await createArtistPostHandler( - request, - "context-account-id", - ); - const data = await response.json(); + await createArtistPostHandler(request); - expect(response.status).toBe(201); expect(mockCreateArtistInDb).toHaveBeenCalledWith( "Test Artist", - "context-account-id", - undefined, + "api-key-account-id", + "660e8400-e29b-41d4-a716-446655440001", ); }); - it("returns 400 when account_id missing and no context", async () => { - const request = createRequest({ name: "Test Artist" }); + it("returns 401 when API key is missing", async () => { + mockGetApiKeyAccountId.mockResolvedValue( + NextResponse.json( + { status: "error", message: "x-api-key header required" }, + { status: 401 }, + ), + ); + const request = createRequest({ name: "Test Artist" }); const response = await createArtistPostHandler(request); - const data = await response.json(); - expect(response.status).toBe(400); - expect(data.error).toBe("account_id is required"); + expect(response.status).toBe(401); }); it("returns 400 when name is missing", async () => { - const request = createRequest({ - account_id: "550e8400-e29b-41d4-a716-446655440000", - }); - + const request = createRequest({}); const response = await createArtistPostHandler(request); expect(response.status).toBe(400); @@ -123,7 +156,10 @@ describe("createArtistPostHandler", () => { it("returns 400 for invalid JSON body", async () => { const request = new NextRequest("http://localhost/api/artists", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "x-api-key": "test-api-key", + }, body: "invalid json", }); @@ -137,11 +173,7 @@ describe("createArtistPostHandler", () => { it("returns 500 when artist creation fails", async () => { mockCreateArtistInDb.mockResolvedValue(null); - const request = createRequest({ - name: "Test Artist", - account_id: "550e8400-e29b-41d4-a716-446655440000", - }); - + const request = createRequest({ name: "Test Artist" }); const response = await createArtistPostHandler(request); const data = await response.json(); @@ -152,11 +184,7 @@ describe("createArtistPostHandler", () => { it("returns 500 with error message when exception thrown", async () => { mockCreateArtistInDb.mockRejectedValue(new Error("Database error")); - const request = createRequest({ - name: "Test Artist", - account_id: "550e8400-e29b-41d4-a716-446655440000", - }); - + const request = createRequest({ name: "Test Artist" }); const response = await createArtistPostHandler(request); const data = await response.json(); diff --git a/lib/artists/createArtistPostHandler.ts b/lib/artists/createArtistPostHandler.ts index 3fcdf770..aab85ee7 100644 --- a/lib/artists/createArtistPostHandler.ts +++ b/lib/artists/createArtistPostHandler.ts @@ -2,26 +2,35 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateCreateArtistBody } from "@/lib/artists/validateCreateArtistBody"; import { createArtistInDb } from "@/lib/artists/createArtistInDb"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; /** * Handler for POST /api/artists. * - * Creates a new artist account. + * Creates a new artist account. Requires authentication via x-api-key header. + * The account ID is inferred from the API key, unless an account_id is provided + * in the request body by an organization API key with access to that account. * * Request body: * - name (required): The name of the artist to create * - account_id (optional): The ID of the account to create the artist for (UUID). - * Only required for organization API keys creating artists on behalf of other accounts. + * Only used by organization API keys creating artists on behalf of other accounts. * - organization_id (optional): The organization ID to link the new artist to (UUID) * * @param request - The request object containing JSON body - * @param accountId - The account ID from API key context (used if account_id not in body) * @returns A NextResponse with artist data or error */ export async function createArtistPostHandler( request: NextRequest, - accountId?: string, ): Promise { + const accountIdOrError = await getApiKeyAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + + let accountId = accountIdOrError; + let body: unknown; try { body = await request.json(); @@ -37,22 +46,22 @@ export async function createArtistPostHandler( return validated; } - // Use account_id from body, or fall back to API key context - const ownerAccountId = validated.account_id ?? accountId; - if (!ownerAccountId) { - return NextResponse.json( - { - status: "error", - error: "account_id is required", - }, - { status: 400, headers: getCorsHeaders() }, - ); + // Handle account_id override for org API keys + if (validated.account_id) { + const overrideResult = await validateOverrideAccountId({ + apiKey: request.headers.get("x-api-key"), + targetAccountId: validated.account_id, + }); + if (overrideResult instanceof NextResponse) { + return overrideResult; + } + accountId = overrideResult.accountId; } try { const artist = await createArtistInDb( validated.name, - ownerAccountId, + accountId, validated.organization_id, ); From b49af7065908a6f549d6e1336ee44db477883542 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 13:23:01 -0500 Subject: [PATCH 5/8] refactor: DRY API key validation using getApiKeyDetails - Use single getApiKeyDetails call instead of two separate auth functions - Use canAccessAccount directly for override validation - Reduces duplicate API key hashing and lookup - Add test for invalid API key case Co-Authored-By: Claude Opus 4.5 --- .../__tests__/createArtistPostHandler.test.ts | 67 ++++++++++++------- lib/artists/createArtistPostHandler.ts | 37 ++++++---- 2 files changed, 66 insertions(+), 38 deletions(-) diff --git a/lib/artists/__tests__/createArtistPostHandler.test.ts b/lib/artists/__tests__/createArtistPostHandler.test.ts index 51bb7b4a..c764c5da 100644 --- a/lib/artists/__tests__/createArtistPostHandler.test.ts +++ b/lib/artists/__tests__/createArtistPostHandler.test.ts @@ -1,21 +1,20 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; const mockCreateArtistInDb = vi.fn(); -const mockGetApiKeyAccountId = vi.fn(); -const mockValidateOverrideAccountId = vi.fn(); +const mockGetApiKeyDetails = vi.fn(); +const mockCanAccessAccount = vi.fn(); vi.mock("@/lib/artists/createArtistInDb", () => ({ createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args), })); -vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ - getApiKeyAccountId: (...args: unknown[]) => mockGetApiKeyAccountId(...args), +vi.mock("@/lib/keys/getApiKeyDetails", () => ({ + getApiKeyDetails: (...args: unknown[]) => mockGetApiKeyDetails(...args), })); -vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ - validateOverrideAccountId: (...args: unknown[]) => - mockValidateOverrideAccountId(...args), +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: (...args: unknown[]) => mockCanAccessAccount(...args), })); import { createArtistPostHandler } from "../createArtistPostHandler"; @@ -34,7 +33,10 @@ function createRequest(body: unknown, apiKey = "test-api-key"): NextRequest { describe("createArtistPostHandler", () => { beforeEach(() => { vi.clearAllMocks(); - mockGetApiKeyAccountId.mockResolvedValue("api-key-account-id"); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "api-key-account-id", + orgId: null, + }); }); it("creates artist using account_id from API key", async () => { @@ -61,6 +63,12 @@ describe("createArtistPostHandler", () => { }); it("uses account_id override for org API keys", async () => { + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-account-id", + orgId: "org-account-id", + }); + mockCanAccessAccount.mockResolvedValue(true); + const mockArtist = { id: "artist-123", account_id: "artist-123", @@ -69,9 +77,6 @@ describe("createArtistPostHandler", () => { account_socials: [], }; mockCreateArtistInDb.mockResolvedValue(mockArtist); - mockValidateOverrideAccountId.mockResolvedValue({ - accountId: "550e8400-e29b-41d4-a716-446655440000", - }); const request = createRequest({ name: "Test Artist", @@ -79,8 +84,8 @@ describe("createArtistPostHandler", () => { }); const response = await createArtistPostHandler(request); - expect(mockValidateOverrideAccountId).toHaveBeenCalledWith({ - apiKey: "test-api-key", + expect(mockCanAccessAccount).toHaveBeenCalledWith({ + orgId: "org-account-id", targetAccountId: "550e8400-e29b-41d4-a716-446655440000", }); expect(mockCreateArtistInDb).toHaveBeenCalledWith( @@ -92,12 +97,11 @@ describe("createArtistPostHandler", () => { }); it("returns 403 when org API key lacks access to account_id", async () => { - mockValidateOverrideAccountId.mockResolvedValue( - NextResponse.json( - { status: "error", message: "Access denied" }, - { status: 403 }, - ), - ); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-account-id", + orgId: "org-account-id", + }); + mockCanAccessAccount.mockResolvedValue(false); const request = createRequest({ name: "Test Artist", @@ -133,17 +137,28 @@ describe("createArtistPostHandler", () => { }); it("returns 401 when API key is missing", async () => { - mockGetApiKeyAccountId.mockResolvedValue( - NextResponse.json( - { status: "error", message: "x-api-key header required" }, - { status: 401 }, - ), - ); + const request = new NextRequest("http://localhost/api/artists", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Test Artist" }), + }); + + const response = await createArtistPostHandler(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("x-api-key header required"); + }); + + it("returns 401 when API key is invalid", async () => { + mockGetApiKeyDetails.mockResolvedValue(null); const request = createRequest({ name: "Test Artist" }); const response = await createArtistPostHandler(request); + const data = await response.json(); expect(response.status).toBe(401); + expect(data.error).toBe("Invalid API key"); }); it("returns 400 when name is missing", async () => { diff --git a/lib/artists/createArtistPostHandler.ts b/lib/artists/createArtistPostHandler.ts index aab85ee7..0d0fa4d8 100644 --- a/lib/artists/createArtistPostHandler.ts +++ b/lib/artists/createArtistPostHandler.ts @@ -2,8 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateCreateArtistBody } from "@/lib/artists/validateCreateArtistBody"; import { createArtistInDb } from "@/lib/artists/createArtistInDb"; -import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; /** * Handler for POST /api/artists. @@ -24,12 +24,21 @@ import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccoun export async function createArtistPostHandler( request: NextRequest, ): Promise { - const accountIdOrError = await getApiKeyAccountId(request); - if (accountIdOrError instanceof NextResponse) { - return accountIdOrError; + const apiKey = request.headers.get("x-api-key"); + if (!apiKey) { + return NextResponse.json( + { status: "error", error: "x-api-key header required" }, + { status: 401, headers: getCorsHeaders() }, + ); } - let accountId = accountIdOrError; + const keyDetails = await getApiKeyDetails(apiKey); + if (!keyDetails) { + return NextResponse.json( + { status: "error", error: "Invalid API key" }, + { status: 401, headers: getCorsHeaders() }, + ); + } let body: unknown; try { @@ -46,16 +55,20 @@ export async function createArtistPostHandler( return validated; } - // Handle account_id override for org API keys + // Use account_id from body if provided (org API keys only), otherwise use API key's account + let accountId = keyDetails.accountId; if (validated.account_id) { - const overrideResult = await validateOverrideAccountId({ - apiKey: request.headers.get("x-api-key"), + const hasAccess = await canAccessAccount({ + orgId: keyDetails.orgId, targetAccountId: validated.account_id, }); - if (overrideResult instanceof NextResponse) { - return overrideResult; + if (!hasAccess) { + return NextResponse.json( + { status: "error", error: "Access denied to specified account_id" }, + { status: 403, headers: getCorsHeaders() }, + ); } - accountId = overrideResult.accountId; + accountId = validated.account_id; } try { From b3b78fcdcfe4d8a391db86577a2d11e7f5f03415 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 13:31:52 -0500 Subject: [PATCH 6/8] refactor: move all early validation to validateCreateArtistBody - validateCreateArtistBody now takes NextRequest and returns Promise - Validates API key, JSON body parsing, and schema in one place - Handler now only handles business logic (account access check, artist creation) - Updated tests for new async signature Co-Authored-By: Claude Opus 4.5 --- .../validateCreateArtistBody.test.ts | 146 ++++++++++++++---- lib/artists/createArtistPostHandler.ts | 41 +---- lib/artists/validateCreateArtistBody.ts | 57 +++++-- 3 files changed, 164 insertions(+), 80 deletions(-) diff --git a/lib/artists/__tests__/validateCreateArtistBody.test.ts b/lib/artists/__tests__/validateCreateArtistBody.test.ts index 3b2bacd7..7aa77ecf 100644 --- a/lib/artists/__tests__/validateCreateArtistBody.test.ts +++ b/lib/artists/__tests__/validateCreateArtistBody.test.ts @@ -1,72 +1,150 @@ -import { describe, it, expect } from "vitest"; -import { NextResponse } from "next/server"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +const mockGetApiKeyDetails = vi.fn(); + +vi.mock("@/lib/keys/getApiKeyDetails", () => ({ + getApiKeyDetails: (...args: unknown[]) => mockGetApiKeyDetails(...args), +})); + import { validateCreateArtistBody } from "../validateCreateArtistBody"; +function createRequest(body: unknown, apiKey?: string): NextRequest { + const headers: Record = { "Content-Type": "application/json" }; + if (apiKey) { + headers["x-api-key"] = apiKey; + } + return new NextRequest("http://localhost/api/artists", { + method: "POST", + headers, + body: JSON.stringify(body), + }); +} + describe("validateCreateArtistBody", () => { - it("returns validated body when name is provided", () => { - const body = { name: "Test Artist" }; - const result = validateCreateArtistBody(body); + beforeEach(() => { + vi.clearAllMocks(); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "api-key-account-id", + orgId: null, + }); + }); + + it("returns validated body and keyDetails when valid", async () => { + const request = createRequest({ name: "Test Artist" }, "test-api-key"); + const result = await validateCreateArtistBody(request); expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ name: "Test Artist" }); + if (!(result instanceof NextResponse)) { + expect(result.body).toEqual({ name: "Test Artist" }); + expect(result.keyDetails).toEqual({ accountId: "api-key-account-id", orgId: null }); + } }); - it("returns validated body with all optional fields", () => { + it("returns validated body with all optional fields", async () => { const body = { name: "Test Artist", account_id: "550e8400-e29b-41d4-a716-446655440000", organization_id: "660e8400-e29b-41d4-a716-446655440001", }; - const result = validateCreateArtistBody(body); + const request = createRequest(body, "test-api-key"); + const result = await validateCreateArtistBody(request); expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual(body); + if (!(result instanceof NextResponse)) { + expect(result.body).toEqual(body); + } }); - it("returns error when name is missing", () => { - const body = {}; - const result = validateCreateArtistBody(body); + it("returns 401 when API key is missing", async () => { + const request = createRequest({ name: "Test Artist" }); + const result = await validateCreateArtistBody(request); expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(401); + const data = await result.json(); + expect(data.error).toBe("x-api-key header required"); + } }); - it("returns error when name is empty", () => { - const body = { name: "" }; - const result = validateCreateArtistBody(body); + it("returns 401 when API key is invalid", async () => { + mockGetApiKeyDetails.mockResolvedValue(null); + + const request = createRequest({ name: "Test Artist" }, "invalid-key"); + const result = await validateCreateArtistBody(request); expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(401); + const data = await result.json(); + expect(data.error).toBe("Invalid API key"); + } }); - it("returns error when account_id is not a valid UUID", () => { - const body = { name: "Test Artist", account_id: "invalid-uuid" }; - const result = validateCreateArtistBody(body); + it("returns 400 for invalid JSON body", async () => { + const request = new NextRequest("http://localhost/api/artists", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-api-key", + }, + body: "invalid json", + }); + + const result = await validateCreateArtistBody(request); expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const data = await result.json(); + expect(data.error).toBe("Invalid JSON body"); + } }); - it("returns error when organization_id is not a valid UUID", () => { - const body = { name: "Test Artist", organization_id: "invalid-uuid" }; - const result = validateCreateArtistBody(body); + it("returns error when name is missing", async () => { + const request = createRequest({}, "test-api-key"); + const result = await validateCreateArtistBody(request); expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } }); - it("allows account_id to be omitted", () => { - const body = { name: "Test Artist" }; - const result = validateCreateArtistBody(body); + it("returns error when name is empty", async () => { + const request = createRequest({ name: "" }, "test-api-key"); + const result = await validateCreateArtistBody(request); - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ name: "Test Artist" }); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } }); - it("allows organization_id to be omitted", () => { - const body = { - name: "Test Artist", - account_id: "550e8400-e29b-41d4-a716-446655440000", - }; - const result = validateCreateArtistBody(body); + it("returns error when account_id is not a valid UUID", async () => { + const request = createRequest( + { name: "Test Artist", account_id: "invalid-uuid" }, + "test-api-key", + ); + const result = await validateCreateArtistBody(request); - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual(body); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns error when organization_id is not a valid UUID", async () => { + const request = createRequest( + { name: "Test Artist", organization_id: "invalid-uuid" }, + "test-api-key", + ); + const result = await validateCreateArtistBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } }); }); diff --git a/lib/artists/createArtistPostHandler.ts b/lib/artists/createArtistPostHandler.ts index 0d0fa4d8..df52dd26 100644 --- a/lib/artists/createArtistPostHandler.ts +++ b/lib/artists/createArtistPostHandler.ts @@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateCreateArtistBody } from "@/lib/artists/validateCreateArtistBody"; import { createArtistInDb } from "@/lib/artists/createArtistInDb"; -import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; /** @@ -24,43 +23,19 @@ import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; export async function createArtistPostHandler( request: NextRequest, ): Promise { - const apiKey = request.headers.get("x-api-key"); - if (!apiKey) { - return NextResponse.json( - { status: "error", error: "x-api-key header required" }, - { status: 401, headers: getCorsHeaders() }, - ); - } - - const keyDetails = await getApiKeyDetails(apiKey); - if (!keyDetails) { - return NextResponse.json( - { status: "error", error: "Invalid API key" }, - { status: 401, headers: getCorsHeaders() }, - ); - } - - let body: unknown; - try { - body = await request.json(); - } catch { - return NextResponse.json( - { status: "error", error: "Invalid JSON body" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const validated = validateCreateArtistBody(body); + const validated = await validateCreateArtistBody(request); if (validated instanceof NextResponse) { return validated; } + const { body, keyDetails } = validated; + // Use account_id from body if provided (org API keys only), otherwise use API key's account let accountId = keyDetails.accountId; - if (validated.account_id) { + if (body.account_id) { const hasAccess = await canAccessAccount({ orgId: keyDetails.orgId, - targetAccountId: validated.account_id, + targetAccountId: body.account_id, }); if (!hasAccess) { return NextResponse.json( @@ -68,14 +43,14 @@ export async function createArtistPostHandler( { status: 403, headers: getCorsHeaders() }, ); } - accountId = validated.account_id; + accountId = body.account_id; } try { const artist = await createArtistInDb( - validated.name, + body.name, accountId, - validated.organization_id, + body.organization_id, ); if (!artist) { diff --git a/lib/artists/validateCreateArtistBody.ts b/lib/artists/validateCreateArtistBody.ts index b4af1d12..f6c12c6b 100644 --- a/lib/artists/validateCreateArtistBody.ts +++ b/lib/artists/validateCreateArtistBody.ts @@ -1,5 +1,6 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getApiKeyDetails, type ApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; import { z } from "zod"; export const createArtistBodySchema = z.object({ @@ -18,17 +19,47 @@ export const createArtistBodySchema = z.object({ export type CreateArtistBody = z.infer; +export type ValidatedCreateArtistRequest = { + body: CreateArtistBody; + keyDetails: ApiKeyDetails; +}; + /** - * Validates request body for POST /api/artists. + * Validates POST /api/artists request including API key, body parsing, and schema validation. * - * @param body - The request body - * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + * @param request - The NextRequest object + * @returns A NextResponse with an error if validation fails, or the validated body and keyDetails if validation passes. */ -export function validateCreateArtistBody( - body: unknown, -): NextResponse | CreateArtistBody { - const result = createArtistBodySchema.safeParse(body); +export async function validateCreateArtistBody( + request: NextRequest, +): Promise { + const apiKey = request.headers.get("x-api-key"); + if (!apiKey) { + return NextResponse.json( + { status: "error", error: "x-api-key header required" }, + { status: 401, headers: getCorsHeaders() }, + ); + } + + const keyDetails = await getApiKeyDetails(apiKey); + if (!keyDetails) { + return NextResponse.json( + { status: "error", error: "Invalid API key" }, + { status: 401, headers: getCorsHeaders() }, + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Invalid JSON body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + const result = createArtistBodySchema.safeParse(body); if (!result.success) { const firstError = result.error.issues[0]; return NextResponse.json( @@ -37,12 +68,12 @@ export function validateCreateArtistBody( missing_fields: firstError.path, error: firstError.message, }, - { - status: 400, - headers: getCorsHeaders(), - }, + { status: 400, headers: getCorsHeaders() }, ); } - return result.data; + return { + body: result.data, + keyDetails, + }; } From 4110c8306e22804b633876d0c8eb63c7e73b1de4 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 13:34:38 -0500 Subject: [PATCH 7/8] refactor: move canAccessAccount logic to validateCreateArtistBody - Validation function now handles all auth/authz: API key, body parsing, schema, and account access - Returns flat { name, accountId, organizationId } instead of nested structure - Handler is now purely business logic: validate -> create -> respond - Updated tests to cover 403 case in validation Co-Authored-By: Claude Opus 4.5 --- .../validateCreateArtistBody.test.ts | 72 ++++++++++++++++--- lib/artists/createArtistPostHandler.ts | 25 +------ lib/artists/validateCreateArtistBody.ts | 34 +++++++-- 3 files changed, 91 insertions(+), 40 deletions(-) diff --git a/lib/artists/__tests__/validateCreateArtistBody.test.ts b/lib/artists/__tests__/validateCreateArtistBody.test.ts index 7aa77ecf..d5e00453 100644 --- a/lib/artists/__tests__/validateCreateArtistBody.test.ts +++ b/lib/artists/__tests__/validateCreateArtistBody.test.ts @@ -2,11 +2,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; const mockGetApiKeyDetails = vi.fn(); +const mockCanAccessAccount = vi.fn(); vi.mock("@/lib/keys/getApiKeyDetails", () => ({ getApiKeyDetails: (...args: unknown[]) => mockGetApiKeyDetails(...args), })); +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: (...args: unknown[]) => mockCanAccessAccount(...args), +})); + import { validateCreateArtistBody } from "../validateCreateArtistBody"; function createRequest(body: unknown, apiKey?: string): NextRequest { @@ -30,29 +35,74 @@ describe("validateCreateArtistBody", () => { }); }); - it("returns validated body and keyDetails when valid", async () => { + it("returns validated data with accountId from API key", async () => { const request = createRequest({ name: "Test Artist" }, "test-api-key"); const result = await validateCreateArtistBody(request); expect(result).not.toBeInstanceOf(NextResponse); if (!(result instanceof NextResponse)) { - expect(result.body).toEqual({ name: "Test Artist" }); - expect(result.keyDetails).toEqual({ accountId: "api-key-account-id", orgId: null }); + expect(result.name).toBe("Test Artist"); + expect(result.accountId).toBe("api-key-account-id"); + expect(result.organizationId).toBeUndefined(); } }); - it("returns validated body with all optional fields", async () => { - const body = { - name: "Test Artist", - account_id: "550e8400-e29b-41d4-a716-446655440000", - organization_id: "660e8400-e29b-41d4-a716-446655440001", - }; - const request = createRequest(body, "test-api-key"); + it("returns validated data with organization_id", async () => { + const request = createRequest( + { name: "Test Artist", organization_id: "660e8400-e29b-41d4-a716-446655440001" }, + "test-api-key", + ); const result = await validateCreateArtistBody(request); expect(result).not.toBeInstanceOf(NextResponse); if (!(result instanceof NextResponse)) { - expect(result.body).toEqual(body); + expect(result.name).toBe("Test Artist"); + expect(result.accountId).toBe("api-key-account-id"); + expect(result.organizationId).toBe("660e8400-e29b-41d4-a716-446655440001"); + } + }); + + it("uses account_id override for org API keys with access", async () => { + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-account-id", + orgId: "org-account-id", + }); + mockCanAccessAccount.mockResolvedValue(true); + + const request = createRequest( + { name: "Test Artist", account_id: "550e8400-e29b-41d4-a716-446655440000" }, + "test-api-key", + ); + const result = await validateCreateArtistBody(request); + + expect(mockCanAccessAccount).toHaveBeenCalledWith({ + orgId: "org-account-id", + targetAccountId: "550e8400-e29b-41d4-a716-446655440000", + }); + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.accountId).toBe("550e8400-e29b-41d4-a716-446655440000"); + } + }); + + it("returns 403 when org API key lacks access to account_id", async () => { + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-account-id", + orgId: "org-account-id", + }); + mockCanAccessAccount.mockResolvedValue(false); + + const request = createRequest( + { name: "Test Artist", account_id: "550e8400-e29b-41d4-a716-446655440000" }, + "test-api-key", + ); + const result = await validateCreateArtistBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(403); + const data = await result.json(); + expect(data.error).toBe("Access denied to specified account_id"); } }); diff --git a/lib/artists/createArtistPostHandler.ts b/lib/artists/createArtistPostHandler.ts index df52dd26..58bed3f7 100644 --- a/lib/artists/createArtistPostHandler.ts +++ b/lib/artists/createArtistPostHandler.ts @@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateCreateArtistBody } from "@/lib/artists/validateCreateArtistBody"; import { createArtistInDb } from "@/lib/artists/createArtistInDb"; -import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; /** * Handler for POST /api/artists. @@ -28,29 +27,11 @@ export async function createArtistPostHandler( return validated; } - const { body, keyDetails } = validated; - - // Use account_id from body if provided (org API keys only), otherwise use API key's account - let accountId = keyDetails.accountId; - if (body.account_id) { - const hasAccess = await canAccessAccount({ - orgId: keyDetails.orgId, - targetAccountId: body.account_id, - }); - if (!hasAccess) { - return NextResponse.json( - { status: "error", error: "Access denied to specified account_id" }, - { status: 403, headers: getCorsHeaders() }, - ); - } - accountId = body.account_id; - } - try { const artist = await createArtistInDb( - body.name, - accountId, - body.organization_id, + validated.name, + validated.accountId, + validated.organizationId, ); if (!artist) { diff --git a/lib/artists/validateCreateArtistBody.ts b/lib/artists/validateCreateArtistBody.ts index f6c12c6b..edfab638 100644 --- a/lib/artists/validateCreateArtistBody.ts +++ b/lib/artists/validateCreateArtistBody.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getApiKeyDetails, type ApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; import { z } from "zod"; export const createArtistBodySchema = z.object({ @@ -20,15 +21,17 @@ export const createArtistBodySchema = z.object({ export type CreateArtistBody = z.infer; export type ValidatedCreateArtistRequest = { - body: CreateArtistBody; - keyDetails: ApiKeyDetails; + name: string; + accountId: string; + organizationId?: string; }; /** - * Validates POST /api/artists request including API key, body parsing, and schema validation. + * Validates POST /api/artists request including API key, body parsing, schema validation, + * and account access authorization. * * @param request - The NextRequest object - * @returns A NextResponse with an error if validation fails, or the validated body and keyDetails if validation passes. + * @returns A NextResponse with an error if validation fails, or the validated request data if validation passes. */ export async function validateCreateArtistBody( request: NextRequest, @@ -72,8 +75,25 @@ export async function validateCreateArtistBody( ); } + // Use account_id from body if provided (org API keys only), otherwise use API key's account + let accountId = keyDetails.accountId; + if (result.data.account_id) { + const hasAccess = await canAccessAccount({ + orgId: keyDetails.orgId, + targetAccountId: result.data.account_id, + }); + if (!hasAccess) { + return NextResponse.json( + { status: "error", error: "Access denied to specified account_id" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + accountId = result.data.account_id; + } + return { - body: result.data, - keyDetails, + name: result.data.name, + accountId, + organizationId: result.data.organization_id, }; } From 66dc1655d2732c5032960b2fed9facd9d826f13b Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 13:42:04 -0500 Subject: [PATCH 8/8] fix: remove invalid extra.accountId access in MCP tool The MCP SDK's extra parameter doesn't have an accountId property. account_id must be provided via the tool args from system prompt context. Co-Authored-By: Claude Opus 4.5 --- lib/mcp/tools/artists/registerCreateNewArtistTool.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/mcp/tools/artists/registerCreateNewArtistTool.ts b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts index cfa662a9..ea11c6ed 100644 --- a/lib/mcp/tools/artists/registerCreateNewArtistTool.ts +++ b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts @@ -63,13 +63,11 @@ export function registerCreateNewArtistTool(server: McpServer): void { "The organization_id parameter is optional — use the organization_id from the system prompt context to link the artist to the user's selected organization.", inputSchema: createNewArtistSchema, }, - async (args: CreateNewArtistArgs, extra) => { + async (args: CreateNewArtistArgs) => { try { const { name, account_id, active_conversation_id, organization_id } = args; - // Get account_id from args or from API key context - const accountId = account_id ?? extra?.accountId; - if (!accountId) { + if (!account_id) { return getToolResultError( "account_id is required. Provide it from the system prompt context.", ); @@ -78,7 +76,7 @@ export function registerCreateNewArtistTool(server: McpServer): void { // Create the artist account (with optional org linking) const artist = await createArtistInDb( name, - accountId, + account_id, organization_id ?? undefined, );