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
40 changes: 40 additions & 0 deletions app/api/chartmetric/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { type NextRequest } from "next/server";
import { proxyChartmetricRequest } from "@/lib/chartmetric/proxyChartmetricRequest";

/**
* GET /api/chartmetric/[...path]
*
* Proxies GET requests to the Chartmetric API on behalf of an authenticated account.
* Deducts 1 credit per call.
*
* @param request - Incoming API request.
* @param context - Route context containing the Chartmetric path segments.
* @param context.params - Route params with Chartmetric path segments.
* @param context.params.path - Array of path segments to forward to Chartmetric.
* @returns The Chartmetric API response.
*/
Comment on lines +4 to +15
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 | 🟡 Minor

JSDoc says 1 credit but implementation deducts 5 credits.

The documentation states "Deducts 1 credit per call" (lines 8 and 24), but proxyChartmetricRequest actually deducts 5 credits. This inconsistency will mislead API consumers.

📝 Update JSDoc to reflect actual credit cost
 /**
  * GET /api/chartmetric/[...path]
  *
  * Proxies GET requests to the Chartmetric API on behalf of an authenticated account.
- * Deducts 1 credit per call.
+ * Deducts 5 credits per call.
  *

Apply the same fix to the POST handler's JSDoc (line 24).

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

In `@app/api/chartmetric/`[...path]/route.ts around lines 4 - 15, Update the JSDoc
comments for both the GET and POST handlers in route.ts to reflect the actual
credit cost (change "Deducts 1 credit per call" to "Deducts 5 credits per call")
so they match the implementation in proxyChartmetricRequest; locate the
top-of-file comment blocks above the GET handler and the POST handler and edit
the line mentioning credits to state 5 credits instead of 1.

export async function GET(request: NextRequest, context: { params: Promise<{ path: string[] }> }) {
const params = await context.params;
return proxyChartmetricRequest(request, params);
Comment on lines +16 to +18
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

Add runtime Zod validation for route params before proxying.

Line 16 and Line 33 only use TypeScript typing; context.params is still unvalidated runtime input. Add a validate function (Zod + safeParse) and return 400 for invalid path payloads.

✅ Proposed validation pattern
-import { type NextRequest } from "next/server";
+import { type NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
 import { proxyChartmetricRequest } from "@/lib/chartmetric/proxyChartmetricRequest";
+
+const chartmetricParamsSchema = z.object({
+  path: z.array(z.string().min(1)).min(1),
+});
+
+function validateChartmetricParams(input: unknown) {
+  const parsed = chartmetricParamsSchema.safeParse(input);
+  if (!parsed.success) {
+    return NextResponse.json(
+      { status: "error", error: "Invalid Chartmetric path" },
+      { status: 400 },
+    );
+  }
+  return parsed.data;
+}
@@
 export async function GET(request: NextRequest, context: { params: Promise<{ path: string[] }> }) {
-  const params = await context.params;
-  return proxyChartmetricRequest(request, params);
+  const validated = validateChartmetricParams(await context.params);
+  if (validated instanceof NextResponse) return validated;
+  return proxyChartmetricRequest(request, validated);
 }
@@
 export async function POST(request: NextRequest, context: { params: Promise<{ path: string[] }> }) {
-  const params = await context.params;
-  return proxyChartmetricRequest(request, params);
+  const validated = validateChartmetricParams(await context.params);
+  if (validated instanceof NextResponse) return validated;
+  return proxyChartmetricRequest(request, validated);
 }

As per coding guidelines: "All API endpoints should use a validate function for input parsing using Zod for schema validation."

Also applies to: 33-35

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

In `@app/api/chartmetric/`[...path]/route.ts around lines 16 - 18, The route
handler GET is relying only on TypeScript types for context.params (a runtime
input) before calling proxyChartmetricRequest; add a Zod schema (e.g.,
pathSchema = z.object({ path: z.array(z.string()) })) and a validate helper that
uses schema.safeParse to validate await context.params, and if validation fails
return a NextResponse with status 400 and a descriptive error; once validated
pass the parsed value to proxyChartmetricRequest (repeat same validation pattern
for the other handler around lines 33-35) so all runtime route params are
validated before proxying.

}

/**
* POST /api/chartmetric/[...path]
*
* Proxies POST requests to the Chartmetric API on behalf of an authenticated account.
* Deducts 1 credit per call.
*
* @param request - Incoming API request.
* @param context - Route context containing the Chartmetric path segments.
* @param context.params - Route params with Chartmetric path segments.
* @param context.params.path - Array of path segments to forward to Chartmetric.
* @returns The Chartmetric API response.
*/
export async function POST(request: NextRequest, context: { params: Promise<{ path: string[] }> }) {
const params = await context.params;
return proxyChartmetricRequest(request, params);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
171 changes: 171 additions & 0 deletions lib/chartmetric/__tests__/proxyChartmetricRequest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { proxyChartmetricRequest } from "@/lib/chartmetric/proxyChartmetricRequest";

import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { deductCredits } from "@/lib/credits/deductCredits";
import { getChartmetricToken } from "@/lib/chartmetric/getChartmetricToken";

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

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

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

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

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

describe("proxyChartmetricRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
});

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

