fix: Proxy Mintlify traffic through a Next.js API route (#25320)

* hotfix

* type fix and test fix

* update env example

* improvements

* more fix

* tada

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
Syed Ali Shahbaz
2025-11-21 18:43:09 +00:00
committed by GitHub
co-authored by Keith Williams
parent 0d77fc8474
commit a7d87fc5be
8 changed files with 853 additions and 48 deletions
+2 -1
View File
@@ -425,7 +425,8 @@ VAPID_PRIVATE_KEY=
# Mintlify chat api
# Power AI chat in for docs
NEXT_PUBLIC_MINTLIFY_CHAT_API_KEY=
MINTLIFY_CHAT_API_KEY=
NEXT_PUBLIC_ENABLE_MINTLIFY_CHAT=
NEXT_PUBLIC_CHAT_API_URL=
NEXT_PUBLIC_DOCS_URL=
@@ -0,0 +1,470 @@
import type { NextRequest } from "next/server";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as mintlifyChatValidation from "@calcom/lib/server/mintlifyChatValidation";
import { POST as topicPOST } from "../topic/route";
import { POST as messagePOST } from "../message/route";
// Mock fetch
global.fetch = vi.fn();
// Polyfill Response.json for test environment
if (!Response.json) {
Response.json = function (data: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(data), {
...init,
headers: {
"Content-Type": "application/json",
...init?.headers,
},
});
};
}
describe("Mintlify Chat Proxy Endpoints", () => {
beforeEach(() => {
vi.clearAllMocks();
// Set environment variables
process.env.MINTLIFY_CHAT_API_KEY = "test-api-key";
process.env.NEXT_PUBLIC_CHAT_API_URL = "https://api.mintlify.com";
});
afterEach(() => {
delete process.env.MINTLIFY_CHAT_API_KEY;
delete process.env.NEXT_PUBLIC_CHAT_API_URL;
});
describe("POST /api/mintlify-chat/topic", () => {
it("should create a topic successfully", async () => {
const mockTopicId = "test-topic-123";
// Mock Mintlify API response
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ topicId: mockTopicId }),
});
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
method: "POST",
}) as NextRequest;
const response = await topicPOST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.topicId).toBe(mockTopicId);
// Verify fetch was called with correct parameters
expect(global.fetch).toHaveBeenCalledWith(
"https://api.mintlify.com/topic",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer test-api-key",
},
}
);
});
it("should handle missing API key", async () => {
delete process.env.MINTLIFY_CHAT_API_KEY;
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
method: "POST",
}) as NextRequest;
const response = await topicPOST(request);
const data = await response.json();
expect(response.status).toBe(500);
// Generic error message - doesn't leak internal config details
expect(data.error).toBe("Failed to create topic. Please try again later.");
});
it("should handle Mintlify API errors", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
});
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
method: "POST",
}) as NextRequest;
const response = await topicPOST(request);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Failed to create topic");
});
it("should never expose API key in response", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ topicId: "test-topic-123" }),
});
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
method: "POST",
}) as NextRequest;
const response = await topicPOST(request);
const responseText = await response.text();
expect(responseText).not.toContain("test-api-key");
expect(responseText).not.toContain("Bearer");
});
});
describe("POST /api/mintlify-chat/message", () => {
it("should send a message and stream response", async () => {
const mockMessage = "What is Cal.com?";
const mockTopicId = "test-topic-123";
const mockResponse = "Cal.com is a scheduling platform.";
// Create a mock ReadableStream
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(mockResponse));
controller.close();
},
});
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
body: mockStream,
headers: new Headers({
"content-type": "text/plain",
"x-mintlify-base-url": "https://docs.cal.com",
}),
});
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: mockMessage,
topicId: mockTopicId,
}),
}) as NextRequest;
const response = await messagePOST(request);
expect(response.status).toBe(200);
expect(response.headers.get("x-mintlify-base-url")).toBe("https://docs.cal.com");
// Verify fetch was called with sanitized input
expect(global.fetch).toHaveBeenCalledWith(
"https://api.mintlify.com/message",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer test-api-key",
},
body: JSON.stringify({
message: mockMessage,
topicId: mockTopicId,
}),
}
);
});
it("should reject invalid JSON payload", async () => {
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: "invalid json",
}) as NextRequest;
const response = await messagePOST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("Invalid JSON payload");
});
it("should reject malformed message payload", async () => {
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: "",
topicId: "test-topic",
}),
}) as NextRequest;
const response = await messagePOST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain("Invalid message");
});
it("should reject oversized messages", async () => {
const largeMessage = "a".repeat(10001); // Exceeds MAX_MESSAGE_LENGTH
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: largeMessage,
topicId: "test-topic",
}),
}) as NextRequest;
const response = await messagePOST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain("exceeds maximum length");
});
it("should reject path traversal in topicId", async () => {
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: "test message",
topicId: "../../../etc/passwd",
}),
}) as NextRequest;
const response = await messagePOST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain("Path traversal");
});
it("should reject control characters in message", async () => {
const maliciousMessage = "test\x00message\x01with\x02control\x03chars";
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("sanitized response"));
controller.close();
},
});
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
body: mockStream,
headers: new Headers(),
});
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: maliciousMessage,
topicId: "test-topic-123",
}),
}) as NextRequest;
const response = await messagePOST(request);
// Should either sanitize (200) or reject with validation error (400)
expect([200, 400]).toContain(response.status);
if (response.status === 200) {
// If sanitized, verify control chars were removed from the proxied request
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const bodyArg = fetchCall?.[1]?.body;
if (typeof bodyArg === "string") {
const parsedBody = JSON.parse(bodyArg);
expect(parsedBody.message).not.toContain("\x00");
expect(parsedBody.message).not.toContain("\x01");
}
}
});
it("should reject invalid topicId format", async () => {
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: "test message",
topicId: "invalid topic id with spaces!@#$",
}),
}) as NextRequest;
const response = await messagePOST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain("Invalid topicId");
});
it("should sanitize response headers", async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("response"));
controller.close();
},
});
const mockHeaders = new Headers({
"content-type": "text/plain",
"x-mintlify-base-url": "https://docs.cal.com",
"x-custom-dangerous-header": "should-not-appear",
"authorization": "Bearer secret-key",
});
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
body: mockStream,
headers: mockHeaders,
});
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: "test",
topicId: "test-topic-123",
}),
}) as NextRequest;
const response = await messagePOST(request);
// Verify only safe headers are included
expect(response.headers.get("x-mintlify-base-url")).toBe("https://docs.cal.com");
expect(response.headers.get("x-custom-dangerous-header")).toBeNull();
expect(response.headers.get("authorization")).toBeNull();
});
it("should never expose API key in any response", async () => {
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("response text"));
controller.close();
},
});
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
body: mockStream,
headers: new Headers(),
});
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: "test",
topicId: "test-topic-123",
}),
}) as NextRequest;
const response = await messagePOST(request);
// Verify no API key in response headers
expect(response.headers.get("authorization")).toBeNull();
expect(response.headers.get("Authorization")).toBeNull();
// Read response text (for non-stream responses in test env)
const responseText = await response.text();
expect(responseText).not.toContain("test-api-key");
expect(responseText).not.toContain("Bearer");
});
it("should handle missing message field", async () => {
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
topicId: "test-topic-123",
}),
}) as NextRequest;
const response = await messagePOST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain("Invalid message");
});
it("should handle missing topicId field", async () => {
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: "test message",
}),
}) as NextRequest;
const response = await messagePOST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain("Invalid topicId");
});
});
describe("Security Tests", () => {
it("should never leak API key through error messages", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("Upstream error with API key: test-api-key")
);
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
method: "POST",
}) as NextRequest;
const response = await topicPOST(request);
const data = await response.json();
expect(response.status).toBe(500);
// Verify generic error message doesn't contain API key
expect(JSON.stringify(data)).not.toContain("test-api-key");
expect(data.error).toBe("Failed to create topic. Please try again later.");
});
it("should validate environment variables on every request", async () => {
const validateSpy = vi.spyOn(mintlifyChatValidation, "validateMintlifyConfig");
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ topicId: "test" }),
});
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
method: "POST",
}) as NextRequest;
await topicPOST(request);
expect(validateSpy).toHaveBeenCalled();
});
it("should validate message payload on every request", async () => {
const validateSpy = vi.spyOn(mintlifyChatValidation, "validateChatMessage");
const mockStream = new ReadableStream({
start(controller) {
controller.close();
},
});
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
status: 200,
body: mockStream,
headers: new Headers(),
});
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
method: "POST",
body: JSON.stringify({
message: "test",
topicId: "test-topic-123",
}),
}) as NextRequest;
await messagePOST(request);
expect(validateSpy).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,133 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import {
validateChatMessage,
validateMintlifyConfig,
sanitizeResponseHeaders,
} from "@calcom/lib/server/mintlifyChatValidation";
/**
* POST /api/mintlify-chat/message
*
* Sends a message to a Mintlify chat topic and streams the response.
* This endpoint proxies requests to Mintlify's API while:
* - Keeping the API key secure on the server side
* - Validating and sanitizing user input
* - Preventing malicious payloads
*
* Expected body:
* {
* message: string,
* topicId: string
* }
*/
export async function POST(req: NextRequest) {
try {
// Validate configuration
const { apiKey, apiBaseUrl } = validateMintlifyConfig();
// Parse and validate request body
let body;
try {
body = await req.json();
} catch {
return NextResponse.json(
{ error: "Invalid JSON payload" },
{ status: 400 }
);
}
// Validate and sanitize the message payload
let message: string;
let topicId: string;
try {
const validated = validateChatMessage(body);
message = validated.message;
topicId = validated.topicId;
} catch (validationError) {
// For validation errors, return detailed message with 400
if (validationError instanceof Error) {
return NextResponse.json(
{ error: validationError.message },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Invalid request" },
{ status: 400 }
);
}
// Make request to Mintlify
const messageUrl = `${apiBaseUrl}/message`;
console.log("[Mintlify Chat Message] Fetching message endpoint", { messageUrl, topicId });
const queryResponse = await fetch(messageUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ message, topicId }),
});
if (!queryResponse.ok) {
return NextResponse.json(
{ error: "Failed to send message" },
{ status: queryResponse.status }
);
}
if (!queryResponse.body) {
return NextResponse.json(
{ error: "No response body from Mintlify" },
{ status: 500 }
);
}
// Sanitize response headers before forwarding
const sanitizedHeaders = sanitizeResponseHeaders(queryResponse.headers);
// Stream the response back to the client
// We need to pipe the ReadableStream from Mintlify directly to the client
const stream = new ReadableStream({
async start(controller) {
const reader = queryResponse.body!.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
controller.close();
break;
}
controller.enqueue(value);
}
} catch (error) {
console.error("[Mintlify Chat Message Stream]", error);
controller.error(error);
}
},
});
return new NextResponse(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked",
...sanitizedHeaders,
},
});
} catch {
// Return generic error message to avoid leaking internal details
// Validation errors are already handled above with specific messages
return NextResponse.json(
{ error: "Failed to send message. Please try again later." },
{ status: 500 }
);
}
}
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { validateMintlifyConfig } from "@calcom/lib/server/mintlifyChatValidation";
/**
* POST /api/mintlify-chat/topic
*
* Creates a new Mintlify chat topic.
* This endpoint proxies requests to Mintlify's API while keeping
* the API key secure on the server side.
*/
export async function POST(_req: NextRequest) {
try {
// Validate configuration
const { apiKey, apiBaseUrl } = validateMintlifyConfig();
// Make request to Mintlify
const topicUrl = `${apiBaseUrl}/topic`;
console.log("[Mintlify Chat Topic] Fetching topic endpoint", { topicUrl });
const topicResponse = await fetch(topicUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
});
if (!topicResponse.ok) {
return NextResponse.json(
{ error: "Failed to create topic" },
{ status: topicResponse.status }
);
}
const topic = await topicResponse.json();
return NextResponse.json(topic);
} catch {
// Return generic error message to avoid leaking internal details
return NextResponse.json(
{ error: "Failed to create topic. Please try again later." },
{ status: 500 }
);
}
}
+3 -6
View File
@@ -17,7 +17,6 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { isMac } from "@calcom/lib/isMac";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";
@@ -27,9 +26,6 @@ type shortcutArrayType = {
shortcuts?: string[];
};
type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"];
type EventTypeGroup = EventTypeGroups[number];
const getApps = Object.values(appStoreMetadata).map(({ name, slug }) => ({
id: slug,
name,
@@ -242,7 +238,6 @@ export const KBarRoot = ({ children }: { children: React.ReactNode }) => {
},
...appStoreActions,
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <KBarProvider actions={actions}>{children}</KBarProvider>;
@@ -253,7 +248,9 @@ export const KBarContent = () => {
useEventTypesAction();
const [inputText, setInputText] = useState("");
const [aiResponse, setAiResponse] = useState("");
const showAiChat = process.env.NEXT_PUBLIC_MINTLIFY_CHAT_API_KEY && process.env.NEXT_PUBLIC_CHAT_API_URL;
const showAiChat =
process.env.NEXT_PUBLIC_ENABLE_MINTLIFY_CHAT === "true" &&
!!process.env.NEXT_PUBLIC_CHAT_API_URL;
return (
<KBarPortal>
+48 -40
View File
@@ -1,35 +1,45 @@
/**
* This file contains utility functions for interacting with the Mintlify chat API.
* The code was adapted from https://mintlify.com/docs/advanced/rest-api/overview#getting-started. The original source can be found at https://github.com/mintlify/discovery-api-example/tree/main/src/utils.
*
* NOTE: These functions now call our internal proxy API routes at /api/mintlify-chat/*
* instead of calling Mintlify directly. This keeps the API key secure on the server side.
*/
const API_KEY = process.env.NEXT_PUBLIC_MINTLIFY_CHAT_API_KEY;
const apiBaseUrl = process.env.NEXT_PUBLIC_CHAT_API_URL;
/**
* Creates a new Mintlify chat topic via our internal proxy
*/
export const createChat = async () => {
if (!API_KEY || !apiBaseUrl) return;
try {
const topicResponse = await fetch("/api/mintlify-chat/topic", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const topicResponse = await fetch(`${apiBaseUrl}/topic`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
});
if (!topicResponse.ok) {
console.error("Failed to create chat topic:", topicResponse.statusText);
return;
}
if (!topicResponse.ok) {
return;
}
const topic: unknown = await topicResponse.json();
const topic: unknown = await topicResponse.json();
if (topic && typeof topic === "object" && "topicId" in topic && typeof topic.topicId === "string") {
return topic.topicId;
} else {
if (topic && typeof topic === "object" && "topicId" in topic && typeof topic.topicId === "string") {
return topic.topicId;
} else {
return undefined;
}
} catch (error) {
console.error("Error creating chat topic:", error);
return undefined;
}
};
/**
* Generates a response from Mintlify via our internal proxy
* Streams the response back to the caller in chunks
*/
export const generateResponse = async ({
topicId,
userQuery,
@@ -39,37 +49,35 @@ export const generateResponse = async ({
userQuery: string;
onChunkReceived: (chunk: string, baseUrl?: string, finalChunk?: boolean) => void;
}) => {
if (!API_KEY || !apiBaseUrl) return;
const queryResponse = await fetch(
`
${apiBaseUrl}/message`,
{
try {
const queryResponse = await fetch("/api/mintlify-chat/message", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({ message: userQuery, topicId }),
});
if (!queryResponse.ok || !queryResponse.body) {
throw Error(queryResponse.statusText);
}
);
const streamReader = queryResponse.body.getReader();
if (!queryResponse.ok || !queryResponse.body) {
throw Error(queryResponse.statusText);
}
const streamReader = queryResponse.body.getReader();
for (;;) {
const { done, value } = await streamReader.read();
if (done) {
onChunkReceived("", queryResponse.headers.get("x-mintlify-base-url") ?? "", true);
return;
}
for (;;) {
const { done, value } = await streamReader.read();
if (done) {
const newValue = new TextDecoder().decode(value);
onChunkReceived(newValue, queryResponse.headers.get("X-Mintlify-Base-Url") ?? "", true);
return;
onChunkReceived(newValue);
}
const newValue = new TextDecoder().decode(value);
onChunkReceived(newValue);
} catch (error) {
console.error("Error generating response:", error);
throw error;
}
};
@@ -0,0 +1,147 @@
/**
* Validation and sanitization helpers for Mintlify chat proxy
*
* This module provides security utilities to validate user input before
* proxying requests to the Mintlify API, preventing:
* - Path traversal attacks
* - Control character injection
* - Malicious payloads
* - Oversized requests
*/
const MAX_MESSAGE_LENGTH = 10000; // 10KB max message length
const MAX_TOPIC_ID_LENGTH = 200;
const PATH_TRAVERSAL_PATTERNS = ["../", "..\\"]; // basic sequences we never expect inside identifiers
const stripControlCharacters = (input: string) => {
if (typeof input !== "string") {
throw new Error("Input must be a string");
}
// Remove null bytes
let sanitized = input.replace(/\0/g, "");
// Remove other control characters (except newlines, tabs, carriage returns which might be needed in messages)
// eslint-disable-next-line no-control-regex
sanitized = sanitized.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, "");
return sanitized;
};
/**
* Sanitizes free-form user messages by stripping control characters.
* This intentionally does not reject path-like substrings so users can ask about "../".
*/
export function sanitizeMessageString(input: string): string {
return stripControlCharacters(input);
}
/**
* Sanitizes identifiers (topic IDs) and rejects path traversal attempts.
*/
export function sanitizeTopicIdentifier(input: string): string {
const sanitized = stripControlCharacters(input);
// Check for path traversal attempts
if (PATH_TRAVERSAL_PATTERNS.some((pattern) => sanitized.includes(pattern))) {
throw new Error("Path traversal detected");
}
return sanitized;
}
/**
* Validates a chat message payload for the /message endpoint
*/
export function validateChatMessage(payload: unknown): {
message: string;
topicId: string;
} {
if (!payload || typeof payload !== "object") {
throw new Error("Invalid payload: must be an object");
}
const { message, topicId } = payload as Record<string, unknown>;
// Validate message
if (typeof message !== "string") {
throw new Error("Invalid message: must be a string");
}
if (message.length === 0) {
throw new Error("Invalid message: cannot be empty");
}
if (message.length > MAX_MESSAGE_LENGTH) {
throw new Error(`Invalid message: exceeds maximum length of ${MAX_MESSAGE_LENGTH} characters`);
}
// Validate topicId
if (typeof topicId !== "string") {
throw new Error("Invalid topicId: must be a string");
}
if (topicId.length === 0) {
throw new Error("Invalid topicId: cannot be empty");
}
if (topicId.length > MAX_TOPIC_ID_LENGTH) {
throw new Error(`Invalid topicId: exceeds maximum length of ${MAX_TOPIC_ID_LENGTH} characters`);
}
// Sanitize both fields
const sanitizedMessage = sanitizeMessageString(message);
const sanitizedTopicId = sanitizeTopicIdentifier(topicId);
// Validate topicId format (alphanumeric, dashes, underscores only)
if (!/^[a-zA-Z0-9_-]+$/.test(sanitizedTopicId)) {
throw new Error("Invalid topicId: must contain only alphanumeric characters, dashes, and underscores");
}
return {
message: sanitizedMessage,
topicId: sanitizedTopicId,
};
}
/**
* Validates that the API key and base URL are configured
*/
export function validateMintlifyConfig(): {
apiKey: string;
apiBaseUrl: string;
} {
const apiKey = process.env.MINTLIFY_CHAT_API_KEY;
const rawApiBaseUrl = process.env.NEXT_PUBLIC_CHAT_API_URL;
const apiBaseUrl = rawApiBaseUrl?.replace(/\/+$/, "");
if (!apiKey) {
throw new Error("MINTLIFY_CHAT_API_KEY is not configured");
}
if (!apiBaseUrl) {
throw new Error("NEXT_PUBLIC_CHAT_API_URL is not configured");
}
return { apiKey, apiBaseUrl };
}
/**
* Sanitizes response headers before sending to client
* Only allows safe headers through
*/
export function sanitizeResponseHeaders(headers: Headers): Record<string, string> {
const allowedHeaders = ["content-type", "x-mintlify-base-url"];
const sanitized: Record<string, string> = {};
allowedHeaders.forEach((header) => {
const value = headers.get(header);
if (value) {
sanitized[header] = value;
}
});
return sanitized;
}
+2 -1
View File
@@ -225,9 +225,10 @@
"LOCAL_TESTING_DOMAIN_VERCEL",
"AUTH_BEARER_TOKEN_CLOUDFLARE",
"CLOUDFLARE_ZONE_ID",
"NEXT_PUBLIC_MINTLIFY_CHAT_API_KEY",
"MINTLIFY_CHAT_API_KEY",
"NEXT_PUBLIC_CHAT_API_URL",
"NEXT_PUBLIC_DOCS_URL",
"NEXT_PUBLIC_ENABLE_MINTLIFY_CHAT",
"CLOUDFLARE_VERCEL_CNAME",
"CLOUDFLARE_DNS",
"EMBED_PUBLIC_EMBED_FINGER_PRINT",