diff --git a/.env.example b/.env.example index 65c9520433..72dc1ea40a 100644 --- a/.env.example +++ b/.env.example @@ -434,14 +434,7 @@ BLACKLISTED_GUEST_EMAILS= NEXT_PUBLIC_VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= -# Mintlify chat api -# Power AI chat in for docs -MINTLIFY_CHAT_API_KEY= -NEXT_PUBLIC_ENABLE_MINTLIFY_CHAT= -NEXT_PUBLIC_CHAT_API_URL= -NEXT_PUBLIC_DOCS_URL= - -# Custom privacy policy / terms URLs (for self-hosters: change to your privacy policy / terms URLs) +# Custom privacy policy/ terms URLs (for self-hosters: change to your privacy policy / terms URLs) NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL= NEXT_PUBLIC_WEBSITE_TERMS_URL= diff --git a/apps/web/app/api/mintlify-chat/__tests__/route.test.ts b/apps/web/app/api/mintlify-chat/__tests__/route.test.ts deleted file mode 100644 index 839e194558..0000000000 --- a/apps/web/app/api/mintlify-chat/__tests__/route.test.ts +++ /dev/null @@ -1,470 +0,0 @@ -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).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).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).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).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).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).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).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).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).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).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).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(); - }); - }); -}); - diff --git a/apps/web/app/api/mintlify-chat/message/route.ts b/apps/web/app/api/mintlify-chat/message/route.ts deleted file mode 100644 index c147c87b91..0000000000 --- a/apps/web/app/api/mintlify-chat/message/route.ts +++ /dev/null @@ -1,133 +0,0 @@ -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 } - ); - } -} - diff --git a/apps/web/app/api/mintlify-chat/topic/route.ts b/apps/web/app/api/mintlify-chat/topic/route.ts deleted file mode 100644 index 2438efd7c0..0000000000 --- a/apps/web/app/api/mintlify-chat/topic/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 } - ); - } -} - diff --git a/apps/web/modules/shell/Kbar.tsx b/apps/web/modules/shell/Kbar.tsx index c4f22f0134..e848f8d0bd 100644 --- a/apps/web/modules/shell/Kbar.tsx +++ b/apps/web/modules/shell/Kbar.tsx @@ -11,28 +11,85 @@ import { useRegisterActions, } from "kbar"; import { useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { useMemo } from "react"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; -import { MintlifyChat } from "@calcom/features/mintlify-chat/MintlifyChat"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { isMac } from "@calcom/lib/isMac"; import { trpc } from "@calcom/trpc/react"; import { Icon } from "@calcom/ui/components/icon"; import { Tooltip } from "@calcom/ui/components/tooltip"; -type shortcutArrayType = { +type ShortcutArrayType = { shortcuts?: string[]; }; -const getApps = Object.values(appStoreMetadata).map(({ name, slug }) => ({ +type ActionConfig = { + id: string; + name: string; + section?: string; + shortcut?: string[]; + keywords: string; + href: string; +}; + +type AppAction = { + id: string; + name: string; + section: string; + keywords: string; +}; + +const getApps: AppAction[] = Object.values(appStoreMetadata).map(({ name, slug }) => ({ id: slug, name, section: "Installable Apps", keywords: `app ${name}`, })); -const useEventTypesAction = () => { +const KBAR_ACTION_CONFIGS: ActionConfig[] = [ + { id: "workflows", name: "workflows", section: "workflows", shortcut: ["w", "f"], keywords: "workflows automation", href: "/workflows" }, + { id: "event-types", name: "event_types_page_title", section: "event_types_page_title", shortcut: ["e", "t"], keywords: "event types", href: "/event-types" }, + { id: "app-store", name: "app_store", section: "apps", shortcut: ["a", "s"], keywords: "app store", href: "/apps" }, + { id: "upcoming-bookings", name: "upcoming", section: "bookings", shortcut: ["u", "b"], keywords: "upcoming bookings", href: "/bookings/upcoming" }, + { id: "recurring-bookings", name: "recurring", section: "bookings", shortcut: ["r", "b"], keywords: "recurring bookings", href: "/bookings/recurring" }, + { id: "past-bookings", name: "past", section: "bookings", shortcut: ["p", "b"], keywords: "past bookings", href: "/bookings/past" }, + { id: "cancelled-bookings", name: "cancelled", section: "bookings", shortcut: ["c", "b"], keywords: "cancelled bookings", href: "/bookings/cancelled" }, + { id: "schedule", name: "availability", section: "availability", shortcut: ["s", "a"], keywords: "schedule availability", href: "/availability" }, + { id: "profile", name: "profile", section: "profile", shortcut: ["p", "s"], keywords: "setting profile", href: "/settings/my-account/profile" }, + { id: "avatar", name: "change_avatar", section: "profile", shortcut: ["c", "a"], keywords: "remove change modify avatar", href: "/settings/my-account/profile" }, + { id: "timezone", name: "timezone", section: "profile", shortcut: ["c", "t"], keywords: "change modify timezone", href: "/settings/my-account/general" }, + { id: "brand-color", name: "brand_color", section: "profile", shortcut: ["b", "c"], keywords: "change modify brand color", href: "/settings/my-account/appearance" }, + { id: "teams", name: "teams", shortcut: ["t", "s"], keywords: "add manage modify team", href: "/settings/teams" }, + { id: "password", name: "change_password", section: "security", shortcut: ["c", "p"], keywords: "change modify password", href: "/settings/security/password" }, + { id: "two-factor", name: "two_factor_auth", section: "security", shortcut: ["t", "f", "a"], keywords: "two factor authentication", href: "/settings/security/two-factor-auth" }, + { id: "impersonation", name: "user_impersonation_heading", section: "security", shortcut: ["u", "i"], keywords: "user impersonation", href: "/settings/security/impersonation" }, + { id: "license", name: "choose_a_license", section: "admin", shortcut: ["u", "l"], keywords: "license", href: "/auth/setup?step=1" }, + { id: "webhooks", name: "Webhooks", section: "developer", shortcut: ["w", "h"], keywords: "webhook automation", href: "/settings/developer/webhooks" }, + { id: "api-keys", name: "api_keys", section: "developer", shortcut: ["a", "p", "i"], keywords: "api keys", href: "/settings/developer/api-keys" }, + { id: "billing", name: "manage_billing", section: "billing", shortcut: ["m", "b"], keywords: "billing view manage", href: "/settings/billing" }, +]; + +function buildKbarActions(push: (href: string) => void): Action[] { + const staticActions: Action[] = KBAR_ACTION_CONFIGS.map((config) => ({ + id: config.id, + name: config.name, + section: config.section, + shortcut: config.shortcut, + keywords: config.keywords, + perform: () => push(config.href), + })); + + const appStoreActions: Action[] = getApps.map((item) => ({ + ...item, + perform: () => push(`/apps/${item.id}`), + })); + + return [...staticActions, ...appStoreActions]; +} + +function useEventTypesAction(): void { const router = useRouter(); const { data } = trpc.viewer.eventTypes.getEventTypesFromGroup.useInfiniteQuery( { @@ -42,7 +99,7 @@ const useEventTypesAction = () => { { refetchOnWindowFocus: false, staleTime: 1 * 60 * 60 * 1000, - getNextPageParam: (lastPage) => lastPage.nextCursor, + getNextPageParam: (lastPage: { nextCursor?: number | null }) => lastPage.nextCursor, } ); @@ -59,196 +116,31 @@ const useEventTypesAction = () => { ); }) ?? []; - const actions = eventTypeActions?.length ? eventTypeActions : []; + let actions: Action[] = []; + if (eventTypeActions.length > 0) { + actions = eventTypeActions; + } - return useRegisterActions(actions); -}; + useRegisterActions(actions); +} -export const KBarRoot = ({ children }: { children: React.ReactNode }) => { +const KBarRoot = ({ children }: { children: ReactNode }): JSX.Element => { const router = useRouter(); - - // grab link to events - // quick nested actions would be extremely useful - - const actions = useMemo(() => { - const appStoreActions = getApps.map((item) => ({ - ...item, - perform: () => router.push(`/apps/${item.id}`), - })); - return [ - { - id: "workflows", - name: "workflows", - section: "workflows", - shortcut: ["w", "f"], - keywords: "workflows automation", - perform: () => router.push("/workflows"), - }, - { - id: "event-types", - name: "event_types_page_title", - section: "event_types_page_title", - shortcut: ["e", "t"], - keywords: "event types", - perform: () => router.push("/event-types"), - }, - { - id: "app-store", - name: "app_store", - section: "apps", - shortcut: ["a", "s"], - keywords: "app store", - perform: () => router.push("/apps"), - }, - { - id: "upcoming-bookings", - name: "upcoming", - section: "bookings", - shortcut: ["u", "b"], - keywords: "upcoming bookings", - perform: () => router.push("/bookings/upcoming"), - }, - { - id: "recurring-bookings", - name: "recurring", - section: "bookings", - shortcut: ["r", "b"], - keywords: "recurring bookings", - perform: () => router.push("/bookings/recurring"), - }, - { - id: "past-bookings", - name: "past", - section: "bookings", - shortcut: ["p", "b"], - keywords: "past bookings", - perform: () => router.push("/bookings/past"), - }, - { - id: "cancelled-bookings", - name: "cancelled", - section: "bookings", - shortcut: ["c", "b"], - keywords: "cancelled bookings", - perform: () => router.push("/bookings/cancelled"), - }, - { - id: "schedule", - name: "availability", - section: "availability", - shortcut: ["s", "a"], - keywords: "schedule availability", - perform: () => router.push("/availability"), - }, - { - id: "profile", - name: "profile", - section: "profile", - shortcut: ["p", "s"], - keywords: "setting profile", - perform: () => router.push("/settings/my-account/profile"), - }, - { - id: "avatar", - name: "change_avatar", - section: "profile", - shortcut: ["c", "a"], - keywords: "remove change modify avatar", - perform: () => router.push("/settings/my-account/profile"), - }, - { - id: "timezone", - name: "timezone", - section: "profile", - shortcut: ["c", "t"], - keywords: "change modify timezone", - perform: () => router.push("/settings/my-account/general"), - }, - { - id: "brand-color", - name: "brand_color", - section: "profile", - shortcut: ["b", "c"], - keywords: "change modify brand color", - perform: () => router.push("/settings/my-account/appearance"), - }, - { - id: "teams", - name: "teams", - shortcut: ["t", "s"], - keywords: "add manage modify team", - perform: () => router.push("/settings/teams"), - }, - { - id: "password", - name: "change_password", - section: "security", - shortcut: ["c", "p"], - keywords: "change modify password", - perform: () => router.push("/settings/security/password"), - }, - { - id: "two-factor", - name: "two_factor_auth", - section: "security", - shortcut: ["t", "f", "a"], - keywords: "two factor authentication", - perform: () => router.push("/settings/security/two-factor-auth"), - }, - { - id: "impersonation", - name: "user_impersonation_heading", - section: "security", - shortcut: ["u", "i"], - keywords: "user impersonation", - perform: () => router.push("/settings/security/impersonation"), - }, - { - id: "license", - name: "choose_a_license", - section: "admin", - shortcut: ["u", "l"], - keywords: "license", - perform: () => router.push("/auth/setup?step=1"), - }, - { - id: "webhooks", - name: "Webhooks", - section: "developer", - shortcut: ["w", "h"], - keywords: "webhook automation", - perform: () => router.push("/settings/developer/webhooks"), - }, - { - id: "api-keys", - name: "api_keys", - section: "developer", - shortcut: ["a", "p", "i"], - keywords: "api keys", - perform: () => router.push("/settings/developer/api-keys"), - }, - { - id: "billing", - name: "manage_billing", - section: "billing", - shortcut: ["m", "b"], - keywords: "billing view manage", - perform: () => router.push("/settings/billing"), - }, - ...appStoreActions, - ]; - }, []); + const actions = useMemo(() => buildKbarActions(router.push), [router.push]); return {children}; }; -export const KBarContent = () => { +function CommandKey(): JSX.Element { + if (isMac) { + return ; + } + return <>CTRL; +} + +const KBarContent = (): JSX.Element => { const { t } = useLocale(); useEventTypesAction(); - const [inputText, setInputText] = useState(""); - const [aiResponse, setAiResponse] = useState(""); - const showAiChat = - process.env.NEXT_PUBLIC_ENABLE_MINTLIFY_CHAT === "true" && !!process.env.NEXT_PUBLIC_CHAT_API_URL; return ( @@ -259,23 +151,15 @@ export const KBarContent = () => { { - setInputText(e.currentTarget.value.trim()); - if (aiResponse) setAiResponse(""); - }} /> - {showAiChat && inputText && ( - - )}
{t("navigate")} {t("open")} - {isMac ? : "CTRL"} + + K {t("close")}
@@ -286,23 +170,33 @@ export const KBarContent = () => { ); }; -export const KBarTrigger = () => { +function getTooltipContent(): string { + if (isMac) { + return "⌘ + K"; + } + return "CTRL + K"; +} + +const KBarTrigger = (): JSX.Element | null => { const { query } = useKBar(); - return query ? ( - <> - - - - - ) : null; + + if (!query) { + return null; + } + + return ( + + + + ); }; -const DisplayShortcuts = (item: shortcutArrayType) => { +function DisplayShortcuts(item: ShortcutArrayType): JSX.Element { const shortcuts = item.shortcuts; return ( @@ -318,32 +212,45 @@ const DisplayShortcuts = (item: shortcutArrayType) => { })} ); +} + +type RenderItemProps = { + item: string | Action; + active: boolean; }; -function RenderResults() { +function renderResultItem(item: string | Action, active: boolean, t: (key: string) => string): JSX.Element { + if (typeof item === "string") { + return
{t(item)}
; + } + + let background = "var(--cal-bg-default)"; + let borderLeft = "2px solid transparent"; + if (active) { + background = "var(--cal-bg-subtle)"; + borderLeft = "2px solid var(--cal-border)"; + } + + return ( +
+ {t(item.name)} + +
+ ); +} + +function RenderResults(): JSX.Element { const { results } = useMatches(); const { t } = useLocale(); return ( - typeof item === "string" ? ( -
{t(item)}
- ) : ( -
- {t(item.name)} - -
- ) - } + onRender={({ item, active }: RenderItemProps): JSX.Element => renderResultItem(item, active, t)} /> ); } + +export { KBarRoot, KBarContent, KBarTrigger }; diff --git a/packages/features/mintlify-chat/MintlifyChat.tsx b/packages/features/mintlify-chat/MintlifyChat.tsx deleted file mode 100644 index ff54bff1d9..0000000000 --- a/packages/features/mintlify-chat/MintlifyChat.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/* eslint-disable react/no-danger */ -import type { Dispatch, SetStateAction } from "react"; -import { useState } from "react"; - -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; -import classNames from "@calcom/ui/classNames"; -import { Icon } from "@calcom/ui/components/icon"; -import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton"; - -import { getFormattedCitations, handleAiChat, optionallyAddBaseUrl } from "../mintlify-chat/util"; - -interface MintlifyChatProps { - searchText: string; - aiResponse: string; - setAiResponse: Dispatch>; -} - -export const MintlifyChat = ({ searchText, aiResponse, setAiResponse }: MintlifyChatProps) => { - const { t } = useLocale(); - const [topicId, setTopicId] = useState(""); - const [baseUrl, setBaseUrl] = useState(process.env.NEXT_PUBLIC_DOCS_URL ?? ""); - const [isGenerating, setIsGenerating] = useState(false); - const [error, setError] = useState(""); - - const onChunkReceived = (chunk: string, baseUrl?: string, finalChunk?: boolean) => { - setAiResponse((prev) => { - return prev + chunk; - }); - if (baseUrl) { - setBaseUrl(baseUrl); - } - if (finalChunk) { - setIsGenerating(false); - } - }; - - const citations = getFormattedCitations(aiResponse.split("||")[1]) ?? []; - const answer = aiResponse.split("||")[0] ?? ""; - - return ( - <> -
{ - if (isGenerating) return; - setIsGenerating(true); - setAiResponse(""); - setError(""); - const { id, error } = await handleAiChat(onChunkReceived, searchText, topicId); - if (id) { - setTopicId(id); - } else if (error) { - setIsGenerating(false); - setError(error); - } - }} - className={classNames( - "hover:bg-subtle flex items-center gap-3 px-4 py-2 transition", - isGenerating ? "cursor-not-allowed" : "cursor-pointer" - )}> -
- -
-
-
- {t("can_you_tell_me_about")} {searchText} -
-
{t("use_ai_to_answer_your_questions")}
-
-
-
- {error &&

{error}

} - {isGenerating && aiResponse === "" ? ( - - - - ) : ( - <> -
-
- {baseUrl && - citations.map((citation) => { - if (citation.title) { - const url = optionallyAddBaseUrl(baseUrl, citation.url); - return ( - -
- {citation.title} -
-
- ); - } - return null; - })} -
- - )} -
- - ); -}; diff --git a/packages/features/mintlify-chat/util.ts b/packages/features/mintlify-chat/util.ts deleted file mode 100644 index adfb7bb1cc..0000000000 --- a/packages/features/mintlify-chat/util.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * 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. - */ - -/** - * Creates a new Mintlify chat topic via our internal proxy - */ -export const createChat = async () => { - try { - const topicResponse = await fetch("/api/mintlify-chat/topic", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - }); - - if (!topicResponse.ok) { - console.error("Failed to create chat topic:", topicResponse.statusText); - return; - } - - const topic: unknown = await topicResponse.json(); - - 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, - onChunkReceived, -}: { - topicId: string; - userQuery: string; - onChunkReceived: (chunk: string, baseUrl?: string, finalChunk?: boolean) => void; -}) => { - try { - const queryResponse = await fetch("/api/mintlify-chat/message", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ message: userQuery, topicId }), - }); - - 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; - } - - - const newValue = new TextDecoder().decode(value); - onChunkReceived(newValue); - } - } catch (error) { - console.error("Error generating response:", error); - throw error; - } -}; - -export const handleAiChat = async ( - onChunkReceived: (chunk: string, baseUrl?: string, finalChunk?: boolean) => void, - userQuery: string, - topicId?: string -) => { - let id = null; - let error = ""; - try { - if (!topicId) { - id = await createChat(); - } - - if (!id) - return { - id, - error: "Error creating topic. Please try again later", - }; - - await generateResponse({ - topicId: id, - onChunkReceived, - userQuery, - }); - } catch (err) { - if (err instanceof Error) { - error = err.message; - } else { - error = "k_bar_ai_error"; - } - } - - return { - id, - error, - }; -}; - -type ChunkMetadata = { - id: string; - link?: string; - metadata?: Record; - chunk_html?: string; -}; - -export const generateDeeplink = (chunkMetadata: ChunkMetadata) => { - if ( - !( - "metadata" in chunkMetadata && - !!chunkMetadata.metadata && - "title" in chunkMetadata.metadata && - "link" in chunkMetadata && - typeof chunkMetadata.link === "string" - ) - ) - return ""; - const section = chunkMetadata.metadata.title; - const link = optionallyAddLeadingSlash(chunkMetadata.link); - if (section && typeof section === "string") { - const sectionSlug = section - .toLowerCase() - .replaceAll(" ", "-") - .replaceAll(/[^a-zA-Z0-9-_#]/g, ""); - - return `${link}#${sectionSlug}`; - } - - return link; -}; - -type UnformattedCitation = { - id: string; - link: string; - chunk_html: string; - metadata: Record; -}; - -export type Citation = { - citationNumber: number; - title: string; - url: string; - rootRecordId?: number; - rootRecordType?: string; -}; - -export function getFormattedCitations(rawContent?: string): Citation[] { - try { - const citations: UnformattedCitation[] = JSON.parse(rawContent ?? "[]"); - - const uniqueCitations = new Map( - citations.map((citation, index) => { - const title = citation.metadata.title ?? ""; - const formattedCitation = { - citationNumber: index, - title: citation.metadata.title ?? "", - url: generateDeeplink(citation), - }; - - return [title, formattedCitation]; - }) - ); - - return Array.from(uniqueCitations.values()); - } catch { - return []; - } -} - -export function optionallyRemoveLeadingSlash(path: string) { - return path.startsWith("/") ? path.substring(1) : path; -} - -export function optionallyAddLeadingSlash(path: string) { - return path.startsWith("/") ? path : `/${path}`; -} - -export function optionallyAddBaseUrl(baseUrl: string, url: string) { - // absolute urls - if (url.startsWith("https://")) return url; - - const urlWithLeadingSlash = optionallyAddLeadingSlash(url); - return `${baseUrl}${urlWithLeadingSlash}`; -} diff --git a/packages/lib/server/mintlifyChatValidation.ts b/packages/lib/server/mintlifyChatValidation.ts deleted file mode 100644 index f61781ec7d..0000000000 --- a/packages/lib/server/mintlifyChatValidation.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * 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; - - // 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 { - const allowedHeaders = ["content-type", "x-mintlify-base-url"]; - const sanitized: Record = {}; - - allowedHeaders.forEach((header) => { - const value = headers.get(header); - if (value) { - sanitized[header] = value; - } - }); - - return sanitized; -} - diff --git a/turbo.json b/turbo.json index be567a424e..31fae255e3 100644 --- a/turbo.json +++ b/turbo.json @@ -228,10 +228,6 @@ "LOCAL_TESTING_DOMAIN_VERCEL", "AUTH_BEARER_TOKEN_CLOUDFLARE", "CLOUDFLARE_ZONE_ID", - "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",