const request = new NextRequest("http://localhost/api/chartmetric/artist/123", {
method: "GET",
});

const result = await proxyChartmetricRequest(request, { path: ["artist", "123"] });

expect(result.status).toBe(401);
});

it("returns 402 when credits are insufficient", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc_123",
orgId: null,
authToken: "token_abc",
});
vi.mocked(deductCredits).mockRejectedValue(
new Error("Insufficient credits. Required: 5, Available: 0"),
);

const request = new NextRequest("http://localhost/api/chartmetric/artist/123", {
method: "GET",
});

const result = await proxyChartmetricRequest(request, { path: ["artist", "123"] });
const body = await result.json();

expect(result.status).toBe(402);
expect(body.status).toBe("error");
expect(body.error).toBe("Insufficient credits for Chartmetric API call");
});

it("returns 500 when Chartmetric API call fails", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc_123",
orgId: null,
authToken: "token_abc",
});
vi.mocked(deductCredits).mockResolvedValue({ success: true, newBalance: 5 });
vi.mocked(getChartmetricToken).mockResolvedValue("cm_access_token");
mockFetch.mockRejectedValue(new Error("Network error"));

const request = new NextRequest("http://localhost/api/chartmetric/artist/123", {
method: "GET",
});

const result = await proxyChartmetricRequest(request, { path: ["artist", "123"] });
const body = await result.json();

expect(result.status).toBe(500);
expect(body.status).toBe("error");
});
});

describe("successful cases", () => {
it("successfully proxies a GET request and returns Chartmetric response", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc_123",
orgId: null,
authToken: "token_abc",
});
vi.mocked(deductCredits).mockResolvedValue({ success: true, newBalance: 5 });
vi.mocked(getChartmetricToken).mockResolvedValue("cm_access_token");

const chartmetricData = { id: 123, name: "Test Artist" };
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => chartmetricData,
});

const request = new NextRequest("http://localhost/api/chartmetric/artist/123", {
method: "GET",
});

const result = await proxyChartmetricRequest(request, { path: ["artist", "123"] });
const body = await result.json();

expect(result.status).toBe(200);
expect(body).toEqual(chartmetricData);
expect(mockFetch).toHaveBeenCalledWith(
"https://api.chartmetric.com/api/artist/123",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({
Authorization: "Bearer cm_access_token",
}),
}),
);
expect(deductCredits).toHaveBeenCalledWith({ accountId: "acc_123", creditsToDeduct: 5 });
});

it("successfully proxies a POST request with body", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc_456",
orgId: null,
authToken: "token_xyz",
});
vi.mocked(deductCredits).mockResolvedValue({ success: true, newBalance: 5 });
vi.mocked(getChartmetricToken).mockResolvedValue("cm_access_token_2");

const chartmetricData = { success: true };
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => chartmetricData,
});

const requestBody = { filter: "test" };
const request = new NextRequest("http://localhost/api/chartmetric/search", {
method: "POST",
body: JSON.stringify(requestBody),
headers: { "Content-Type": "application/json" },
});

const result = await proxyChartmetricRequest(request, { path: ["search"] });
const body = await result.json();

expect(result.status).toBe(200);
expect(body).toEqual(chartmetricData);
expect(mockFetch).toHaveBeenCalledWith(
"https://api.chartmetric.com/api/search",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Authorization: "Bearer cm_access_token_2",
}),
}),
);
});
});
});
33 changes: 33 additions & 0 deletions lib/chartmetric/getChartmetricToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Exchanges the Chartmetric refresh token for a short-lived access token.
*
* @returns The Chartmetric access token string.
* @throws Error if the token exchange fails or the env variable is missing.
*/
export async function getChartmetricToken(): Promise<string> {
const refreshToken = process.env.CHARTMETRIC_REFRESH_TOKEN;

if (!refreshToken) {
throw new Error("CHARTMETRIC_REFRESH_TOKEN environment variable is not set");
}

const response = await fetch("https://api.chartmetric.com/api/token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ refreshtoken: refreshToken }),
});

