Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions app/api/music/compose/detailed/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { composeDetailedHandler } from "@/lib/elevenlabs/composeDetailedHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A 204 response with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: getCorsHeaders() });
}

/**
* POST /api/music/compose/detailed
*
* Generate a song with metadata and optional word timestamps.
* Returns multipart/mixed (JSON metadata + binary audio).
*/
export { composeDetailedHandler as POST };

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
export const maxDuration = 120;
25 changes: 25 additions & 0 deletions app/api/music/compose/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { composeHandler } from "@/lib/elevenlabs/composeHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A 204 response with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: getCorsHeaders() });
}

/**
* POST /api/music/compose
*
* Generate a song from a text prompt or composition plan using ElevenLabs Music AI.
* Returns binary audio. The song-id is returned in the response headers.
*/
export { composeHandler as POST };
Comment on lines +14 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify getCorsHeaders doesn't expose custom headers
cat lib/networking/getCorsHeaders.ts

# Check which handlers set custom headers like song-id
rg -n "song-id|content-disposition" lib/elevenlabs/

Repository: recoupable/api

Length of output: 1263


🏁 Script executed:

#!/bin/bash
# Get full buildUpstreamResponse implementation
cat lib/elevenlabs/buildUpstreamResponse.ts

# Check if any route handler sets Access-Control-Expose-Headers
rg -n "Access-Control-Expose-Headers" .

# Check the composeHandler to see how it uses buildUpstreamResponse
cat lib/elevenlabs/composeHandler.ts

Repository: recoupable/api

Length of output: 1676


🏁 Script executed:

#!/bin/bash
# Get full route file context
cat app/api/music/compose/route.ts

# Check test setup to understand CORS testing
head -80 lib/elevenlabs/__tests__/composeHandler.test.ts

Repository: recoupable/api

Length of output: 3246


🏁 Script executed:

#!/bin/bash
# Check if there are other places where Access-Control-Expose-Headers is set or handled
rg -n "Expose-Headers|forwardHeaders" --type ts --type tsx

# Check all uses of buildUpstreamResponse to see the pattern
rg -n "buildUpstreamResponse" --type ts --type tsx -B 2 -A 2

Repository: recoupable/api

Length of output: 113


🏁 Script executed:

#!/bin/bash
# Check if there are other places where Access-Control-Expose-Headers is set or handled
rg -n "Expose-Headers|forwardHeaders"

# Check all uses of buildUpstreamResponse to see the pattern
rg -n "buildUpstreamResponse" -B 2 -A 2

Repository: recoupable/api

Length of output: 3257


Browser clients cannot read response headers without Access-Control-Expose-Headers in CORS response.

The endpoint correctly sets song-id in response headers, but getCorsHeaders() doesn't include Access-Control-Expose-Headers. This blocks browser clients from reading the header—the browser's CORS policy will strip it. The same issue affects stemSeparationHandler with content-disposition.

Update getCorsHeaders() to expose custom headers:

"Access-Control-Expose-Headers": "song-id, content-disposition"

This affects all music endpoints using buildUpstreamResponse() (compose, video-to-music, stem-separation).

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

In `@app/api/music/compose/route.ts` around lines 14 - 20, getCorsHeaders()
currently does not expose custom response headers, so browser clients cannot
read the song-id or content-disposition set by buildUpstreamResponse(); update
getCorsHeaders() to include "Access-Control-Expose-Headers" with the values
"song-id, content-disposition" so composeHandler (exported as POST),
stemSeparationHandler and any endpoints that use buildUpstreamResponse() can
surface those headers to browser clients.


export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
export const maxDuration = 120;
25 changes: 25 additions & 0 deletions app/api/music/plan/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { createPlanHandler } from "@/lib/elevenlabs/createPlanHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A 204 response with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: getCorsHeaders() });
}

/**
* POST /api/music/plan
*
* Create a composition plan from a text prompt.
* Free — does not consume ElevenLabs credits.
* Use this before compose to preview and tweak the plan.
*/
export { createPlanHandler as POST };

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
26 changes: 26 additions & 0 deletions app/api/music/stem-separation/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { stemSeparationHandler } from "@/lib/elevenlabs/stemSeparationHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A 204 response with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: getCorsHeaders() });
}

/**
* POST /api/music/stem-separation
*
* Separate an audio file into individual stems (vocals, drums, bass, etc.).
* Accepts multipart/form-data with one audio file.
* Returns a ZIP archive containing the stem files.
*/
export { stemSeparationHandler as POST };

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
export const maxDuration = 120;
24 changes: 24 additions & 0 deletions app/api/music/stream/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { streamHandler } from "@/lib/elevenlabs/streamHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A 204 response with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: getCorsHeaders() });
}

/**
* POST /api/music/stream
*
* Generate a song and stream audio chunks to the client in real time.
*/
export { streamHandler as POST };

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
export const maxDuration = 120;
25 changes: 25 additions & 0 deletions app/api/music/video-to-music/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { videoToMusicHandler } from "@/lib/elevenlabs/videoToMusicHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A 204 response with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: getCorsHeaders() });
}

