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..c764c5da --- /dev/null +++ b/lib/artists/__tests__/createArtistPostHandler.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockCreateArtistInDb = vi.fn(); +const mockGetApiKeyDetails = vi.fn(); +const mockCanAccessAccount = vi.fn(); + +vi.mock("@/lib/artists/createArtistInDb", () => ({ + createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args), +})); + +vi.mock("@/lib/keys/getApiKeyDetails", () => ({ + getApiKeyDetails: (...args: unknown[]) => mockGetApiKeyDetails(...args), +})); + +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: (...args: unknown[]) => mockCanAccessAccount(...args), +})); + +import { createArtistPostHandler } from "../createArtistPostHandler"; + +function createRequest(body: unknown, apiKey = "test-api-key"): NextRequest { + return new NextRequest("http://localhost/api/artists", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify(body), + }); +} + +describe("createArtistPostHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "api-key-account-id", + orgId: null, + }); + }); + + it("creates artist using account_id from API key", 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); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.artist).toEqual(mockArtist); + expect(mockCreateArtistInDb).toHaveBeenCalledWith( + "Test Artist", + "api-key-account-id", + undefined, + ); + }); + + 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", + 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); + + expect(mockCanAccessAccount).toHaveBeenCalledWith({ + orgId: "org-account-id", + targetAccountId: "550e8400-e29b-41d4-a716-446655440000", + }); + expect(mockCreateArtistInDb).toHaveBeenCalledWith( + "Test Artist", + "550e8400-e29b-41d4-a716-446655440000", + undefined, + ); + expect(response.status).toBe(201); + }); + + 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", + }); + const response = await createArtistPostHandler(request); + + expect(response.status).toBe(403); + }); + + 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", + organization_id: "660e8400-e29b-41d4-a716-446655440001", + }); + + await createArtistPostHandler(request); + + expect(mockCreateArtistInDb).toHaveBeenCalledWith( + "Test Artist", + "api-key-account-id", + "660e8400-e29b-41d4-a716-446655440001", + ); + }); + + it("returns 401 when API key is missing", async () => { + 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 () => { + const request = createRequest({}); + 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", + "x-api-key": "test-api-key", + }, + 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" }); + 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" }); + 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..d5e00453 --- /dev/null +++ b/lib/artists/__tests__/validateCreateArtistBody.test.ts @@ -0,0 +1,200 @@ +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 { + 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", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "api-key-account-id", + orgId: null, + }); + }); + + 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.name).toBe("Test Artist"); + expect(result.accountId).toBe("api-key-account-id"); + expect(result.organizationId).toBeUndefined(); + } + }); + + 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.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"); + } + }); + + 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 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 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 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("returns error when name is empty", async () => { + const request = createRequest({ name: "" }, "test-api-key"); + const result = await validateCreateArtistBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + 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).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 new file mode 100644 index 00000000..58bed3f7 --- /dev/null +++ b/lib/artists/createArtistPostHandler.ts @@ -0,0 +1,55 @@ +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. 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 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 + * @returns A NextResponse with artist data or error + */ +export async function createArtistPostHandler( + request: NextRequest, +): Promise { + const validated = await validateCreateArtistBody(request); + if (validated instanceof NextResponse) { + return validated; + } + + try { + const artist = await createArtistInDb( + validated.name, + validated.accountId, + validated.organizationId, + ); + + 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..edfab638 --- /dev/null +++ b/lib/artists/validateCreateArtistBody.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +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; + +export type ValidatedCreateArtistRequest = { + name: string; + accountId: string; + organizationId?: string; +}; + +/** + * 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 request data if validation passes. + */ +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( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + // 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 { + name: result.data.name, + accountId, + organizationId: result.data.organization_id, + }; +} 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, );