if (!response.ok) {
throw new Error(`Chartmetric token exchange failed with status ${response.status}`);
}

const data = (await response.json()) as { access_token: string; expires_in: number };

if (!data.access_token) {
throw new Error("Chartmetric token response did not include an access_token");
}

return data.access_token;
}
105 changes: 105 additions & 0 deletions lib/chartmetric/proxyChartmetricRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { type NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { deductCredits } from "@/lib/credits/deductCredits";
import { getChartmetricToken } from "@/lib/chartmetric/getChartmetricToken";

/**
* Proxy handler for Chartmetric API requests.
*
* Authenticates the caller, deducts 5 credits, then forwards the request
* to the Chartmetric API using a server-side access token.
*
* Credit cost rationale: Chartmetric costs $350/month for API access. At 5 credits
* ($0.05) per call, we break even at ~7,000 calls/month (~233/day). A typical
* research task (6–7 API calls) costs 30–35 credits ($0.30–$0.35).
*
* @param request - The incoming NextRequest.
* @param params - Route params containing the Chartmetric path segments.
* @param params.path - Array of path segments to forward to the Chartmetric API.
* @returns The Chartmetric API response forwarded as JSON.
*/
export async function proxyChartmetricRequest(
request: NextRequest,
params: { path: string[] },
): Promise<NextResponse> {
// 1. Authenticate
const authResult = await validateAuthContext(request);

if (authResult instanceof NextResponse) {
return authResult;
}

const { accountId } = authResult;

// 2. Deduct 5 credits per Chartmetric API call
try {
await deductCredits({ accountId, creditsToDeduct: 5 });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);

if (message.toLowerCase().includes("insufficient credits")) {
return NextResponse.json(
{ status: "error", error: "Insufficient credits for Chartmetric API call" },
{ status: 402, headers: getCorsHeaders() },
);
}

return NextResponse.json(
{ status: "error", error: "Failed to deduct credits" },
{ status: 500, headers: getCorsHeaders() },
);
}
Comment on lines +36 to +52
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

Credits deducted before confirming upstream success.

Currently, credits are deducted prior to the Chartmetric API call. If the upstream request fails (network error, 5xx, rate limit, etc.), the caller loses 5 credits without receiving data. This could lead to user frustration.

Consider one of these approaches:

  1. Deduct after success: Move credit deduction after a successful upstream response (risk: user could abort before deduction).
  2. Refund on failure: If the upstream call fails, issue a credit refund.
  3. Accept as-is: Document this behavior clearly so users understand they're charged per attempt, not per successful response.

If the current behavior is intentional (e.g., to cover costs of failed calls), it's worth documenting in the JSDoc or user-facing API docs.

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

In `@lib/chartmetric/proxyChartmetricRequest.ts` around lines 36 - 52, Credits are
being deducted before the Chartmetric upstream call (deductCredits is called
pre-request) which charges users even if the upstream request fails; to fix,
move the deductCredits call to after a successful upstream response (i.e., call
deductCredits only when the Chartmetric request returns a 2xx and data is
returned) or, if you prefer to keep pre-charging, implement a refund path by
calling a refund function (e.g., refundCredits or addCredits with
creditsToRefund: 5) inside the catch/failed-response handling so failed
Chartmetric requests trigger a refund, and update the NextResponse.json error
branches to invoke that refund call before returning (refer to deductCredits,
the upstream Chartmetric request handler, and existing error response paths
using NextResponse.json and getCorsHeaders).


// 3. Get Chartmetric access token
let accessToken: string;
try {
accessToken = await getChartmetricToken();
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ status: "error", error: `Failed to obtain Chartmetric token: ${message}` },
{ status: 500, headers: getCorsHeaders() },
);
}

// 4. Build the Chartmetric URL, preserving query params
const pathSegments = params.path.join("/");
const originalUrl = new URL(request.url);
const chartmetricUrl = `https://api.chartmetric.com/api/${pathSegments}${originalUrl.search}`;

// 5. Read the request body for non-GET methods
let body: string | undefined;
if (request.method !== "GET" && request.method !== "HEAD") {
try {
body = await request.text();
} catch {
// If body reading fails, proceed without a body
}
}

// 6. Forward the request to Chartmetric
try {
const chartmetricResponse = await fetch(chartmetricUrl, {
method: request.method,
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
...(body ? { body } : {}),
});

const data = await chartmetricResponse.json();

return NextResponse.json(data, {
status: chartmetricResponse.status,
headers: getCorsHeaders(),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ status: "error", error: `Chartmetric API request failed: ${message}` },
{ status: 500, headers: getCorsHeaders() },
);
}
}
Loading