/**
* POST /api/music/video-to-music
*
* Generate background music from video files.
* Accepts multipart/form-data with 1-10 video files (total ≤200MB).
*/
export { videoToMusicHandler as POST };

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
export const maxDuration = 120;
3 changes: 3 additions & 0 deletions lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ export const RECOUP_ORG_ID = "04e3aba9-c130-4fb8-8b92-34e95d43e66b";

export const RECOUP_API_KEY = process.env.RECOUP_API_KEY || "";

/** ElevenLabs Music API base URL */
export const ELEVENLABS_BASE_URL = "https://api.elevenlabs.io";

/** Music Flamingo model inference endpoint (Modal) */
export const FLAMINGO_GENERATE_URL =
"https://sidney-78147--music-flamingo-musicflamingo-generate.modal.run";
Expand Down
61 changes: 61 additions & 0 deletions lib/elevenlabs/__tests__/callElevenLabsMusic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@/lib/const", () => ({
ELEVENLABS_BASE_URL: "https://api.elevenlabs.io",
}));

const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);

import { callElevenLabsMusic } from "../callElevenLabsMusic";

describe("callElevenLabsMusic", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("ELEVENLABS_API_KEY", "test-xi-key");
});

it("sends JSON body with xi-api-key header", async () => {
mockFetch.mockResolvedValue(new Response("audio-data", { status: 200 }));

await callElevenLabsMusic("/v1/music", { prompt: "upbeat pop" });

expect(mockFetch).toHaveBeenCalledWith(
"https://api.elevenlabs.io/v1/music",
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
"xi-api-key": "test-xi-key",
},
body: JSON.stringify({ prompt: "upbeat pop" }),
}),
);
});

it("appends output_format query param when provided", async () => {
mockFetch.mockResolvedValue(new Response("audio-data", { status: 200 }));

await callElevenLabsMusic("/v1/music", { prompt: "test" }, "mp3_44100_128");

const calledUrl = mockFetch.mock.calls[0][0];
expect(calledUrl).toContain("output_format=mp3_44100_128");
});

it("does not append output_format when not provided", async () => {
mockFetch.mockResolvedValue(new Response("audio-data", { status: 200 }));

await callElevenLabsMusic("/v1/music", { prompt: "test" });

const calledUrl = mockFetch.mock.calls[0][0];
expect(calledUrl).not.toContain("output_format");
});

it("throws when ELEVENLABS_API_KEY is missing", async () => {
vi.stubEnv("ELEVENLABS_API_KEY", "");

await expect(callElevenLabsMusic("/v1/music", { prompt: "test" })).rejects.toThrow(
"ELEVENLABS_API_KEY is not configured",
);
});
});
64 changes: 64 additions & 0 deletions lib/elevenlabs/__tests__/callElevenLabsMusicMultipart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@/lib/const", () => ({
ELEVENLABS_BASE_URL: "https://api.elevenlabs.io",
}));

const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);

import { callElevenLabsMusicMultipart } from "../callElevenLabsMusicMultipart";

describe("callElevenLabsMusicMultipart", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("ELEVENLABS_API_KEY", "test-xi-key");
});

it("sends FormData with xi-api-key header", async () => {
mockFetch.mockResolvedValue(new Response("zip-data", { status: 200 }));

const formData = new FormData();
formData.append("file", new Blob(["audio"]), "test.mp3");

await callElevenLabsMusicMultipart("/v1/music/stem-separation", formData);

expect(mockFetch).toHaveBeenCalledWith(
"https://api.elevenlabs.io/v1/music/stem-separation",
expect.objectContaining({
method: "POST",
headers: { "xi-api-key": "test-xi-key" },
body: formData,
}),
);
});

it("does not set Content-Type header (lets fetch auto-set multipart boundary)", async () => {
mockFetch.mockResolvedValue(new Response("data", { status: 200 }));

const formData = new FormData();
await callElevenLabsMusicMultipart("/v1/music/stem-separation", formData);

const headers = mockFetch.mock.calls[0][1].headers;
expect(headers).not.toHaveProperty("Content-Type");
});

it("appends output_format query param when provided", async () => {
mockFetch.mockResolvedValue(new Response("data", { status: 200 }));

const formData = new FormData();
await callElevenLabsMusicMultipart("/v1/music/stem-separation", formData, "mp3_44100_128");

const calledUrl = mockFetch.mock.calls[0][0];
expect(calledUrl).toContain("output_format=mp3_44100_128");
});

it("throws when ELEVENLABS_API_KEY is missing", async () => {
vi.stubEnv("ELEVENLABS_API_KEY", "");

const formData = new FormData();
await expect(
callElevenLabsMusicMultipart("/v1/music/stem-separation", formData),
).rejects.toThrow("ELEVENLABS_API_KEY is not configured");
});
});
Loading
Loading