From 5794584624fda5f5c85f9681e8bcab9a53decc98 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Tue, 6 Jan 2026 15:37:33 +0100 Subject: [PATCH] chore: remove Mintlify AI chat from CMD+K widget (#26485) * chore: remove Mintlify AI chat from CMD+K widget - Remove MintlifyChat component and related state from Kbar.tsx - Delete packages/features/mintlify-chat directory (MintlifyChat.tsx, util.ts) - Delete apps/web/app/api/mintlify-chat API routes and tests - Delete packages/lib/server/mintlifyChatValidation.ts - Remove Mintlify-related env vars from .env.example and turbo.json - Refactor Kbar.tsx to fix lint issues (explicit types, exports at end) Co-Authored-By: peer@cal.com * fix: use ReactNode import instead of JSX from react The JSX type is not exported from 'react' module. Use the global JSX.Element type (available in React projects) and import ReactNode for the children prop type. Co-Authored-By: peer@cal.com * feat: add all event-types and upcoming bookings to KBar - Increase event types limit from 10 to 100 to show more event types - Add useUpcomingBookingsAction hook to fetch and display upcoming bookings - Bookings show title, date, and time in KBar search results - Navigate to booking details page when selecting a booking Co-Authored-By: peer@cal.com * Revert "feat: add all event-types and upcoming bookings to KBar" This reverts commit 69d03397e3820e45e7207eb55b38117d269eae5e. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .env.example | 9 +- .../api/mintlify-chat/__tests__/route.test.ts | 470 ------------------ .../app/api/mintlify-chat/message/route.ts | 133 ----- apps/web/app/api/mintlify-chat/topic/route.ts | 48 -- apps/web/modules/shell/Kbar.tsx | 363 +++++--------- .../features/mintlify-chat/MintlifyChat.tsx | 101 ---- packages/features/mintlify-chat/util.ts | 205 -------- packages/lib/server/mintlifyChatValidation.ts | 147 ------ turbo.json | 4 - 9 files changed, 136 insertions(+), 1344 deletions(-) delete mode 100644 apps/web/app/api/mintlify-chat/__tests__/route.test.ts delete mode 100644 apps/web/app/api/mintlify-chat/message/route.ts delete mode 100644 apps/web/app/api/mintlify-chat/topic/route.ts delete mode 100644 packages/features/mintlify-chat/MintlifyChat.tsx delete mode 100644 packages/features/mintlify-chat/util.ts delete mode 100644 packages/lib/server/mintlifyChatValidation.ts 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",