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:
co-authored by
Keith Williams
parent
0d77fc8474
commit
a7d87fc5be
+2
-1
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user