From 9364c96997805550b6c74efbeee78707c16c6e5d Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Tue, 28 Jul 2026 13:37:25 +0800 Subject: [PATCH 01/36] refactor: replace manual portal popovers with Radix Popover (modal + smart positioning) --- .../components/app/views/month-view.tsx | 55 ++++++++++++------- .../components/app/views/year-view.tsx | 52 ++++++++++++------ 2 files changed, 70 insertions(+), 37 deletions(-) diff --git a/apps/calendar/components/app/views/month-view.tsx b/apps/calendar/components/app/views/month-view.tsx index ec5e69f0..38eddb01 100644 --- a/apps/calendar/components/app/views/month-view.tsx +++ b/apps/calendar/components/app/views/month-view.tsx @@ -18,9 +18,9 @@ import { DEFAULT_ACCENT, } from '@/components/app/views/event-colors' import type { ViewConfig } from '@/components/app/calendar-types' -import { useCallback, useState } from 'react' -import { createPortal } from 'react-dom' +import { useCallback, useEffect, useState } from 'react' import { ScrollArea } from '@zntr/ui/scroll-area' +import { Popover, PopoverAnchor, PopoverContent } from '@zntr/ui/popover' interface RemainingPopoverState { key: string @@ -76,23 +76,26 @@ export default function MonthView({ day: Date, allDayEvents: CalendarEvent[], ) => { - const key = format(day, 'yyyy-MM-dd') - if (remainingPopover?.key === key) { - setRemainingPopover(null) - return - } const rect = e.currentTarget.getBoundingClientRect() + const key = format(day, 'yyyy-MM-dd') setRemainingPopover({ key, anchorRect: rect, remainingEvents: allDayEvents.slice(3), }) }, - [remainingPopover], + [], ) const closeRemainingPopover = useCallback(() => setRemainingPopover(null), []) + useEffect(() => { + document.body.style.overflow = remainingPopover ? 'hidden' : '' + return () => { + document.body.style.overflow = '' + } + }, [remainingPopover]) + return ( <>
@@ -193,19 +196,31 @@ export default function MonthView({ })}
- {remainingPopover && - typeof document !== 'undefined' && - createPortal( + { + if (!open) closeRemainingPopover() + }} + modal={true} + > +
+ + {remainingPopover && ( +
@@ -268,9 +283,9 @@ export default function MonthView({
)}
-
, - document.body, + )} +
) } diff --git a/apps/calendar/components/app/views/year-view.tsx b/apps/calendar/components/app/views/year-view.tsx index dce222e8..689d1d3b 100644 --- a/apps/calendar/components/app/views/year-view.tsx +++ b/apps/calendar/components/app/views/year-view.tsx @@ -10,11 +10,11 @@ import { } from 'date-fns' import { isZhLanguage, translations } from '@zntr/i18n/calendar' import type { CalendarEvent } from '../calendar' -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { cn } from '@zntr/utils' import type { ViewConfig } from '@/components/app/calendar-types' -import { createPortal } from 'react-dom' import { ScrollArea } from '@zntr/ui/scroll-area' +import { Popover, PopoverAnchor, PopoverContent } from '@zntr/ui/popover' interface YearViewProps { date: Date @@ -144,20 +144,23 @@ export default function YearView({ const handleDayClick = useCallback( (e: React.MouseEvent, day: Date, dayKey: string) => { - const key = `${day.getMonth()}-${dayKey}` - if (popover?.key === key) { - setPopover(null) - return - } const rect = e.currentTarget.getBoundingClientRect() const dayEvents = eventsByDayKey.get(dayKey) ?? [] + const key = `${day.getMonth()}-${dayKey}` setPopover({ key, anchorRect: rect, day, dayEvents }) }, - [popover, eventsByDayKey], + [eventsByDayKey], ) const closePopover = useCallback(() => setPopover(null), []) + useEffect(() => { + document.body.style.overflow = popover ? 'hidden' : '' + return () => { + document.body.style.overflow = '' + } + }, [popover]) + return (
@@ -208,16 +211,31 @@ export default function YearView({ ))}
- {popover && - typeof document !== 'undefined' && - createPortal( + { + if (!open) closePopover() + }} + modal={true} + > +
+ + {popover && ( +
@@ -288,9 +306,9 @@ export default function YearView({
)}
-
, - document.body, + )} +
) } From a58fab4b4a4548c826b93e296c8ffb09e7a97428 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Tue, 28 Jul 2026 13:46:22 +0800 Subject: [PATCH 02/36] fix: scrollarea height constraint and popover anchor positioning --- .../components/app/views/month-view.tsx | 50 +++++++------- .../components/app/views/year-view.tsx | 67 ++++++++++--------- 2 files changed, 61 insertions(+), 56 deletions(-) diff --git a/apps/calendar/components/app/views/month-view.tsx b/apps/calendar/components/app/views/month-view.tsx index 38eddb01..ddf262ec 100644 --- a/apps/calendar/components/app/views/month-view.tsx +++ b/apps/calendar/components/app/views/month-view.tsx @@ -207,7 +207,10 @@ export default function MonthView({
-
-
-
{t.events}
- -
- {remainingPopover.remainingEvents.length > 0 ? ( - +
+
{t.events}
+ +
+ {remainingPopover.remainingEvents.length > 0 ? ( +
+
{remainingPopover.remainingEvents.map((event) => (
+
+ ) : ( +
+ {t.noEventsFound} +
+ )} )} diff --git a/apps/calendar/components/app/views/year-view.tsx b/apps/calendar/components/app/views/year-view.tsx index 689d1d3b..6349a362 100644 --- a/apps/calendar/components/app/views/year-view.tsx +++ b/apps/calendar/components/app/views/year-view.tsx @@ -222,7 +222,9 @@ export default function YearView({
-
-
-
- {popover.day.toLocaleDateString( - isZhLanguage(config.language.code as any) - ? 'zh-CN' - : 'en-US', - { - year: 'numeric', - month: 'long', - day: 'numeric', - }, - )} -
- +
+
+ {popover.day.toLocaleDateString( + isZhLanguage(config.language.code as any) ? 'zh-CN' : 'en-US', + { + year: 'numeric', + month: 'long', + day: 'numeric', + }, + )}
+ +
- {popover.dayEvents.length > 0 ? ( - + {popover.dayEvents.length > 0 ? ( +
+
{popover.dayEvents.map((event) => (
+
+ ) : ( +
+ {t.noEventsFound} +
+ )} )} From 4310e4c3e6d781c472b708c3fe6cbfe5baf89737 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Tue, 28 Jul 2026 13:55:16 +0800 Subject: [PATCH 03/36] fix: replace scrollarea with overflow-y-auto, reposition month popover --- .../components/app/views/month-view.tsx | 95 +++++++++---------- .../components/app/views/year-view.tsx | 70 ++++++-------- 2 files changed, 73 insertions(+), 92 deletions(-) diff --git a/apps/calendar/components/app/views/month-view.tsx b/apps/calendar/components/app/views/month-view.tsx index ddf262ec..502115e0 100644 --- a/apps/calendar/components/app/views/month-view.tsx +++ b/apps/calendar/components/app/views/month-view.tsx @@ -19,7 +19,6 @@ import { } from '@/components/app/views/event-colors' import type { ViewConfig } from '@/components/app/calendar-types' import { useCallback, useEffect, useState } from 'react' -import { ScrollArea } from '@zntr/ui/scroll-area' import { Popover, PopoverAnchor, PopoverContent } from '@zntr/ui/popover' interface RemainingPopoverState { @@ -76,7 +75,10 @@ export default function MonthView({ day: Date, allDayEvents: CalendarEvent[], ) => { - const rect = e.currentTarget.getBoundingClientRect() + const cell = (e.currentTarget as HTMLElement).parentElement?.parentElement + const rect = cell + ? cell.getBoundingClientRect() + : e.currentTarget.getBoundingClientRect() const key = format(day, 'yyyy-MM-dd') setRemainingPopover({ key, @@ -207,11 +209,11 @@ export default function MonthView({
{remainingPopover && (
@@ -238,49 +239,39 @@ export default function MonthView({
{remainingPopover.remainingEvents.length > 0 ? ( -
- -
- {remainingPopover.remainingEvents.map((event) => ( - - ))} -
-
+
+ {remainingPopover.remainingEvents.map((event) => ( + + ))}
) : (
diff --git a/apps/calendar/components/app/views/year-view.tsx b/apps/calendar/components/app/views/year-view.tsx index 6349a362..8ed2234d 100644 --- a/apps/calendar/components/app/views/year-view.tsx +++ b/apps/calendar/components/app/views/year-view.tsx @@ -13,7 +13,6 @@ import type { CalendarEvent } from '../calendar' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { cn } from '@zntr/utils' import type { ViewConfig } from '@/components/app/calendar-types' -import { ScrollArea } from '@zntr/ui/scroll-area' import { Popover, PopoverAnchor, PopoverContent } from '@zntr/ui/popover' interface YearViewProps { @@ -262,45 +261,36 @@ export default function YearView({
{popover.dayEvents.length > 0 ? ( -
- -
- {popover.dayEvents.map((event) => ( - - ))} -
-
+
+ {popover.dayEvents.map((event) => ( + + ))}
) : (
From e2e2582f5211ec8f49878fa942fc4cb297aaab90 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Tue, 28 Jul 2026 17:33:14 +0800 Subject: [PATCH 04/36] fix: resolve year view popover/countdown bugs, remove mini calendar sheet --- .../components/app/sidebar/countdown.tsx | 2 +- .../app/sidebar/mini-calendar-sheet.tsx | 264 ------------------ .../components/app/sidebar/right-sidebar.tsx | 27 +- .../components/app/views/year-view.tsx | 12 +- 4 files changed, 8 insertions(+), 297 deletions(-) delete mode 100644 apps/calendar/components/app/sidebar/mini-calendar-sheet.tsx diff --git a/apps/calendar/components/app/sidebar/countdown.tsx b/apps/calendar/components/app/sidebar/countdown.tsx index b548cf94..12d401ab 100644 --- a/apps/calendar/components/app/sidebar/countdown.tsx +++ b/apps/calendar/components/app/sidebar/countdown.tsx @@ -310,7 +310,7 @@ export function CountdownTool({ open, onOpenChange }: CountdownToolProps) { await createCountdown({ id, name: newCountdown.name, - targetDate: selectedDate.toISOString(), + targetDate: toDateString(selectedDate), repeat: (newCountdown.repeat || 'none') as | 'none' | 'weekly' diff --git a/apps/calendar/components/app/sidebar/mini-calendar-sheet.tsx b/apps/calendar/components/app/sidebar/mini-calendar-sheet.tsx deleted file mode 100644 index 6fba6ec7..00000000 --- a/apps/calendar/components/app/sidebar/mini-calendar-sheet.tsx +++ /dev/null @@ -1,264 +0,0 @@ -'use client' - -import { - format, - addDays, - subDays, - startOfWeek, - isSameDay, - isToday, -} from 'date-fns' -import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@zntr/ui/sheet' -import { useCalendar } from '@/components/providers/calendar-context' -import { translations, useLanguage } from '@zntr/i18n/calendar' -import { CalendarDays, ChevronRight } from 'lucide-react' -import { ScrollArea } from '@zntr/ui/scroll-area' -import type { CalendarEvent } from '../calendar' -import { Button } from '@zntr/ui/button' -import { useState, useEffect } from 'react' -import { cn } from '@zntr/utils' -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from '@zntr/ui/empty' - -const getWeekStartsOn = (locale: string): 0 | 1 => { - try { - const intlLocale = new Intl.Locale(locale) - const firstDay = ( - intlLocale as Intl.Locale & { weekInfo?: { firstDay?: number } } - ).weekInfo?.firstDay - if (firstDay === 1) return 1 - if (firstDay === 7) return 0 - } catch {} - - return [ - 'zh-CN', - 'zh-TW', - 'zh-HK', - 'de', - 'fr', - 'es', - 'it', - 'pt', - 'ru', - 'sv', - 'fi', - 'nb', - 'pl', - 'tr', - 'uk', - 'lt', - 'lv', - 'sl', - 'mk', - 'sr', - 'th', - 'vi', - ].includes(locale) - ? 1 - : 0 -} - -interface MiniCalendarSheetProps { - open: boolean - onOpenChange: (open: boolean) => void - selectedDate: Date - onDateSelect: (date: Date) => void -} - -export default function MiniCalendarSheet({ - open, - onOpenChange, - selectedDate, - onDateSelect, -}: MiniCalendarSheetProps) { - const [language] = useLanguage() - const t = translations[language] - const { events } = useCalendar() - const [currentDate, setCurrentDate] = useState(selectedDate) - - useEffect(() => { - setCurrentDate(selectedDate) - }, [selectedDate]) - - const weekStartsOn = getWeekStartsOn(language) - const weekStart = startOfWeek(currentDate, { weekStartsOn }) - const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)) - - const dayEvents = events - .filter((event) => isSameDay(new Date(event.startDate), currentDate)) - .sort( - (a, b) => - new Date(a.startDate).getTime() - new Date(b.startDate).getTime(), - ) - - const handleDayClick = (day: Date) => { - setCurrentDate(day) - onDateSelect(day) - } - - function getDarkerColorClass(color: string) { - const colorMapping: Record = { - 'bg-[#E6F6FD]': '#3B82F6', - 'bg-[#E7F8F2]': '#10B981', - 'bg-[#FEF5E6]': '#F59E0B', - 'bg-[#FFE4E6]': '#EF4444', - 'bg-[#F3EEFE]': '#8B5CF6', - 'bg-[#FCE7F3]': '#EC4899', - 'bg-[#EEF2FF]': '#6366F1', - 'bg-[#FFF0E5]': '#FB923C', - 'bg-[#E6FAF7]': '#14B8A6', - } - - return colorMapping[color] || '#3A3A3A' - } - - const handlePreviousWeek = () => { - setCurrentDate((prevDate) => subDays(prevDate, 7)) - } - - const handleNextWeek = () => { - setCurrentDate((prevDate) => addDays(prevDate, 7)) - } - - const handleTodayClick = () => { - const today = new Date() - setCurrentDate(today) - onDateSelect(today) - } - - const formatEventTime = (event: CalendarEvent) => { - const startDate = new Date(event.startDate) - return format(startDate, 'HH:mm') - } - - const calculateDuration = (event: CalendarEvent) => { - const startDate = new Date(event.startDate) - const endDate = new Date(event.endDate) - const durationMs = endDate.getTime() - startDate.getTime() - const durationMinutes = Math.round(durationMs / (1000 * 60)) - - return `${durationMinutes} ${t.minutesShort}` - } - - const getDayNames = () => - Array.from({ length: 7 }, (_, index) => - new Intl.DateTimeFormat(language, { weekday: 'short' }).format( - addDays(weekStart, index), - ), - ) - - const monthYearLabel = new Intl.DateTimeFormat(language, { - year: 'numeric', - month: 'long', - }).format(currentDate) - - return ( - - - - - - {t.calendar} - - - -
-
-
- {monthYearLabel} -
-
- - - -
-
- -
- {getDayNames().map((day, index) => ( -
- {day} -
- ))} - {weekDays.map((day) => ( - - ))} -
-
- -
-

- {new Intl.DateTimeFormat(language, { - weekday: 'long', - month: 'long', - day: 'numeric', - }).format(currentDate)} -

- - - {dayEvents.length === 0 ? ( - - - - - - {t.today} - {t.noEventsToday} - - - ) : ( -
- {dayEvents.map((event) => ( -
-
-
-
-
{event.title}
-
- {formatEventTime(event)} -
-
-
- {calculateDuration(event)} -
-
-
- ))} -
- )} - -
- - - ) -} diff --git a/apps/calendar/components/app/sidebar/right-sidebar.tsx b/apps/calendar/components/app/sidebar/right-sidebar.tsx index 6d4cd1c9..979cda86 100644 --- a/apps/calendar/components/app/sidebar/right-sidebar.tsx +++ b/apps/calendar/components/app/sidebar/right-sidebar.tsx @@ -1,6 +1,5 @@ -import { Calendar, Bookmark } from 'lucide-react' +import { Bookmark } from 'lucide-react' import { ClockDashed } from '@/components/icons/clock-dashed' -import MiniCalendarSheet from './mini-calendar-sheet' import { Button } from '@zntr/ui/button' import BookmarkPanel from './bookmark-panel' import { CountdownTool } from './countdown' @@ -18,29 +17,13 @@ export default function RightSidebar({ onViewChange: _onViewChange, onEventClick, }: RightSidebarProps) { - const [miniCalendarOpen, setMiniCalendarOpen] = useState(false) - const [selectedDate, setSelectedDate] = useState(new Date()) const [bookmarkPanelOpen, setBookmarkPanelOpen] = useState(false) const [countdownOpen, setCountdownOpen] = useState(false) - const handleDateSelect = (date: Date) => { - setSelectedDate(date) - } - return ( <>
- {} - -
- {} - - {popover && (
@@ -271,6 +270,7 @@ export default function YearView({ event.color, )} onClick={(e) => { + closePopover() onEventClick(event, e.currentTarget, e.clientX, e.clientY) }} style={{ From 1ec13f5bafa89124adac86f9bce6d9d9619c74a1 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Tue, 28 Jul 2026 18:00:38 +0800 Subject: [PATCH 05/36] feat: install ioredis dependency --- apps/calendar/package.json | 1 + pnpm-lock.yaml | 1310 +++++++++++++++++++++--------------- 2 files changed, 784 insertions(+), 527 deletions(-) diff --git a/apps/calendar/package.json b/apps/calendar/package.json index a1cafac0..e0c74862 100644 --- a/apps/calendar/package.json +++ b/apps/calendar/package.json @@ -42,6 +42,7 @@ "fumadocs-mdx": "15.0.12", "fumadocs-ui": "16.10.5", "geist": "latest", + "ioredis": "^5.11.1", "lucide-react": "1.21.0", "motion": "^12.41.0", "next": "16.2.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0497df27..7a184bf0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,9 @@ importers: geist: specifier: latest version: 1.7.2(next@16.2.9(@babel/core@7.29.7)(babel-plugin-macros@3.1.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)) + ioredis: + specifier: ^5.11.1 + version: 5.11.1 lucide-react: specifier: 1.21.0 version: 1.21.0(react@19.2.7) @@ -176,7 +179,7 @@ importers: version: 1.9.2 radix-ui: specifier: latest - version: 1.6.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.6.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: 19.2.7 version: 19.2.7 @@ -216,7 +219,7 @@ importers: devDependencies: '@testing-library/jest-dom': specifier: ^6.9.1 - version: 6.9.1 + version: 6.10.0(@testing-library/dom@10.4.1) '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -299,7 +302,7 @@ importers: version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) radix-ui: specifier: latest - version: 1.6.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.6.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: 19.2.7 version: 19.2.7 @@ -1474,6 +1477,9 @@ packages: cpu: [x64] os: [win32] + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@jest/types@27.0.2': resolution: {integrity: sha512-XpjCtJ/99HB4PmyJ2vgmN7vT+JLP7RW1FBT9RgnMFS4Dt7cvIyBee8O3/j98aUZ34ZpenPZFqmaaObWSeL65dg==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -1838,14 +1844,17 @@ packages: '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} + '@radix-ui/number@1.1.3': + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + '@radix-ui/primitive@1.1.4': resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} - '@radix-ui/primitive@1.1.5': - resolution: {integrity: sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==} + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} - '@radix-ui/react-accessible-icon@1.1.11': - resolution: {integrity: sha512-HQDOFTKwSnmUij6l54wYJJtxTAnxI71+YJLOrjm2ladFB8HAV5Jt7hwaZPhWTGBkYoW4+ZAOfNZrLDh/qvxSYA==} + '@radix-ui/react-accessible-icon@1.1.15': + resolution: {integrity: sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1870,8 +1879,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-accordion@1.2.16': - resolution: {integrity: sha512-BpZJNmetujnGgUI6OX0jEhEmlA46WPqgub8Rv09Kyquwd0cc1ndMKpiPYCjmBU6KSSRPAMtgLpEoZSG/tdNIWQ==} + '@radix-ui/react-accordion@1.2.20': + resolution: {integrity: sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1883,8 +1892,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-alert-dialog@1.1.19': - resolution: {integrity: sha512-FA7n1f6D/DwGE0+AWxiY5LacNbbExQuEgMubeG06idEaH+mSLuf9dp/qBNqOnvbTQ+4gZ2ue1RATF1Ub91Mg5g==} + '@radix-ui/react-alert-dialog@1.1.23': + resolution: {integrity: sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1909,8 +1918,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-arrow@1.1.11': - resolution: {integrity: sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==} + '@radix-ui/react-arrow@1.1.15': + resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1922,8 +1931,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-aspect-ratio@1.1.11': - resolution: {integrity: sha512-IUAhIVpBUvP5NNICjlaB1OFmtRLGqQqTF3ZOSGPoq3XeLXRFtHiWTRxSVEULgOd9GQR2c7tsYqDnhUennapZnw==} + '@radix-ui/react-aspect-ratio@1.1.15': + resolution: {integrity: sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1935,8 +1944,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-avatar@1.2.2': - resolution: {integrity: sha512-sST0qh8GzOB7besQ3tMLWLyngnRuSk0gc/Hm+667KYKQFCt6Y6ZXv25WlqM7dIDK54ULCh5+CHmk4LIolzfz+A==} + '@radix-ui/react-avatar@1.2.6': + resolution: {integrity: sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1948,8 +1957,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-checkbox@1.3.7': - resolution: {integrity: sha512-JroKHfQBfh+fDuzpPsBC+pESkhuq8ql4hljTguz8MWnS35cISr3d/Jhl9kYrB44FlDtxCArYdDvTx+BSsJ64rQ==} + '@radix-ui/react-checkbox@1.3.11': + resolution: {integrity: sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1974,8 +1983,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collapsible@1.1.16': - resolution: {integrity: sha512-opfXRe6nnzyGmCDPx+l1Aqo/RbqWtQal2FnsBqF9hhePp6j0LsRoBaRxcMOlTv+uYTJVtWYZKg9t9wTe+BA/ZA==} + '@radix-ui/react-collapsible@1.1.20': + resolution: {integrity: sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2000,8 +2009,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collection@1.1.12': - resolution: {integrity: sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==} + '@radix-ui/react-collection@1.1.15': + resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2022,8 +2031,17 @@ packages: '@types/react': optional: true - '@radix-ui/react-context-menu@2.3.3': - resolution: {integrity: sha512-PS+gKE0z2prJ74Y0sM+brAGK4mYOHIR7TlcV5EJgUQ6E0xMvyswkK2X4yRqyganrzsRL+WCSKAPu0NQITICRWg==} + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.3.7': + resolution: {integrity: sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2044,8 +2062,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-context@1.2.0': - resolution: {integrity: sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==} + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -2066,8 +2084,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dialog@1.1.19': - resolution: {integrity: sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==} + '@radix-ui/react-dialog@1.1.23': + resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2088,6 +2106,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-direction@1.1.4': + resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dismissable-layer@1.1.13': resolution: {integrity: sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==} peerDependencies: @@ -2101,8 +2128,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dismissable-layer@1.1.15': - resolution: {integrity: sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==} + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2114,8 +2141,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dropdown-menu@2.1.20': - resolution: {integrity: sha512-slfm+rRaZRuQBvHq60lXvSVUPhid0IPtjSZzIuUlWZMUs01iYZNlGS3mJgRD3ChLQVBAYlKiL/tFyWGX+dz8Xw==} + '@radix-ui/react-dropdown-menu@2.1.24': + resolution: {integrity: sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2136,6 +2163,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-focus-scope@1.1.10': resolution: {integrity: sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==} peerDependencies: @@ -2149,8 +2185,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-focus-scope@1.1.12': - resolution: {integrity: sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==} + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2162,8 +2198,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-form@0.1.12': - resolution: {integrity: sha512-JTX94E4LDL91rzLg7X0mHPdxr0A8JEdVwZEmeOwZJSMDHCGW5DFtSlTSJozUyUs807IQmnvbfzKZFVCK5DmkqQ==} + '@radix-ui/react-form@0.1.16': + resolution: {integrity: sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2175,8 +2211,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-hover-card@1.1.19': - resolution: {integrity: sha512-2KTgMLQtKvicznQgbindEI2RZ3QbDIwU5gabjUPwFJsormjGDz+rUvO4NANmYwzEEpTcTONUt33vBHIfTIVSfw==} + '@radix-ui/react-hover-card@1.1.23': + resolution: {integrity: sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2197,8 +2233,17 @@ packages: '@types/react': optional: true - '@radix-ui/react-label@2.1.11': - resolution: {integrity: sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ==} + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.15': + resolution: {integrity: sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2210,8 +2255,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-menu@2.1.20': - resolution: {integrity: sha512-VsUrXxFe9d2ScbZF0fR/oPR1+qjyeLs5p0jzG8h90puMoA9bq4SirYlXbE+USRg9Q2qTeJSFNqjw2nts8jJe4w==} + '@radix-ui/react-menu@2.1.24': + resolution: {integrity: sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2223,8 +2268,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-menubar@1.1.20': - resolution: {integrity: sha512-gzFZvybgmwYsFBWDqanycIoEYnhyk8MMnuLamdFVHUZYGp4COM+sqXiwbnn0VMWqGLeeU7GV7jm+dXRa+Wufag==} + '@radix-ui/react-menubar@1.1.24': + resolution: {integrity: sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2249,8 +2294,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-navigation-menu@1.2.18': - resolution: {integrity: sha512-K9HiuxZ6xCwSaHcIuUpxyhy4w5gpwzWjh9dHTSbMN3Ix4qAyVObS9RlU3zMycb0PO3v9Tpk0BXMwWvXOUbVXew==} + '@radix-ui/react-navigation-menu@1.2.22': + resolution: {integrity: sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2262,8 +2307,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-one-time-password-field@0.1.12': - resolution: {integrity: sha512-nQLu5OAcORDQp1EHAv6k3mJGV1hjMTw2NTGVAsGE1g/mWeNqAd1R5jyaAs3U+A8ZD/W8XNPY2yKT0ZdQnqo3NA==} + '@radix-ui/react-one-time-password-field@0.1.16': + resolution: {integrity: sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2275,8 +2320,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-password-toggle-field@0.1.7': - resolution: {integrity: sha512-gB1Mr8vzdv1XzDjrtJTXmL0JORRs1B4g7ngUs0F+H2VvMOwXTZMTmLCl0wZZ3m7ylX8TssI7NCvgiSHmLuTm/A==} + '@radix-ui/react-password-toggle-field@0.1.11': + resolution: {integrity: sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2301,8 +2346,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popover@1.1.19': - resolution: {integrity: sha512-jkrTdQVxnIB8fpn0NyyxW9CTB5aCXZZelVz5z+Xmii6g5WxMqS3fInNslZ63puP39+Puu4jYohUK31y3dT87gQ==} + '@radix-ui/react-popover@1.1.23': + resolution: {integrity: sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2327,8 +2372,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popper@1.3.3': - resolution: {integrity: sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==} + '@radix-ui/react-popper@1.3.7': + resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2353,8 +2398,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-portal@1.1.13': - resolution: {integrity: sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==} + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2366,8 +2411,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-presence@1.1.6': - resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2379,8 +2424,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-presence@1.1.7': - resolution: {integrity: sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==} + '@radix-ui/react-presence@1.1.6': + resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2392,8 +2437,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.1.6': - resolution: {integrity: sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==} + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2405,8 +2450,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.1.7': - resolution: {integrity: sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==} + '@radix-ui/react-primitive@2.1.6': + resolution: {integrity: sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2418,8 +2463,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-progress@1.1.12': - resolution: {integrity: sha512-ZPHyI0JyzoH/rP0tq2uRaIZTj/4s8+kAbqPz+e2N8+ejHvwPJ889dHhqn+vh7PNvNeq+boAoH9yzqeoShzwF2w==} + '@radix-ui/react-progress@1.1.16': + resolution: {integrity: sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2431,8 +2476,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-radio-group@1.4.3': - resolution: {integrity: sha512-WwZFjWV4s3aC1QtR3k04R+oANHtX2q6fgKlc7MCEiDNlnTxCZ3H8k3mHtEgVlOejystwk1WQgarQhNOQZ2bK1g==} + '@radix-ui/react-radio-group@1.4.7': + resolution: {integrity: sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2457,8 +2502,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-roving-focus@1.1.15': - resolution: {integrity: sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==} + '@radix-ui/react-roving-focus@1.1.19': + resolution: {integrity: sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2483,8 +2528,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-scroll-area@1.2.14': - resolution: {integrity: sha512-bBODCWZK7JTbQLHs0uIP4f73wIWatakK4OS33UzkR1x897wu0PuO658a3f+6P2GEGyDzGYMuHRatMVoAk9WZTw==} + '@radix-ui/react-scroll-area@1.2.18': + resolution: {integrity: sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2496,8 +2541,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-select@2.3.3': - resolution: {integrity: sha512-L5RQTXz6Anxsf9CCv+pTgiAsUpyVj7rJxsGtmhFaEOJ++cVfXucv4qWfsIO0AIB4NAhi3yovWGVMKKS1Xf1Wrg==} + '@radix-ui/react-select@2.3.7': + resolution: {integrity: sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2509,8 +2554,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-separator@1.1.11': - resolution: {integrity: sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ==} + '@radix-ui/react-separator@1.1.15': + resolution: {integrity: sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2522,8 +2567,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-slider@1.4.3': - resolution: {integrity: sha512-CWVVj+XaTom0SKCqw1EUgb0NuiLwS+N3OFG73mVEezKEjgNIvZiu0EevMelSSU+CbX3owbqJweG2gPU31WGC5A==} + '@radix-ui/react-slider@1.4.7': + resolution: {integrity: sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2544,8 +2589,17 @@ packages: '@types/react': optional: true - '@radix-ui/react-switch@1.3.3': - resolution: {integrity: sha512-1+mlB4/lxJfk5tgJ4g+R5mUCbRpPE1T9+UsEyeLYbGgMtwiMgmuTnfKz4Mw1nHALHjuwyxw4MLd4cSHn6pNSlQ==} + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.3.7': + resolution: {integrity: sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2570,8 +2624,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-tabs@1.1.17': - resolution: {integrity: sha512-nRyXnrAVCwjeXcHbvEbLS6ndbTeKHG1RqCP4A8Gw5L4cemDzPXdD8rAmr6wet0v57R69wGvuIIsFjHSVkZiMzQ==} + '@radix-ui/react-tabs@1.1.21': + resolution: {integrity: sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2596,8 +2650,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toast@1.2.19': - resolution: {integrity: sha512-SxfVZfVOibWKWdkf0Xx1awW2d09fQu4V4PXDY1j5hi4MVf7MWdJZqTBJMa1KWtOr1S6GGtCk02nniZ0Iia+dHw==} + '@radix-ui/react-toast@1.2.23': + resolution: {integrity: sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2609,8 +2663,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toggle-group@1.1.15': - resolution: {integrity: sha512-gIC5Q+Xljg7lmUdzSuDoy0t97yZn1sZl00Ra37ZvKrYdWnQLU6sWLd09yG8cIB9jUAlQfHgJ2ACAG00MFwsqSQ==} + '@radix-ui/react-toggle-group@1.1.19': + resolution: {integrity: sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2622,8 +2676,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toggle@1.1.14': - resolution: {integrity: sha512-QI/hB65XKWACA66P64A+aHxtLUgHJeJLkaQa+awUNXT6T3swndtY5DojeHA+vldrTspMTtFBd7HfZ9QGbM1Qrw==} + '@radix-ui/react-toggle@1.1.18': + resolution: {integrity: sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2635,8 +2689,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toolbar@1.1.15': - resolution: {integrity: sha512-t/iEuVjUnXXtrsGK40AA43uIx37sn3AqZ7oAVnPICK6lFJP6dzMzWR3U9b6eCfFjb6wtSEqkJ9Rn9xDjiOx20g==} + '@radix-ui/react-toolbar@1.1.19': + resolution: {integrity: sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2648,8 +2702,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-tooltip@1.2.12': - resolution: {integrity: sha512-U3HoftgWnmla78vzQbLvKKb7bUYJxoiiqYFzp1wu/TBMyDqMZSuCl3aRICsD6EfVEwcJD2mumGDGUXLFVqQHKA==} + '@radix-ui/react-tooltip@1.2.16': + resolution: {integrity: sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2670,6 +2724,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.2.3': resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} peerDependencies: @@ -2679,6 +2742,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-effect-event@0.0.3': resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} peerDependencies: @@ -2688,6 +2760,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-escape-keydown@1.1.2': resolution: {integrity: sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==} peerDependencies: @@ -2697,8 +2778,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-escape-keydown@1.1.3': - resolution: {integrity: sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg==} + '@radix-ui/react-use-escape-keydown@1.1.5': + resolution: {integrity: sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -2706,8 +2787,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-is-hydrated@0.1.1': - resolution: {integrity: sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==} + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -2724,6 +2805,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-previous@1.1.2': resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==} peerDependencies: @@ -2733,6 +2823,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-previous@1.1.4': + resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.2': resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==} peerDependencies: @@ -2742,6 +2841,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-size@1.1.2': resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} peerDependencies: @@ -2751,8 +2859,17 @@ packages: '@types/react': optional: true - '@radix-ui/react-visually-hidden@1.2.6': - resolution: {integrity: sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==} + '@radix-ui/react-use-size@1.1.4': + resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.11': + resolution: {integrity: sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2764,8 +2881,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-visually-hidden@1.2.7': - resolution: {integrity: sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==} + '@radix-ui/react-visually-hidden@1.2.6': + resolution: {integrity: sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2780,6 +2897,9 @@ packages: '@radix-ui/rect@1.1.2': resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + '@react-email/render@2.0.9': resolution: {integrity: sha512-M89LiXy2q+9tmQ4VMR0rYGuEe6NJ6HhZsSxBMoLwIia5fVOLcZQcZe2GEO0nAkLZspDjEhn7mtMRPmeMKECEdQ==} engines: {node: '>=20.0.0'} @@ -3284,9 +3404,11 @@ packages: resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} - '@testing-library/jest-dom@6.9.1': - resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} - engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/jest-dom@6.10.0': + resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' '@testing-library/react@16.3.2': resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} @@ -3924,6 +4046,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + cobe@0.6.4: resolution: {integrity: sha512-huuGFnDoXLy/tsCZYYa/H35BBRs9cxsS0XKJ3BXjRp699cQKuoEVrvKlAQMx0DKXG7+VUv4jsHVrS7yPbkLSkQ==} @@ -4177,6 +4303,10 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -5100,6 +5230,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -6089,8 +6223,8 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - radix-ui@1.6.2: - resolution: {integrity: sha512-OwYUjzMwiInCUxgAWpPsavXC3Kh4iyi/49uU1/qZTG3RQDlvegyk1GOMiGvSkjua1RDb3JD3fo3eroL9FV4GQw==} + radix-ui@1.6.7: + resolution: {integrity: sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -6232,6 +6366,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + redux-thunk@3.1.0: resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} peerDependencies: @@ -6623,6 +6765,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + standardwebhooks@1.0.0: resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} @@ -8210,6 +8355,8 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@ioredis/commands@1.10.0': {} + '@jest/types@27.0.2': dependencies: '@types/istanbul-lib-coverage': 2.0.6 @@ -8494,13 +8641,15 @@ snapshots: '@radix-ui/number@1.1.2': {} + '@radix-ui/number@1.1.3': {} + '@radix-ui/primitive@1.1.4': {} - '@radix-ui/primitive@1.1.5': {} + '@radix-ui/primitive@1.1.7': {} - '@radix-ui/react-accessible-icon@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-accessible-icon@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -8524,30 +8673,30 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-accordion@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-collapsible': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-accordion@1.2.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-alert-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-alert-dialog@1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dialog': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -8563,47 +8712,47 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-arrow@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-aspect-ratio@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-aspect-ratio@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-avatar@1.2.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-avatar@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-checkbox@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-checkbox@1.3.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -8626,16 +8775,16 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-collapsible@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-collapsible@1.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -8654,12 +8803,12 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-collection@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -8672,13 +8821,19 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-context-menu@2.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.17)(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context-menu@2.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -8691,7 +8846,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-context@1.2.0(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-context@1.2.2(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: @@ -8719,20 +8874,21 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8747,6 +8903,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-direction@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-dismissable-layer@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -8760,28 +8922,28 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-dismissable-layer@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-dropdown-menu@2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -8794,6 +8956,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-focus-scope@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) @@ -8805,42 +8973,42 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-focus-scope@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-form@0.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-form@0.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-label': 2.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-label': 2.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-hover-card@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-hover-card@1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -8854,33 +9022,40 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-label@2.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-id@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-label@2.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-menu@2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu@2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8889,18 +9064,18 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-menubar@1.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menubar@1.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -8929,58 +9104,58 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-navigation-menu@1.2.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-navigation-menu@1.2.22(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-one-time-password-field@0.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/number': 1.1.2 - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-one-time-password-field@0.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-password-toggle-field@0.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-password-toggle-field@0.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -9010,21 +9185,21 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-popover@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popover@1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -9051,18 +9226,18 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-popper@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/rect': 1.1.2 + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.3 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -9079,26 +9254,26 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-portal@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-presence@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 @@ -9107,16 +9282,16 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-primitive@2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-primitive@2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 @@ -9125,28 +9300,27 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-progress@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-progress@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-radio-group@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-radio-group@1.4.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -9170,19 +9344,19 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-roving-focus@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -9206,45 +9380,45 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-scroll-area@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/number': 1.1.2 - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-scroll-area@1.2.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-select@2.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/number': 1.1.2 - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-select@2.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -9253,28 +9427,28 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-separator@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-separator@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-slider@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/number': 1.1.2 - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-slider@1.4.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -9288,15 +9462,21 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-switch@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-slot@1.3.3(@types/react@19.2.17)(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-switch@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -9319,16 +9499,16 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-tabs@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-tabs@1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -9355,81 +9535,82 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-toast@1.2.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toast@1.2.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-toggle-group@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-toggle-group@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toggle': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-toggle@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-toggle@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-toolbar@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-toolbar@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-separator': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toggle-group': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-tooltip@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tooltip@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -9442,6 +9623,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) @@ -9450,6 +9637,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -9457,6 +9653,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-escape-keydown@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -9464,14 +9667,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-escape-keydown@1.1.3(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-use-escape-keydown@1.1.5(@types/react@19.2.17)(react@19.2.7)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-is-hydrated@0.1.1(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: @@ -9483,12 +9686,24 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-previous@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-previous@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/rect': 1.1.2 @@ -9496,6 +9711,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-rect@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -9503,18 +9725,25 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-visually-hidden@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-use-size@1.1.4(@types/react@19.2.17)(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-visually-hidden@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-visually-hidden@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -9523,6 +9752,8 @@ snapshots: '@radix-ui/rect@1.1.2': {} + '@radix-ui/rect@1.1.3': {} + '@react-email/render@2.0.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: html-to-text: 9.0.5 @@ -9875,9 +10106,10 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.0.2 - '@testing-library/jest-dom@6.9.1': + '@testing-library/jest-dom@6.10.0(@testing-library/dom@10.4.1)': dependencies: '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 aria-query: 5.3.2 css.escape: 1.5.1 dom-accessibility-api: 0.6.3 @@ -10567,6 +10799,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.1: {} + cobe@0.6.4: dependencies: phenomenon: 1.6.0 @@ -10796,6 +11030,8 @@ snapshots: defu@6.1.7: {} + denque@2.1.0: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -11757,6 +11993,18 @@ snapshots: internmap@2.0.3: {} + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -12923,63 +13171,63 @@ snapshots: queue-microtask@1.2.3: {} - radix-ui@1.6.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-accessible-icon': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-accordion': 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-alert-dialog': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-aspect-ratio': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-avatar': 1.2.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-checkbox': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-collapsible': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context-menu': 2.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-dialog': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-dropdown-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-form': 0.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-hover-card': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-label': 2.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-menubar': 1.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-navigation-menu': 1.2.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-one-time-password-field': 0.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-password-toggle-field': 0.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-popover': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-progress': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-radio-group': 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-scroll-area': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-select': 2.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-separator': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slider': 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-switch': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-tabs': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toast': 1.2.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toggle': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toggle-group': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toolbar': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-tooltip': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-escape-keydown': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + radix-ui@1.6.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-accessible-icon': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-accordion': 1.2.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-alert-dialog': 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-aspect-ratio': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-avatar': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-checkbox': 1.3.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context-menu': 2.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dropdown-menu': 2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-form': 0.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-hover-card': 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-label': 2.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menubar': 1.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-navigation-menu': 1.2.22(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-one-time-password-field': 0.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-password-toggle-field': 0.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popover': 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-radio-group': 1.4.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-scroll-area': 1.2.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-select': 2.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slider': 1.4.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-switch': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tabs': 1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toast': 1.2.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toolbar': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tooltip': 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -13153,6 +13401,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + redux-thunk@3.1.0(redux@5.0.1): dependencies: redux: 5.0.1 @@ -13703,6 +13957,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + standardwebhooks@1.0.0: dependencies: '@stablelib/base64': 1.0.1 From da4b145a27d163a9f17f59249c113e16750d3ca9 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Tue, 28 Jul 2026 18:01:25 +0800 Subject: [PATCH 06/36] =?UTF-8?q?feat:=20add=20Redis=20cache=20layer=20?= =?UTF-8?q?=E2=80=94=20client,=20keys,=20session,=20events=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/calendar/.env.example | 1 + apps/calendar/lib/cache/client.ts | 34 +++++++++ apps/calendar/lib/cache/events.ts | 107 +++++++++++++++++++++++++++++ apps/calendar/lib/cache/keys.ts | 31 +++++++++ apps/calendar/lib/cache/session.ts | 74 ++++++++++++++++++++ 5 files changed, 247 insertions(+) create mode 100644 apps/calendar/lib/cache/client.ts create mode 100644 apps/calendar/lib/cache/events.ts create mode 100644 apps/calendar/lib/cache/keys.ts create mode 100644 apps/calendar/lib/cache/session.ts diff --git a/apps/calendar/.env.example b/apps/calendar/.env.example index 478cf771..2b64100a 100644 --- a/apps/calendar/.env.example +++ b/apps/calendar/.env.example @@ -8,6 +8,7 @@ BETTER_AUTH_URL=http://localhost:3000 # Optional POSTGRES_URL=postgres://postgres:postgres@localhost:5432/onecalendar +REDIS_URL=rediss://default:password@your-region.upstash.io:6379 BETTER_AUTH_API_KEY=your-api-key NEXT_PUBLIC_TURNSTILE_SITE_KEY=site-key TURNSTILE_SECRET_KEY=secret-key \ No newline at end of file diff --git a/apps/calendar/lib/cache/client.ts b/apps/calendar/lib/cache/client.ts new file mode 100644 index 00000000..41eff906 --- /dev/null +++ b/apps/calendar/lib/cache/client.ts @@ -0,0 +1,34 @@ +import Redis from 'ioredis' + +let _redis: Redis | null = null + +export function getRedis(): Redis { + if (!_redis) { + _redis = new Redis(process.env.REDIS_URL!, { + lazyConnect: true, + enableOfflineQueue: false, + maxRetriesPerRequest: 2, + retryStrategy(times) { + if (times > 3) return null + return Math.min(times * 200, 2000) + }, + }) + + _redis.on('error', () => { + // fail open — don't crash the app, just fall back to PG + }) + } + return _redis +} + +export async function withRedis( + fn: (redis: Redis) => Promise, + fallback: () => Promise, +): Promise { + try { + const redis = getRedis() + return await fn(redis) + } catch { + return fallback() + } +} diff --git a/apps/calendar/lib/cache/events.ts b/apps/calendar/lib/cache/events.ts new file mode 100644 index 00000000..cdc66011 --- /dev/null +++ b/apps/calendar/lib/cache/events.ts @@ -0,0 +1,107 @@ +import { withRedis } from './client' +import { eventsMonthKey } from './keys' +import { calendarEvents } from '@/lib/drizzle/schema' + +const EVENT_CACHE_TTL = 600 + +function computeMonthKeys( + userId: string, + startDate: string, + endDate: string, +): string[] { + const start = new Date(startDate) + const end = new Date(endDate) + const months = new Set() + + const cursor = new Date(start) + while (cursor <= end) { + const ym = `${cursor.getUTCFullYear()}-${String(cursor.getUTCMonth() + 1).padStart(2, '0')}` + months.add(ym) + cursor.setUTCMonth(cursor.getUTCMonth() + 1) + } + + return Array.from(months).map((ym) => eventsMonthKey(userId, ym)) +} + +export type CachedEvent = typeof calendarEvents.$inferSelect + +export async function getCachedEvents( + userId: string, + startDate: string, + endDate: string, +): Promise { + return withRedis( + async (redis) => { + const keys = computeMonthKeys(userId, startDate, endDate) + const results = await redis.mget(...keys) + const misses: string[] = [] + const allEvents: CachedEvent[] = [] + + for (let i = 0; i < results.length; i++) { + if (results[i]) { + try { + const parsed: CachedEvent[] = JSON.parse( + results[i]!, + (_key, value) => { + if ( + typeof value === 'string' && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value) + ) { + return new Date(value) + } + return value + }, + ) + allEvents.push(...parsed) + } catch { + misses.push(keys[i]) + } + } else { + misses.push(keys[i]) + } + } + + if (misses.length > 0) return null + + return allEvents.filter((e) => { + const start = new Date(startDate) + const end = new Date(endDate) + return e.startDate >= start && e.endDate <= end + }) + }, + async () => null, + ) +} + +export async function setCachedEvents( + userId: string, + yearMonth: string, + events: CachedEvent[], +): Promise { + await withRedis( + async (redis) => { + await redis.setex( + eventsMonthKey(userId, yearMonth), + EVENT_CACHE_TTL, + JSON.stringify(events), + ) + }, + async () => {}, + ) +} + +export async function invalidateEventCache( + userId: string, + startDate: string, + endDate: string, +): Promise { + await withRedis( + async (redis) => { + const keys = computeMonthKeys(userId, startDate, endDate) + if (keys.length > 0) { + await redis.del(...keys) + } + }, + async () => {}, + ) +} diff --git a/apps/calendar/lib/cache/keys.ts b/apps/calendar/lib/cache/keys.ts new file mode 100644 index 00000000..05923765 --- /dev/null +++ b/apps/calendar/lib/cache/keys.ts @@ -0,0 +1,31 @@ +export const SESSION_PREFIX = 'session:token:' + +export function sessionKey(token: string): string { + return `${SESSION_PREFIX}${token}` +} + +export function eventsMonthPrefix(userId: string): string { + return `events:${userId}:` +} + +export function eventsMonthKey(userId: string, yearMonth: string): string { + return `events:${userId}:${yearMonth}` +} + +export function yearMonthFromDate(date: Date): string { + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}` +} + +export function affectedMonths(startDate: string, endDate: string): string[] { + const start = new Date(startDate) + const end = new Date(endDate) + const months = new Set() + + const cursor = new Date(start) + while (cursor <= end) { + months.add(yearMonthFromDate(cursor)) + cursor.setUTCMonth(cursor.getUTCMonth() + 1) + } + + return Array.from(months) +} diff --git a/apps/calendar/lib/cache/session.ts b/apps/calendar/lib/cache/session.ts new file mode 100644 index 00000000..104231f7 --- /dev/null +++ b/apps/calendar/lib/cache/session.ts @@ -0,0 +1,74 @@ +import { withRedis } from './client' +import { sessionKey } from './keys' + +type Session = { + user: { + id: string + name: string + email: string + emailVerified: boolean + image?: string | null + twoFactorEnabled?: boolean + createdAt: Date + updatedAt: Date + } + session: { + id: string + expiresAt: Date + token: string + createdAt: Date + updatedAt: Date + ipAddress?: string + userAgent?: string + userId: string + } +} | null + +function computeTtl(expiresAt: Date | string): number { + const expires = new Date(expiresAt).getTime() + const now = Date.now() + const remaining = Math.floor((expires - now) / 1000) + return Math.min(remaining, 600) +} + +export async function getCachedSession(token: string): Promise { + return withRedis( + async (redis) => { + const cached = await redis.get(sessionKey(token)) + if (!cached) return null + + try { + return JSON.parse(cached, (_key, value) => { + if ( + typeof value === 'string' && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value) + ) { + return new Date(value) + } + return value + }) as Session + } catch { + return null + } + }, + async () => null, + ) +} + +export async function setCachedSession( + session: NonNullable, +): Promise { + const ttl = computeTtl(session.session.expiresAt) + if (ttl <= 0) return + + await withRedis( + async (redis) => { + await redis.setex( + sessionKey(session.session.token), + ttl, + JSON.stringify(session), + ) + }, + async () => {}, + ) +} From 3338589433461c3c0eab9c535976ff598598ad2a Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Tue, 28 Jul 2026 18:05:12 +0800 Subject: [PATCH 07/36] feat: integrate Redis cache into auth session and calendar events API --- apps/calendar/app/api/events/route.ts | 84 ++++++++++++++++++++++++- apps/calendar/lib/auth/server.ts | 17 ++++- apps/calendar/lib/cache/events.ts | 90 ++++++++++++--------------- apps/calendar/lib/cache/keys.ts | 22 +++++-- 4 files changed, 155 insertions(+), 58 deletions(-) diff --git a/apps/calendar/app/api/events/route.ts b/apps/calendar/app/api/events/route.ts index 3f5a646a..23a516e5 100644 --- a/apps/calendar/app/api/events/route.ts +++ b/apps/calendar/app/api/events/route.ts @@ -5,6 +5,13 @@ import { eq, and, gte, lte, inArray } from 'drizzle-orm' import { encryptField, encryptJsonField } from '@/lib/field-crypto' import crypto from 'crypto' import { getAuthedUser, decryptEvent } from '@/lib/api-helpers' +import { + getCachedEvents, + setCachedEvents, + invalidateEventCache, + groupByMonth, +} from '@/lib/cache/events' +import { fullMonthRange } from '@/lib/cache/keys' export const runtime = 'nodejs' @@ -32,11 +39,26 @@ export const GET = async function GET(request: NextRequest) { const endDate = searchParams.get('endDate') const categoryIds = searchParams.get('categoryIds') + if (startDate && endDate) { + const cached = await getCachedEvents(user.id, startDate, endDate) + if (cached) { + let events = cached + if (categoryIds) { + const ids = categoryIds.split(',') + events = events.filter( + (e) => e.categoryId && ids.includes(e.categoryId), + ) + } + return NextResponse.json({ events }) + } + } + const filters = [eq(calendarEvents.userId, user.id)] if (startDate && endDate) { - filters.push(gte(calendarEvents.startDate, new Date(startDate))) - filters.push(lte(calendarEvents.endDate, new Date(endDate))) + const range = fullMonthRange(startDate, endDate) + filters.push(gte(calendarEvents.startDate, range.start)) + filters.push(lte(calendarEvents.endDate, range.end)) } if (categoryIds) { @@ -48,7 +70,26 @@ export const GET = async function GET(request: NextRequest) { .from(calendarEvents) .where(and(...filters)) - return NextResponse.json({ events: results.map(decryptEvent) }) + if (startDate && endDate) { + const grouped = groupByMonth(results) + for (const [ym, monthEvents] of grouped) { + await setCachedEvents(user.id, ym, monthEvents) + } + } + + const decrypted = results.map(decryptEvent) + + if (startDate && endDate) { + return NextResponse.json({ + events: decrypted.filter((e) => { + const start = new Date(startDate) + const end = new Date(endDate) + return e.startDate >= start && e.endDate <= end + }), + }) + } + + return NextResponse.json({ events: decrypted }) } export const POST = async function POST(request: NextRequest) { @@ -59,6 +100,24 @@ export const POST = async function POST(request: NextRequest) { const body: EventInput = await request.json() const id = body.id ?? crypto.randomUUID() + const isUpdate = !!body.id + if (isUpdate) { + const [old] = await getDb() + .select({ + startDate: calendarEvents.startDate, + endDate: calendarEvents.endDate, + }) + .from(calendarEvents) + .where(eq(calendarEvents.id, id)) + if (old) { + await invalidateEventCache( + user.id, + old.startDate.toISOString(), + old.endDate.toISOString(), + ) + } + } + const [event] = await getDb() .insert(calendarEvents) .values({ @@ -93,6 +152,8 @@ export const POST = async function POST(request: NextRequest) { }) .returning() + await invalidateEventCache(user.id, body.startDate, body.endDate) + return NextResponse.json({ event: decryptEvent(event) }) } @@ -106,9 +167,26 @@ export const DELETE = async function DELETE(request: NextRequest) { if (!id) return NextResponse.json({ error: 'Missing event id' }, { status: 400 }) + const [old] = await getDb() + .select({ + startDate: calendarEvents.startDate, + endDate: calendarEvents.endDate, + }) + .from(calendarEvents) + .where(and(eq(calendarEvents.id, id), eq(calendarEvents.userId, user.id))) + + if (!old) + return NextResponse.json({ error: 'Event not found' }, { status: 404 }) + await getDb() .delete(calendarEvents) .where(and(eq(calendarEvents.id, id), eq(calendarEvents.userId, user.id))) + await invalidateEventCache( + user.id, + old.startDate.toISOString(), + old.endDate.toISOString(), + ) + return NextResponse.json({ success: true }) } diff --git a/apps/calendar/lib/auth/server.ts b/apps/calendar/lib/auth/server.ts index e9459b82..a7993a5f 100644 --- a/apps/calendar/lib/auth/server.ts +++ b/apps/calendar/lib/auth/server.ts @@ -1,6 +1,21 @@ import { headers } from 'next/headers' +import { getSessionCookie } from 'better-auth/cookies' import { auth } from '@/lib/auth' +import { getCachedSession, setCachedSession } from '@/lib/cache/session' export async function getServerSession() { - return auth.api.getSession({ headers: await headers() }) + const hdrs = await headers() + + const sessionCookie = getSessionCookie(hdrs) + if (sessionCookie) { + const cached = await getCachedSession(sessionCookie) + if (cached) return cached + } + + const session = await auth.api.getSession({ headers: hdrs }) + if (session) { + await setCachedSession(session) + } + + return session } diff --git a/apps/calendar/lib/cache/events.ts b/apps/calendar/lib/cache/events.ts index cdc66011..40ef1c6e 100644 --- a/apps/calendar/lib/cache/events.ts +++ b/apps/calendar/lib/cache/events.ts @@ -1,30 +1,23 @@ import { withRedis } from './client' -import { eventsMonthKey } from './keys' +import { eventsMonthKey, affectedMonths, yearMonthFromDate } from './keys' import { calendarEvents } from '@/lib/drizzle/schema' const EVENT_CACHE_TTL = 600 -function computeMonthKeys( - userId: string, - startDate: string, - endDate: string, -): string[] { - const start = new Date(startDate) - const end = new Date(endDate) - const months = new Set() - - const cursor = new Date(start) - while (cursor <= end) { - const ym = `${cursor.getUTCFullYear()}-${String(cursor.getUTCMonth() + 1).padStart(2, '0')}` - months.add(ym) - cursor.setUTCMonth(cursor.getUTCMonth() + 1) - } +export type CachedEvent = typeof calendarEvents.$inferSelect - return Array.from(months).map((ym) => eventsMonthKey(userId, ym)) +function parseCachedEvents(json: string): CachedEvent[] { + return JSON.parse(json, (_key, value) => { + if ( + typeof value === 'string' && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value) + ) { + return new Date(value) + } + return value + }) as CachedEvent[] } -export type CachedEvent = typeof calendarEvents.$inferSelect - export async function getCachedEvents( userId: string, startDate: string, @@ -32,42 +25,26 @@ export async function getCachedEvents( ): Promise { return withRedis( async (redis) => { - const keys = computeMonthKeys(userId, startDate, endDate) + const months = affectedMonths(startDate, endDate) + const keys = months.map((m) => eventsMonthKey(userId, m)) const results = await redis.mget(...keys) - const misses: string[] = [] - const allEvents: CachedEvent[] = [] for (let i = 0; i < results.length; i++) { - if (results[i]) { - try { - const parsed: CachedEvent[] = JSON.parse( - results[i]!, - (_key, value) => { - if ( - typeof value === 'string' && - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value) - ) { - return new Date(value) - } - return value - }, - ) - allEvents.push(...parsed) - } catch { - misses.push(keys[i]) - } - } else { - misses.push(keys[i]) - } + if (!results[i]) return null } - if (misses.length > 0) return null + const allEvents: CachedEvent[] = [] + for (const result of results) { + try { + allEvents.push(...parseCachedEvents(result!)) + } catch { + return null + } + } - return allEvents.filter((e) => { - const start = new Date(startDate) - const end = new Date(endDate) - return e.startDate >= start && e.endDate <= end - }) + const start = new Date(startDate) + const end = new Date(endDate) + return allEvents.filter((e) => e.startDate >= start && e.endDate <= end) }, async () => null, ) @@ -97,7 +74,8 @@ export async function invalidateEventCache( ): Promise { await withRedis( async (redis) => { - const keys = computeMonthKeys(userId, startDate, endDate) + const months = affectedMonths(startDate, endDate) + const keys = months.map((m) => eventsMonthKey(userId, m)) if (keys.length > 0) { await redis.del(...keys) } @@ -105,3 +83,15 @@ export async function invalidateEventCache( async () => {}, ) } + +export function groupByMonth( + events: CachedEvent[], +): Map { + const grouped = new Map() + for (const event of events) { + const ym = yearMonthFromDate(event.startDate) + if (!grouped.has(ym)) grouped.set(ym, []) + grouped.get(ym)!.push(event) + } + return grouped +} diff --git a/apps/calendar/lib/cache/keys.ts b/apps/calendar/lib/cache/keys.ts index 05923765..398ec62b 100644 --- a/apps/calendar/lib/cache/keys.ts +++ b/apps/calendar/lib/cache/keys.ts @@ -4,10 +4,6 @@ export function sessionKey(token: string): string { return `${SESSION_PREFIX}${token}` } -export function eventsMonthPrefix(userId: string): string { - return `events:${userId}:` -} - export function eventsMonthKey(userId: string, yearMonth: string): string { return `events:${userId}:${yearMonth}` } @@ -29,3 +25,21 @@ export function affectedMonths(startDate: string, endDate: string): string[] { return Array.from(months) } + +export function monthBounds(yearMonth: string): { start: Date; end: Date } { + const [year, month] = yearMonth.split('-').map(Number) + return { + start: new Date(Date.UTC(year, month - 1, 1)), + end: new Date(Date.UTC(year, month, 0, 23, 59, 59, 999)), + } +} + +export function fullMonthRange( + startDate: string, + endDate: string, +): { start: Date; end: Date } { + const months = affectedMonths(startDate, endDate) + const first = monthBounds(months[0]!).start + const last = monthBounds(months[months.length - 1]!).end + return { start: first, end: last } +} From 6b806f12d6ab11c38ee63246a9ec831fc8460431 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Tue, 28 Jul 2026 18:08:02 +0800 Subject: [PATCH 08/36] =?UTF-8?q?feat:=20add=20Redis=20cache=20=E2=80=94?= =?UTF-8?q?=20session,=20events,=20invalidation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 88e66eb4..7f13ec07 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,4 @@ next-env.d.ts .nx/ # session status (not committed) -status.md \ No newline at end of file +STATUS.md \ No newline at end of file From 295d00fab7789dea69bb5ce0a62f57aad745447a Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Tue, 28 Jul 2026 18:10:55 +0800 Subject: [PATCH 09/36] fix: align Session type nullability with better-auth return types --- apps/calendar/lib/cache/session.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/calendar/lib/cache/session.ts b/apps/calendar/lib/cache/session.ts index 104231f7..886f78b8 100644 --- a/apps/calendar/lib/cache/session.ts +++ b/apps/calendar/lib/cache/session.ts @@ -8,7 +8,7 @@ type Session = { email: string emailVerified: boolean image?: string | null - twoFactorEnabled?: boolean + twoFactorEnabled?: boolean | null createdAt: Date updatedAt: Date } @@ -18,8 +18,8 @@ type Session = { token: string createdAt: Date updatedAt: Date - ipAddress?: string - userAgent?: string + ipAddress?: string | null + userAgent?: string | null userId: string } } | null From 4f5e4f3fee9d27cdcc372bece9064bbb6fb96ff9 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Tue, 28 Jul 2026 18:34:09 +0800 Subject: [PATCH 10/36] fix: cache all events by month regardless of date params + decrypt on cache hit --- apps/calendar/app/api/events/route.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/calendar/app/api/events/route.ts b/apps/calendar/app/api/events/route.ts index 23a516e5..2813c48a 100644 --- a/apps/calendar/app/api/events/route.ts +++ b/apps/calendar/app/api/events/route.ts @@ -39,10 +39,12 @@ export const GET = async function GET(request: NextRequest) { const endDate = searchParams.get('endDate') const categoryIds = searchParams.get('categoryIds') + const filters = [eq(calendarEvents.userId, user.id)] + if (startDate && endDate) { const cached = await getCachedEvents(user.id, startDate, endDate) if (cached) { - let events = cached + let events = cached.map(decryptEvent) if (categoryIds) { const ids = categoryIds.split(',') events = events.filter( @@ -51,11 +53,7 @@ export const GET = async function GET(request: NextRequest) { } return NextResponse.json({ events }) } - } - - const filters = [eq(calendarEvents.userId, user.id)] - if (startDate && endDate) { const range = fullMonthRange(startDate, endDate) filters.push(gte(calendarEvents.startDate, range.start)) filters.push(lte(calendarEvents.endDate, range.end)) @@ -70,11 +68,9 @@ export const GET = async function GET(request: NextRequest) { .from(calendarEvents) .where(and(...filters)) - if (startDate && endDate) { - const grouped = groupByMonth(results) - for (const [ym, monthEvents] of grouped) { - await setCachedEvents(user.id, ym, monthEvents) - } + const grouped = groupByMonth(results) + for (const [ym, monthEvents] of grouped) { + await setCachedEvents(user.id, ym, monthEvents) } const decrypted = results.map(decryptEvent) From b323483f70fa83e4ed0d01e5df252ad260032f65 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Tue, 28 Jul 2026 18:59:06 +0800 Subject: [PATCH 11/36] fix: replace ScrollArea with overflow-y-auto in search results dropdown --- apps/calendar/components/app/calendar.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/calendar/components/app/calendar.tsx b/apps/calendar/components/app/calendar.tsx index c10b035a..efec9e36 100644 --- a/apps/calendar/components/app/calendar.tsx +++ b/apps/calendar/components/app/calendar.tsx @@ -41,7 +41,6 @@ import RightSidebar from '@/components/app/sidebar/right-sidebar' import { addDays, addYears, subDays, subYears } from 'date-fns' import EventPreview from '@/components/app/event/event-preview' import EventDialog from '@/components/app/event/event-dialog' -import { ScrollArea } from '@zntr/ui/scroll-area' import Sidebar from '@/components/app/sidebar/sidebar' import { translations, useLanguage } from '@zntr/i18n/calendar' import { Button } from '@zntr/ui/button' @@ -907,7 +906,7 @@ export default function Calendar({ className, ..._props }: CalendarProps) { }} > {searchResultEvents.length > 0 ? ( - +
{searchResultEvents.map((event) => (
- +
) : (
{t.noMatchingEvents} From 9a7e890ed40bb926b4e9282dccf13ff48beaed8b Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Tue, 28 Jul 2026 20:44:21 +0800 Subject: [PATCH 12/36] feat: redirect unauthenticated /app users to /sign-up --- apps/calendar/proxy.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/calendar/proxy.ts b/apps/calendar/proxy.ts index a3f0af4c..caa9b63b 100644 --- a/apps/calendar/proxy.ts +++ b/apps/calendar/proxy.ts @@ -20,6 +20,10 @@ export default function proxy(request: NextRequest) { return NextResponse.redirect(new URL('/', request.url)) } + if (!isLoggedIn && pathname.startsWith('/app')) { + return NextResponse.redirect(new URL('/sign-up', request.url)) + } + return NextResponse.next() } From aad597d99fc3cdf43f616920bccfac7a3a6fa1bc Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Wed, 29 Jul 2026 13:55:07 +0800 Subject: [PATCH 13/36] feat: add MCP infrastructure with DB schema, auth module, and audit logging - Add @modelcontextprotocol/sdk dependency - Create MCP database tables (api_keys, tokens, device_codes, audit_logs, settings) - Implement API Key and OAuth token verification - Add scope-based authorization checks - Set up audit logging for MCP operations - Add MCP settings management --- .../drizzle/0001_create_mcp_tables.sql | 100 +++++++++ apps/calendar/drizzle/meta/_journal.json | 7 + apps/calendar/lib/drizzle/schema.ts | 146 +++++++++++++ apps/calendar/lib/mcp/audit.ts | 46 ++++ apps/calendar/lib/mcp/auth-helpers.ts | 35 +++ apps/calendar/lib/mcp/auth.ts | 201 ++++++++++++++++++ apps/calendar/lib/mcp/settings.ts | 46 ++++ apps/calendar/lib/mcp/types.ts | 64 ++++++ apps/calendar/package.json | 1 + pnpm-lock.yaml | 47 +++- 10 files changed, 684 insertions(+), 9 deletions(-) create mode 100644 apps/calendar/drizzle/0001_create_mcp_tables.sql create mode 100644 apps/calendar/lib/mcp/audit.ts create mode 100644 apps/calendar/lib/mcp/auth-helpers.ts create mode 100644 apps/calendar/lib/mcp/auth.ts create mode 100644 apps/calendar/lib/mcp/settings.ts create mode 100644 apps/calendar/lib/mcp/types.ts diff --git a/apps/calendar/drizzle/0001_create_mcp_tables.sql b/apps/calendar/drizzle/0001_create_mcp_tables.sql new file mode 100644 index 00000000..7ed4d31c --- /dev/null +++ b/apps/calendar/drizzle/0001_create_mcp_tables.sql @@ -0,0 +1,100 @@ +CREATE TABLE IF NOT EXISTS "mcp_api_keys" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "name" text NOT NULL, + "key_hash" text NOT NULL, + "key_prefix" text NOT NULL, + "scopes" jsonb DEFAULT '[]'::jsonb NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "last_used_at" timestamp (3) with time zone, + "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "mcp_tokens" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "token_hash" text NOT NULL, + "refresh_token_hash" text, + "token_type" text DEFAULT 'bearer' NOT NULL, + "scopes" jsonb DEFAULT '[]'::jsonb NOT NULL, + "client_id" text NOT NULL, + "client_name" text NOT NULL, + "expires_at" timestamp (3) with time zone NOT NULL, + "refresh_expires_at" timestamp (3) with time zone, + "is_revoked" boolean DEFAULT false NOT NULL, + "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "mcp_device_codes" ( + "id" text PRIMARY KEY NOT NULL, + "device_code" text NOT NULL, + "user_code" text NOT NULL, + "client_id" text NOT NULL, + "client_name" text NOT NULL, + "scopes" jsonb DEFAULT '[]'::jsonb NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "user_id" text, + "expires_at" timestamp (3) with time zone NOT NULL, + "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "mcp_audit_logs" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "auth_type" text NOT NULL, + "key_id" text, + "action" text NOT NULL, + "resource_type" text, + "resource_id" text, + "ip_address" text, + "user_agent" text, + "success" boolean DEFAULT true NOT NULL, + "error_message" text, + "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "mcp_settings" ( + "user_id" text PRIMARY KEY NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "rate_limit_rpm" integer DEFAULT 60 NOT NULL, + "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "mcp_api_keys" ADD CONSTRAINT "mcp_api_keys_user_id_User_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "mcp_tokens" ADD CONSTRAINT "mcp_tokens_user_id_User_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "mcp_device_codes" ADD CONSTRAINT "mcp_device_codes_user_id_User_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "mcp_audit_logs" ADD CONSTRAINT "mcp_audit_logs_user_id_User_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "mcp_settings" ADD CONSTRAINT "mcp_settings_user_id_User_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "mcp_tokens_token_hash_unique" ON "mcp_tokens" ("token_hash");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "mcp_device_codes_device_code_unique" ON "mcp_device_codes" ("device_code");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "mcp_device_codes_user_code_unique" ON "mcp_device_codes" ("user_code");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_mcp_api_keys_user_id" ON "mcp_api_keys" ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_mcp_audit_user_id" ON "mcp_audit_logs" ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_mcp_audit_created_at" ON "mcp_audit_logs" ("created_at"); diff --git a/apps/calendar/drizzle/meta/_journal.json b/apps/calendar/drizzle/meta/_journal.json index a40da993..c713be55 100644 --- a/apps/calendar/drizzle/meta/_journal.json +++ b/apps/calendar/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1784961892000, "tag": "0000_opposite_joystick", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1785000000000, + "tag": "0001_create_mcp_tables", + "breakpoints": true } ] } diff --git a/apps/calendar/lib/drizzle/schema.ts b/apps/calendar/lib/drizzle/schema.ts index 1b58e4a3..ab4925e6 100644 --- a/apps/calendar/lib/drizzle/schema.ts +++ b/apps/calendar/lib/drizzle/schema.ts @@ -290,6 +290,128 @@ export const shares = pgTable( }), ) +// ============================================================ +// MCP TABLES +// ============================================================ + +// --- MCP API Keys --- +export const mcpApiKeys = pgTable( + 'mcp_api_keys', + { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + keyHash: text('key_hash').notNull(), + keyPrefix: text('key_prefix').notNull(), + scopes: jsonb('scopes').notNull().default([]), + isActive: boolean('is_active').notNull().default(true), + lastUsedAt: timestamp('last_used_at', { + precision: 3, + withTimezone: true, + }), + createdAt: timestamp('created_at', { precision: 3, withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { precision: 3, withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => ({ + userIdIdx: index('idx_mcp_api_keys_user_id').on(table.userId), + }), +) + +// --- MCP OAuth Tokens --- +export const mcpTokens = pgTable('mcp_tokens', { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + tokenHash: text('token_hash').notNull().unique(), + refreshTokenHash: text('refresh_token_hash'), + tokenType: text('token_type').notNull().default('bearer'), + scopes: jsonb('scopes').notNull().default([]), + clientId: text('client_id').notNull(), + clientName: text('client_name').notNull(), + expiresAt: timestamp('expires_at', { + precision: 3, + withTimezone: true, + }).notNull(), + refreshExpiresAt: timestamp('refresh_expires_at', { + precision: 3, + withTimezone: true, + }), + isRevoked: boolean('is_revoked').notNull().default(false), + createdAt: timestamp('created_at', { precision: 3, withTimezone: true }) + .defaultNow() + .notNull(), +}) + +// --- MCP Device Codes (for OAuth Device Code Grant) --- +export const mcpDeviceCodes = pgTable('mcp_device_codes', { + id: text('id').primaryKey(), + deviceCode: text('device_code').notNull().unique(), + userCode: text('user_code').notNull().unique(), + clientId: text('client_id').notNull(), + clientName: text('client_name').notNull(), + scopes: jsonb('scopes').notNull().default([]), + status: text('status').notNull().default('pending'), + userId: text('user_id').references(() => user.id, { + onDelete: 'cascade', + }), + expiresAt: timestamp('expires_at', { + precision: 3, + withTimezone: true, + }).notNull(), + createdAt: timestamp('created_at', { precision: 3, withTimezone: true }) + .defaultNow() + .notNull(), +}) + +// --- MCP Audit Logs --- +export const mcpAuditLogs = pgTable( + 'mcp_audit_logs', + { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + authType: text('auth_type').notNull(), + keyId: text('key_id'), + action: text('action').notNull(), + resourceType: text('resource_type'), + resourceId: text('resource_id'), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + success: boolean('success').notNull().default(true), + errorMessage: text('error_message'), + createdAt: timestamp('created_at', { precision: 3, withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => ({ + userIdIdx: index('idx_mcp_audit_user_id').on(table.userId), + createdAtIdx: index('idx_mcp_audit_created_at').on(table.createdAt), + }), +) + +// --- MCP User Settings --- +export const mcpSettings = pgTable('mcp_settings', { + userId: text('user_id') + .primaryKey() + .references(() => user.id, { onDelete: 'cascade' }), + enabled: boolean('enabled').notNull().default(true), + rateLimitRpm: integer('rate_limit_rpm').notNull().default(60), + createdAt: timestamp('created_at', { precision: 3, withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { precision: 3, withTimezone: true }) + .defaultNow() + .notNull(), +}) + // ============================================================ // Relations // ============================================================ @@ -300,10 +422,14 @@ export const userRelations = relations(user, ({ many, one }) => ({ twoFactor: one(twoFactor), calendarEvents: many(calendarEvents), settings: one(settings), + mcpSettings: one(mcpSettings), calendarCategories: many(calendarCategories), countdowns: many(countdowns), bookmarkedEvents: many(bookmarkedEvents), shares: many(shares), + mcpApiKeys: many(mcpApiKeys), + mcpTokens: many(mcpTokens), + mcpAuditLogs: many(mcpAuditLogs), })) export const sessionRelations = relations(session, ({ one }) => ({ @@ -374,3 +500,23 @@ export const sharesRelations = relations(shares, ({ one }) => ({ references: [calendarEvents.id], }), })) + +export const mcpApiKeysRelations = relations(mcpApiKeys, ({ one }) => ({ + user: one(user, { fields: [mcpApiKeys.userId], references: [user.id] }), +})) + +export const mcpTokensRelations = relations(mcpTokens, ({ one }) => ({ + user: one(user, { fields: [mcpTokens.userId], references: [user.id] }), +})) + +export const mcpDeviceCodesRelations = relations(mcpDeviceCodes, ({ one }) => ({ + user: one(user, { fields: [mcpDeviceCodes.userId], references: [user.id] }), +})) + +export const mcpAuditLogsRelations = relations(mcpAuditLogs, ({ one }) => ({ + user: one(user, { fields: [mcpAuditLogs.userId], references: [user.id] }), +})) + +export const mcpSettingsRelations = relations(mcpSettings, ({ one }) => ({ + user: one(user, { fields: [mcpSettings.userId], references: [user.id] }), +})) diff --git a/apps/calendar/lib/mcp/audit.ts b/apps/calendar/lib/mcp/audit.ts new file mode 100644 index 00000000..a77ffe6f --- /dev/null +++ b/apps/calendar/lib/mcp/audit.ts @@ -0,0 +1,46 @@ +import { getDb } from '@/lib/drizzle/client' +import { mcpAuditLogs } from '@/lib/drizzle/schema' +import { eq, desc, sql } from 'drizzle-orm' +import crypto from 'crypto' +import type { AuditEntry } from './types' + +export async function logAudit(entry: AuditEntry): Promise { + const db = await getDb() + await db.insert(mcpAuditLogs).values({ + id: crypto.randomUUID(), + userId: entry.userId, + authType: entry.authType, + keyId: entry.keyId ?? null, + action: entry.action, + resourceType: entry.resourceType ?? null, + resourceId: entry.resourceId ?? null, + ipAddress: entry.ipAddress ?? null, + userAgent: entry.userAgent ?? null, + success: entry.success, + errorMessage: entry.errorMessage ?? null, + }) +} + +export async function getAuditLogs( + userId: string, + limit: number = 50, + offset: number = 0, +) { + const db = await getDb() + return db + .select() + .from(mcpAuditLogs) + .where(eq(mcpAuditLogs.userId, userId)) + .orderBy(desc(mcpAuditLogs.createdAt)) + .limit(limit) + .offset(offset) +} + +export async function getAuditLogsCount(userId: string): Promise { + const db = await getDb() + const [row] = await db + .select({ count: sql`count(*)` }) + .from(mcpAuditLogs) + .where(eq(mcpAuditLogs.userId, userId)) + return row?.count ?? 0 +} diff --git a/apps/calendar/lib/mcp/auth-helpers.ts b/apps/calendar/lib/mcp/auth-helpers.ts new file mode 100644 index 00000000..c75a735c --- /dev/null +++ b/apps/calendar/lib/mcp/auth-helpers.ts @@ -0,0 +1,35 @@ +import { type McpAuthUser, McpAuthError } from './types' +import { verifyApiKey, verifyOAuthToken } from './auth' + +export async function getMcpAuth( + request: Request, +): Promise<{ user: McpAuthUser; token: string } | null> { + const authHeader = request.headers.get('authorization') + if (!authHeader) return null + + const parts = authHeader.split(' ') + const scheme = parts[0]?.toLowerCase() + const token = parts.slice(1).join(' ') + + if (!token) return null + + if (scheme !== 'bearer') return null + + const user = token.startsWith('zc_') + ? await verifyApiKey(token) + : await verifyOAuthToken(token) + + if (!user) return null + + return { user, token } +} + +export async function requireMcpAuth( + request: Request, +): Promise<{ user: McpAuthUser; token: string }> { + const result = await getMcpAuth(request) + if (!result) { + throw new McpAuthError('Unauthorized', 401) + } + return result +} diff --git a/apps/calendar/lib/mcp/auth.ts b/apps/calendar/lib/mcp/auth.ts new file mode 100644 index 00000000..9690cf23 --- /dev/null +++ b/apps/calendar/lib/mcp/auth.ts @@ -0,0 +1,201 @@ +import { getDb } from '@/lib/drizzle/client' +import { mcpApiKeys, mcpTokens, mcpSettings } from '@/lib/drizzle/schema' +import { eq, and, gte } from 'drizzle-orm' +import crypto from 'crypto' +import bcrypt from 'bcryptjs' +import { type McpAuthUser, ALL_SCOPES } from './types' + +const KEY_PREFIX = 'zc_' +const KEY_PREFIX_LENGTH = 12 + +export async function verifyApiKey(key: string): Promise { + if (!key.startsWith(KEY_PREFIX)) return null + + const db = await getDb() + const keys = await db + .select() + .from(mcpApiKeys) + .where( + and( + eq(mcpApiKeys.isActive, true), + eq(mcpApiKeys.keyPrefix, key.slice(0, KEY_PREFIX_LENGTH)), + ), + ) + + for (const row of keys) { + const match = await bcrypt.compare(key, row.keyHash) + if (!match) continue + + await db + .update(mcpApiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(mcpApiKeys.id, row.id)) + + const enabled = await isMcpEnabled(row.userId) + if (!enabled) return null + + return { + userId: row.userId, + email: '', + name: '', + scopes: row.scopes as string[], + authType: 'api_key', + keyId: row.id, + } + } + + return null +} + +export async function verifyOAuthToken( + token: string, +): Promise { + const hash = hashToken(token) + const db = await getDb() + + const [row] = await db + .select() + .from(mcpTokens) + .where( + and( + eq(mcpTokens.tokenHash, hash), + eq(mcpTokens.isRevoked, false), + gte(mcpTokens.expiresAt, new Date()), + ), + ) + + if (!row) return null + + const enabled = await isMcpEnabled(row.userId) + if (!enabled) return null + + return { + userId: row.userId, + email: '', + name: row.clientName, + scopes: row.scopes as string[], + authType: 'oauth', + keyId: row.id, + } +} + +export async function getFullUserInfo( + userId: string, +): Promise<{ email: string; name: string }> { + const { user } = await import('@/lib/drizzle/schema') + const db = await getDb() + const [row] = await db + .select({ email: user.email, name: user.name }) + .from(user) + .where(eq(user.id, userId)) + + return row ?? { email: '', name: '' } +} + +export async function isMcpEnabled(userId: string): Promise { + const db = await getDb() + const [row] = await db + .select({ enabled: mcpSettings.enabled }) + .from(mcpSettings) + .where(eq(mcpSettings.userId, userId)) + + if (!row) return true + return row.enabled +} + +export async function generateApiKey( + name: string, + userId: string, + scopes: string[], +): Promise { + const raw = crypto.randomBytes(32).toString('hex') + const key = `${KEY_PREFIX}${raw}` + const prefix = key.slice(0, KEY_PREFIX_LENGTH) + const hash = await bcrypt.hash(key, 10) + + const { mcpApiKeys: keysTable } = await import('@/lib/drizzle/schema') + const db = await getDb() + await db.insert(keysTable).values({ + id: crypto.randomUUID(), + userId, + name, + keyHash: hash, + keyPrefix: prefix, + scopes: scopes.length > 0 ? scopes : ALL_SCOPES, + isActive: true, + }) + + return key +} + +export async function revokeApiKey( + keyId: string, + userId: string, +): Promise { + const db = await getDb() + const [row] = await db + .update(mcpApiKeys) + .set({ isActive: false, updatedAt: new Date() }) + .where(and(eq(mcpApiKeys.id, keyId), eq(mcpApiKeys.userId, userId))) + .returning() + + return !!row +} + +export async function updateApiKeyScopes( + keyId: string, + userId: string, + scopes: string[], +): Promise { + const db = await getDb() + const [row] = await db + .update(mcpApiKeys) + .set({ scopes, updatedAt: new Date() }) + .where(and(eq(mcpApiKeys.id, keyId), eq(mcpApiKeys.userId, userId))) + .returning() + + return !!row +} + +export async function listApiKeys(userId: string) { + const db = await getDb() + return db + .select({ + id: mcpApiKeys.id, + name: mcpApiKeys.name, + keyPrefix: mcpApiKeys.keyPrefix, + scopes: mcpApiKeys.scopes, + isActive: mcpApiKeys.isActive, + lastUsedAt: mcpApiKeys.lastUsedAt, + createdAt: mcpApiKeys.createdAt, + }) + .from(mcpApiKeys) + .where(eq(mcpApiKeys.userId, userId)) + .orderBy(mcpApiKeys.createdAt) +} + +export function generateAccessToken(): string { + return crypto.randomBytes(32).toString('hex') +} + +export function generateRefreshToken(): string { + return crypto.randomBytes(48).toString('hex') +} + +export function generateDeviceCode(): string { + return crypto.randomBytes(24).toString('hex') +} + +export function generateUserCode(): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + let code = '' + for (let i = 0; i < 8; i++) { + if (i === 4) code += '-' + code += chars[Math.floor(Math.random() * chars.length)] + } + return code +} + +export function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex') +} diff --git a/apps/calendar/lib/mcp/settings.ts b/apps/calendar/lib/mcp/settings.ts new file mode 100644 index 00000000..afc1cf2e --- /dev/null +++ b/apps/calendar/lib/mcp/settings.ts @@ -0,0 +1,46 @@ +import { getDb } from '@/lib/drizzle/client' +import { mcpSettings } from '@/lib/drizzle/schema' +import { eq } from 'drizzle-orm' + +export async function getMcpSettings(userId: string) { + const db = await getDb() + const [row] = await db + .select() + .from(mcpSettings) + .where(eq(mcpSettings.userId, userId)) + + return ( + row ?? { + userId, + enabled: true, + rateLimitRpm: 60, + createdAt: new Date(), + updatedAt: new Date(), + } + ) +} + +export async function updateMcpSettings( + userId: string, + data: { enabled?: boolean; rateLimitRpm?: number }, +) { + const db = await getDb() + const [row] = await db + .insert(mcpSettings) + .values({ + userId, + enabled: data.enabled ?? true, + rateLimitRpm: data.rateLimitRpm ?? 60, + }) + .onConflictDoUpdate({ + target: mcpSettings.userId, + set: { + enabled: data.enabled ?? undefined, + rateLimitRpm: data.rateLimitRpm ?? undefined, + updatedAt: new Date(), + }, + }) + .returning() + + return row +} diff --git a/apps/calendar/lib/mcp/types.ts b/apps/calendar/lib/mcp/types.ts new file mode 100644 index 00000000..90e1ded9 --- /dev/null +++ b/apps/calendar/lib/mcp/types.ts @@ -0,0 +1,64 @@ +export interface McpAuthUser { + userId: string + email: string + name: string + scopes: string[] + authType: 'api_key' | 'oauth' + keyId?: string +} + +export type McpScope = + | 'events:read' + | 'events:write' + | 'categories:read' + | 'categories:write' + | 'countdowns:read' + | 'countdowns:write' + | 'settings:read' + | 'settings:write' + | 'profile:read' + +export const ALL_SCOPES: McpScope[] = [ + 'events:read', + 'events:write', + 'categories:read', + 'categories:write', + 'countdowns:read', + 'countdowns:write', + 'settings:read', + 'settings:write', + 'profile:read', +] + +export function hasScope(user: McpAuthUser, requiredScope: McpScope): boolean { + return user.scopes.includes(requiredScope) +} + +export function requireScope(user: McpAuthUser, requiredScope: McpScope): void { + if (!hasScope(user, requiredScope)) { + throw new McpAuthError(`Missing required scope: ${requiredScope}`, 403) + } +} + +export class McpAuthError extends Error { + constructor( + message: string, + public statusCode: number = 401, + ) { + super(message) + this.name = 'McpAuthError' + } +} + +export interface AuditEntry { + userId: string + authType: 'api_key' | 'oauth' + keyId?: string + action: string + resourceType?: string + resourceId?: string + ipAddress?: string + userAgent?: string + success: boolean + errorMessage?: string +} diff --git a/apps/calendar/package.json b/apps/calendar/package.json index e0c74862..c69a3d9c 100644 --- a/apps/calendar/package.json +++ b/apps/calendar/package.json @@ -30,6 +30,7 @@ "@zntr/ui": "workspace:*", "@zntr/utils": "workspace:*", "bcryptjs": "latest", + "@modelcontextprotocol/sdk": "^1.29.0", "better-auth": "1.6.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a184bf0..cf090be0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: '@mdx-js/react': specifier: ^3.1.0 version: 3.1.1(@types/react@19.2.17)(react@19.2.7) + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) '@radix-ui/react-toast': specifier: 1.2.17 version: 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -4538,8 +4541,8 @@ packages: es-module-lexer@2.3.0: resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} es-toolkit@1.49.0: @@ -5665,8 +5668,8 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} meow@13.2.0: @@ -8462,6 +8465,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.27) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.27 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -11166,7 +11191,7 @@ snapshots: es-module-lexer@2.3.0: {} - es-object-atoms@1.1.1: + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -11724,7 +11749,7 @@ snapshots: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 @@ -11739,7 +11764,7 @@ snapshots: get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-stream@6.0.1: {} @@ -12490,7 +12515,7 @@ snapshots: mdn-data@2.27.1: {} - media-typer@1.1.0: {} + media-typer@1.1.1: {} meow@13.2.0: {} @@ -14165,7 +14190,7 @@ snapshots: type-is@2.1.0: dependencies: content-type: 2.0.0 - media-typer: 1.1.0 + media-typer: 1.1.1 mime-types: 3.0.2 typescript-eslint@8.62.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3): @@ -14526,6 +14551,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod@3.25.76: {} zod@4.4.3: {} From 03d664d827532bdbfdea2825f4847efa19673316 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Wed, 29 Jul 2026 13:57:08 +0800 Subject: [PATCH 14/36] feat: implement OAuth Device Code Grant flow for MCP - Add .well-known/oauth-authorization-server metadata endpoint - Implement device code request endpoint (/api/oauth/device) - Implement token exchange/polling endpoint (/api/oauth/token) - Create OAuth authorize UI page with user consent flow - Add authorization confirmation API endpoint --- .../oauth-authorization-server/route.ts | 27 +++ .../calendar/app/api/oauth/authorize/route.ts | 59 +++++++ apps/calendar/app/api/oauth/device/route.ts | 48 ++++++ apps/calendar/app/api/oauth/token/route.ts | 108 ++++++++++++ apps/calendar/app/oauth/authorize/page.tsx | 154 ++++++++++++++++++ 5 files changed, 396 insertions(+) create mode 100644 apps/calendar/app/.well-known/oauth-authorization-server/route.ts create mode 100644 apps/calendar/app/api/oauth/authorize/route.ts create mode 100644 apps/calendar/app/api/oauth/device/route.ts create mode 100644 apps/calendar/app/api/oauth/token/route.ts create mode 100644 apps/calendar/app/oauth/authorize/page.tsx diff --git a/apps/calendar/app/.well-known/oauth-authorization-server/route.ts b/apps/calendar/app/.well-known/oauth-authorization-server/route.ts new file mode 100644 index 00000000..c3b77edf --- /dev/null +++ b/apps/calendar/app/.well-known/oauth-authorization-server/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server' + +const MCP_BASE_URL = process.env.BETTER_AUTH_URL || 'http://localhost:3000' + +export async function GET() { + return NextResponse.json({ + issuer: MCP_BASE_URL, + authorization_endpoint: `${MCP_BASE_URL}/oauth/authorize`, + token_endpoint: `${MCP_BASE_URL}/api/oauth/token`, + device_authorization_endpoint: `${MCP_BASE_URL}/api/oauth/device`, + response_types_supported: ['code'], + grant_types_supported: ['urn:ietf:params:oauth:grant-type:device_code'], + token_endpoint_auth_methods_supported: ['none'], + scopes_supported: [ + 'events:read', + 'events:write', + 'categories:read', + 'categories:write', + 'countdowns:read', + 'countdowns:write', + 'settings:read', + 'settings:write', + 'profile:read', + ], + code_challenge_methods_supported: ['S256'], + }) +} diff --git a/apps/calendar/app/api/oauth/authorize/route.ts b/apps/calendar/app/api/oauth/authorize/route.ts new file mode 100644 index 00000000..2725d639 --- /dev/null +++ b/apps/calendar/app/api/oauth/authorize/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDb } from '@/lib/drizzle/client' +import { mcpDeviceCodes } from '@/lib/drizzle/schema' +import { eq, and, gte } from 'drizzle-orm' + +export const runtime = 'nodejs' + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})) + const userCode = body.user_code || body.code + const userId = body.user_id + + if (!userCode || !userId) { + return NextResponse.json( + { + error: 'invalid_request', + error_description: 'Missing user_code or user_id', + }, + { status: 400 }, + ) + } + + const db = await getDb() + + const [record] = await db + .select() + .from(mcpDeviceCodes) + .where( + and( + eq(mcpDeviceCodes.userCode, userCode), + eq(mcpDeviceCodes.status, 'pending'), + gte(mcpDeviceCodes.expiresAt, new Date()), + ), + ) + + if (!record) { + return NextResponse.json( + { + error: 'invalid_grant', + error_description: 'Invalid or expired code', + }, + { status: 400 }, + ) + } + + await db + .update(mcpDeviceCodes) + .set({ status: 'approved', userId }) + .where(eq(mcpDeviceCodes.id, record.id)) + + return NextResponse.json({ success: true }) + } catch (error) { + return NextResponse.json( + { error: 'server_error', error_description: 'Internal server error' }, + { status: 500 }, + ) + } +} diff --git a/apps/calendar/app/api/oauth/device/route.ts b/apps/calendar/app/api/oauth/device/route.ts new file mode 100644 index 00000000..bb3d7ba2 --- /dev/null +++ b/apps/calendar/app/api/oauth/device/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDb } from '@/lib/drizzle/client' +import { mcpDeviceCodes } from '@/lib/drizzle/schema' +import { generateDeviceCode, generateUserCode, hashToken } from '@/lib/mcp/auth' +import crypto from 'crypto' + +export const runtime = 'nodejs' + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})) + const clientId = body.client_id || 'unknown' + const clientName = body.client_name || body.client_id || 'Unknown Client' + const scopes = body.scope ? body.scope.split(' ') : ['events:read'] + + const deviceCode = generateDeviceCode() + const userCode = generateUserCode() + + const db = await getDb() + await db.insert(mcpDeviceCodes).values({ + id: crypto.randomUUID(), + deviceCode: hashToken(deviceCode), + userCode, + clientId, + clientName, + scopes, + status: 'pending', + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }) + + return NextResponse.json({ + device_code: deviceCode, + user_code: userCode, + verification_uri: `${process.env.BETTER_AUTH_URL || 'http://localhost:3000'}/oauth/authorize`, + verification_uri_complete: `${process.env.BETTER_AUTH_URL || 'http://localhost:3000'}/oauth/authorize?code=${userCode}`, + expires_in: 300, + interval: 5, + }) + } catch (error) { + return NextResponse.json( + { + error: 'invalid_request', + error_description: 'Failed to create device code', + }, + { status: 400 }, + ) + } +} diff --git a/apps/calendar/app/api/oauth/token/route.ts b/apps/calendar/app/api/oauth/token/route.ts new file mode 100644 index 00000000..26c89852 --- /dev/null +++ b/apps/calendar/app/api/oauth/token/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDb } from '@/lib/drizzle/client' +import { mcpDeviceCodes, mcpTokens } from '@/lib/drizzle/schema' +import { eq } from 'drizzle-orm' +import { + generateAccessToken, + generateRefreshToken, + hashToken, + getFullUserInfo, +} from '@/lib/mcp/auth' +import crypto from 'crypto' + +export const runtime = 'nodejs' + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})) + const grantType = body.grant_type + + if (grantType !== 'urn:ietf:params:oauth:grant-type:device_code') { + return NextResponse.json( + { error: 'unsupported_grant_type' }, + { status: 400 }, + ) + } + + const deviceCode = body.device_code + if (!deviceCode) { + return NextResponse.json( + { error: 'invalid_request', error_description: 'Missing device_code' }, + { status: 400 }, + ) + } + + const hashedDeviceCode = hashToken(deviceCode) + const db = await getDb() + + const [record] = await db + .select() + .from(mcpDeviceCodes) + .where(eq(mcpDeviceCodes.deviceCode, hashedDeviceCode)) + + if (!record) { + return NextResponse.json( + { error: 'invalid_grant', error_description: 'Invalid device code' }, + { status: 400 }, + ) + } + + if (record.expiresAt < new Date()) { + return NextResponse.json( + { error: 'expired_token', error_description: 'Device code expired' }, + { status: 400 }, + ) + } + + if (record.status === 'pending') { + return NextResponse.json( + { error: 'authorization_pending' }, + { status: 400 }, + ) + } + + if (record.status !== 'approved' || !record.userId) { + return NextResponse.json({ error: 'invalid_grant' }, { status: 400 }) + } + + const accessToken = generateAccessToken() + const refreshToken = generateRefreshToken() + const userInfo = await getFullUserInfo(record.userId) + + await db.insert(mcpTokens).values({ + id: crypto.randomUUID(), + userId: record.userId, + tokenHash: hashToken(accessToken), + refreshTokenHash: hashToken(refreshToken), + tokenType: 'bearer', + scopes: record.scopes, + clientId: record.clientId, + clientName: record.clientName, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + refreshExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + }) + + await db + .update(mcpDeviceCodes) + .set({ status: 'used' }) + .where(eq(mcpDeviceCodes.id, record.id)) + + return NextResponse.json({ + access_token: accessToken, + token_type: 'bearer', + expires_in: 86400, + refresh_token: refreshToken, + scope: record.scopes.join(' '), + user: { + id: record.userId, + name: userInfo.name, + email: userInfo.email, + }, + }) + } catch (error) { + return NextResponse.json( + { error: 'server_error', error_description: 'Internal server error' }, + { status: 500 }, + ) + } +} diff --git a/apps/calendar/app/oauth/authorize/page.tsx b/apps/calendar/app/oauth/authorize/page.tsx new file mode 100644 index 00000000..403ab2ea --- /dev/null +++ b/apps/calendar/app/oauth/authorize/page.tsx @@ -0,0 +1,154 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { Button } from '@zntr/ui/button' +import { Loader2, CheckCircle, XCircle } from 'lucide-react' + +export default function OAuthAuthorizePage() { + const router = useRouter() + const searchParams = useSearchParams() + const code = searchParams.get('code') + + const [status, setStatus] = useState< + 'checking' | 'ready' | 'authorizing' | 'success' | 'error' + >('checking') + const [user, setUser] = useState<{ + id: string + name: string + email: string + image?: string + } | null>(null) + const [errorMsg, setErrorMsg] = useState('') + + useEffect(() => { + if (!code) { + setStatus('error') + setErrorMsg('No authorization code provided.') + return + } + setStatus('ready') + }, [code]) + + const handleAuthorize = async () => { + if (!code) return + setStatus('authorizing') + + try { + const res = await fetch('/api/oauth/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_code: code, + user_id: user?.id || '', + }), + }) + + if (!res.ok) { + const err = await res.json() + throw new Error(err.error_description || 'Authorization failed') + } + + setStatus('success') + setTimeout(() => { + window.close() + }, 2000) + } catch (err) { + setStatus('error') + setErrorMsg(err instanceof Error ? err.message : 'Something went wrong') + } + } + + if (status === 'checking') { + return ( +
+ +
+ ) + } + + if (status === 'success') { + return ( +
+
+ +

Authorization Granted

+

+ You have successfully authorized this application. You can close + this window. +

+
+
+ ) + } + + if (status === 'error') { + return ( +
+
+ +

Authorization Failed

+

{errorMsg}

+ +
+
+ ) + } + + return ( +
+
+
+
+ Calendar { + const target = e.currentTarget + target.style.display = 'none' + }} + /> +
+ +
+

Authorize Application

+

+ An AI agent is requesting access to your calendar data. +

+
+ +
+
+ Code + {code} +
+
+ +
+ This will allow the application to read and manage your calendar + data based on the permissions configured in your MCP settings. +
+ +
+ + +
+
+
+
+ ) +} From 832ff16ee572db58e216dd17f9abd177d966ae9d Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Wed, 29 Jul 2026 14:04:34 +0800 Subject: [PATCH 15/36] feat: implement MCP server with all calendar tools - Create McpServer with 16 tools across 5 categories - Implement event CRUD tools with search and pagination - Add category, countdown, settings, and profile tools - Create auth-aware MCP handler with rate limit check - Wire /api/mcp endpoint to handle iJSON-RPC requests - Integrate scope-based authorization in every tool --- apps/calendar/app/api/mcp/route.ts | 11 + apps/calendar/lib/mcp/category-tools.ts | 81 ++++ apps/calendar/lib/mcp/countdown-tools.ts | 89 ++++ apps/calendar/lib/mcp/event-tools.ts | 172 ++++++++ apps/calendar/lib/mcp/handler.ts | 83 ++++ apps/calendar/lib/mcp/profile-tools.ts | 19 + apps/calendar/lib/mcp/server.ts | 499 +++++++++++++++++++++++ apps/calendar/lib/mcp/settings-tools.ts | 43 ++ 8 files changed, 997 insertions(+) create mode 100644 apps/calendar/app/api/mcp/route.ts create mode 100644 apps/calendar/lib/mcp/category-tools.ts create mode 100644 apps/calendar/lib/mcp/countdown-tools.ts create mode 100644 apps/calendar/lib/mcp/event-tools.ts create mode 100644 apps/calendar/lib/mcp/handler.ts create mode 100644 apps/calendar/lib/mcp/profile-tools.ts create mode 100644 apps/calendar/lib/mcp/server.ts create mode 100644 apps/calendar/lib/mcp/settings-tools.ts diff --git a/apps/calendar/app/api/mcp/route.ts b/apps/calendar/app/api/mcp/route.ts new file mode 100644 index 00000000..67e84097 --- /dev/null +++ b/apps/calendar/app/api/mcp/route.ts @@ -0,0 +1,11 @@ +import { handleMcpRequest } from '@/lib/mcp/handler' + +export const runtime = 'nodejs' + +export async function POST(request: Request) { + return handleMcpRequest(request) +} + +export async function GET(request: Request) { + return handleMcpRequest(request) +} diff --git a/apps/calendar/lib/mcp/category-tools.ts b/apps/calendar/lib/mcp/category-tools.ts new file mode 100644 index 00000000..d2415941 --- /dev/null +++ b/apps/calendar/lib/mcp/category-tools.ts @@ -0,0 +1,81 @@ +import { getDb } from '@/lib/drizzle/client' +import { calendarCategories } from '@/lib/drizzle/schema' +import { eq, and } from 'drizzle-orm' +import { encryptField } from '@/lib/field-crypto' +import crypto from 'crypto' + +export async function listCategories(userId: string) { + const db = await getDb() + const rows = await db + .select() + .from(calendarCategories) + .where(eq(calendarCategories.userId, userId)) + + return rows.map((cat) => ({ + ...cat, + name: cat.name, + })) +} + +export async function createCategory( + userId: string, + data: { name: string; color: string; sort_order?: number }, +) { + const id = crypto.randomUUID() + const db = await getDb() + + const [row] = await db + .insert(calendarCategories) + .values({ + id, + userId, + name: encryptField(id, data.name) ?? data.name, + color: data.color, + sortOrder: data.sort_order ?? 0, + }) + .returning() + + return row +} + +export async function updateCategory( + userId: string, + categoryId: string, + data: { name?: string; color?: string; sort_order?: number }, +) { + const db = await getDb() + + const values: Record = {} + if (data.name !== undefined) + values.name = encryptField(categoryId, data.name) ?? data.name + if (data.color !== undefined) values.color = data.color + if (data.sort_order !== undefined) values.sortOrder = data.sort_order + + const [row] = await db + .update(calendarCategories) + .set(values) + .where( + and( + eq(calendarCategories.id, categoryId), + eq(calendarCategories.userId, userId), + ), + ) + .returning() + + return row ?? null +} + +export async function deleteCategory( + userId: string, + categoryId: string, +): Promise { + const db = await getDb() + await db + .delete(calendarCategories) + .where( + and( + eq(calendarCategories.id, categoryId), + eq(calendarCategories.userId, userId), + ), + ) +} diff --git a/apps/calendar/lib/mcp/countdown-tools.ts b/apps/calendar/lib/mcp/countdown-tools.ts new file mode 100644 index 00000000..0b0d225f --- /dev/null +++ b/apps/calendar/lib/mcp/countdown-tools.ts @@ -0,0 +1,89 @@ +import { getDb } from '@/lib/drizzle/client' +import { countdowns } from '@/lib/drizzle/schema' +import { eq, and } from 'drizzle-orm' +import { encryptField } from '@/lib/field-crypto' +import crypto from 'crypto' + +export async function listCountdowns(userId: string) { + const db = await getDb() + const rows = await db + .select() + .from(countdowns) + .where(eq(countdowns.userId, userId)) + + return rows.map((c) => ({ + ...c, + name: c.name, + })) +} + +export async function createCountdown( + userId: string, + data: { + name: string + target_date: string + description?: string | null + color?: string | null + icon?: string | null + }, +) { + const id = crypto.randomUUID() + const db = await getDb() + + const [row] = await db + .insert(countdowns) + .values({ + id, + userId, + name: encryptField(id, data.name) ?? data.name, + targetDate: new Date(data.target_date), + description: data.description ? encryptField(id, data.description) : null, + color: data.color ?? null, + icon: data.icon ?? null, + }) + .returning() + + return row +} + +export async function updateCountdown( + userId: string, + countdownId: string, + data: { + name?: string + target_date?: string + description?: string | null + color?: string | null + icon?: string | null + }, +) { + const db = await getDb() + + const values: Record = {} + if (data.name !== undefined) + values.name = encryptField(countdownId, data.name) ?? data.name + if (data.target_date !== undefined) + values.targetDate = new Date(data.target_date) + if (data.description !== undefined) + values.description = encryptField(countdownId, data.description) + if (data.color !== undefined) values.color = data.color + if (data.icon !== undefined) values.icon = data.icon + + const [row] = await db + .update(countdowns) + .set(values) + .where(and(eq(countdowns.id, countdownId), eq(countdowns.userId, userId))) + .returning() + + return row ?? null +} + +export async function deleteCountdown( + userId: string, + countdownId: string, +): Promise { + const db = await getDb() + await db + .delete(countdowns) + .where(and(eq(countdowns.id, countdownId), eq(countdowns.userId, userId))) +} diff --git a/apps/calendar/lib/mcp/event-tools.ts b/apps/calendar/lib/mcp/event-tools.ts new file mode 100644 index 00000000..def72beb --- /dev/null +++ b/apps/calendar/lib/mcp/event-tools.ts @@ -0,0 +1,172 @@ +import { getDb } from '@/lib/drizzle/client' +import { calendarEvents } from '@/lib/drizzle/schema' +import { eq, and, gte, lte, ilike, or, desc, sql } from 'drizzle-orm' +import { encryptField } from '@/lib/field-crypto' +import { decryptEvent } from '@/lib/api-helpers' +import crypto from 'crypto' + +export async function listEvents( + userId: string, + startDate?: string, + endDate?: string, + query?: string, + page: number = 1, + limit: number = 50, +) { + const db = await getDb() + const filters = [eq(calendarEvents.userId, userId)] + + if (startDate && endDate) { + filters.push(gte(calendarEvents.startDate, new Date(startDate))) + filters.push(lte(calendarEvents.endDate, new Date(endDate))) + } + + if (query) { + const pattern = `%${query}%` + filters.push( + or( + ilike(calendarEvents.title, pattern), + ilike(calendarEvents.description, pattern), + ilike(calendarEvents.location, pattern), + ), + ) + } + + const offset = (page - 1) * limit + + const [rows, countResult] = await Promise.all([ + db + .select() + .from(calendarEvents) + .where(and(...filters)) + .orderBy(desc(calendarEvents.startDate)) + .limit(limit) + .offset(offset), + db + .select({ count: sql`count(*)` }) + .from(calendarEvents) + .where(and(...filters)), + ]) + + const events = rows.map(decryptEvent) + const total = countResult[0]?.count ?? 0 + + return { + events, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + } +} + +export async function getEvent(userId: string, eventId: string) { + const db = await getDb() + const [row] = await db + .select() + .from(calendarEvents) + .where( + and(eq(calendarEvents.id, eventId), eq(calendarEvents.userId, userId)), + ) + + if (!row) return null + return decryptEvent(row) +} + +export async function createEvent( + userId: string, + data: { + title: string + description?: string | null + location?: string | null + start_date: string + end_date: string + is_all_day?: boolean + color?: string | null + category_id?: string | null + notification_minutes?: number | null + }, +) { + const id = crypto.randomUUID() + const db = await getDb() + + const [event] = await db + .insert(calendarEvents) + .values({ + id, + userId, + title: encryptField(id, data.title) ?? '', + description: encryptField(id, data.description), + location: encryptField(id, data.location), + startDate: new Date(data.start_date), + endDate: new Date(data.end_date), + isAllDay: data.is_all_day ?? false, + color: data.color ?? null, + categoryId: data.category_id ?? null, + notificationMinutes: data.notification_minutes ?? null, + }) + .returning() + + return decryptEvent(event) +} + +export async function updateEvent( + userId: string, + eventId: string, + data: { + title?: string + description?: string | null + location?: string | null + start_date?: string + end_date?: string + is_all_day?: boolean + color?: string | null + category_id?: string | null + notification_minutes?: number | null + }, +) { + const db = await getDb() + const existing = await getEvent(userId, eventId) + if (!existing) return null + + const values: Record = {} + if (data.title !== undefined) + values.title = encryptField(eventId, data.title) ?? '' + if (data.description !== undefined) + values.description = encryptField(eventId, data.description) + if (data.location !== undefined) + values.location = encryptField(eventId, data.location) + if (data.start_date !== undefined) + values.startDate = new Date(data.start_date) + if (data.end_date !== undefined) values.endDate = new Date(data.end_date) + if (data.is_all_day !== undefined) values.isAllDay = data.is_all_day + if (data.color !== undefined) values.color = data.color + if (data.category_id !== undefined) values.categoryId = data.category_id + if (data.notification_minutes !== undefined) + values.notificationMinutes = data.notification_minutes + values.updatedAt = new Date() + + const [event] = await db + .update(calendarEvents) + .set(values) + .where( + and(eq(calendarEvents.id, eventId), eq(calendarEvents.userId, userId)), + ) + .returning() + + return decryptEvent(event) +} + +export async function deleteEvent( + userId: string, + eventId: string, +): Promise { + const db = await getDb() + await db + .delete(calendarEvents) + .where( + and(eq(calendarEvents.id, eventId), eq(calendarEvents.userId, userId)), + ) +} diff --git a/apps/calendar/lib/mcp/handler.ts b/apps/calendar/lib/mcp/handler.ts new file mode 100644 index 00000000..b6f447a6 --- /dev/null +++ b/apps/calendar/lib/mcp/handler.ts @@ -0,0 +1,83 @@ +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js' +import { createServer } from './server' +import { getMcpAuth } from './auth-helpers' +import { logAudit } from './audit' +import { getMcpSettings } from './settings' +import { McpAuthError } from './types' + +let transport: WebStandardStreamableHTTPServerTransport | null = null + +function getTransport(): WebStandardStreamableHTTPServerTransport { + if (!transport) { + transport = new WebStandardStreamableHTTPServerTransport({ + enableJsonResponse: true, + }) + const server = createServer() + server.connect(transport) + } + return transport +} + +export async function handleMcpRequest(request: Request): Promise { + try { + const auth = await getMcpAuth(request) + if (!auth) { + return Response.json( + { + error: 'unauthorized', + auth_required: 'Bearer', + authorization_endpoint: `${process.env.BETTER_AUTH_URL || 'http://localhost:3000'}/oauth/authorize`, + }, + { status: 401 }, + ) + } + + const settings = await getMcpSettings(auth.user.userId) + if (!settings.enabled) { + return Response.json( + { error: 'MCP is disabled for this account' }, + { status: 403 }, + ) + } + + const authInfo: AuthInfo = { + token: auth.token, + clientId: `user:${auth.user.userId}`, + scopes: auth.user.scopes, + extra: { + userId: auth.user.userId, + clientId: auth.user.keyId ?? auth.user.authType, + authType: auth.user.authType, + }, + } + + const clientIp = + request.headers.get('x-forwarded-for') ?? + request.headers.get('x-real-ip') ?? + '' + const userAgent = request.headers.get('user-agent') ?? '' + + try { + const transport = getTransport() + return await transport.handleRequest(request, { authInfo }) + } catch (mcpErr) { + await logAudit({ + userId: auth.user.userId, + authType: auth.user.authType, + keyId: auth.user.keyId, + action: 'mcp_request', + success: false, + errorMessage: String(mcpErr), + ipAddress: clientIp, + userAgent, + }) + throw mcpErr + } + } catch (err) { + if (err instanceof McpAuthError) { + return Response.json({ error: err.message }, { status: err.statusCode }) + } + return Response.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/calendar/lib/mcp/profile-tools.ts b/apps/calendar/lib/mcp/profile-tools.ts new file mode 100644 index 00000000..ff324e90 --- /dev/null +++ b/apps/calendar/lib/mcp/profile-tools.ts @@ -0,0 +1,19 @@ +import { getDb } from '@/lib/drizzle/client' +import { user } from '@/lib/drizzle/schema' +import { eq } from 'drizzle-orm' + +export async function getProfile(userId: string) { + const db = await getDb() + const [row] = await db + .select({ + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + image: user.image, + }) + .from(user) + .where(eq(user.id, userId)) + + return row ?? null +} diff --git a/apps/calendar/lib/mcp/server.ts b/apps/calendar/lib/mcp/server.ts new file mode 100644 index 00000000..4f30a586 --- /dev/null +++ b/apps/calendar/lib/mcp/server.ts @@ -0,0 +1,499 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js' +import { z } from 'zod' + +const SCOPE_EVENTS_READ = 'events:read' +const SCOPE_EVENTS_WRITE = 'events:write' +const SCOPE_CATEGORIES_READ = 'categories:read' +const SCOPE_CATEGORIES_WRITE = 'categories:write' +const SCOPE_COUNTDOWNS_READ = 'countdowns:read' +const SCOPE_COUNTDOWNS_WRITE = 'countdowns:write' +const SCOPE_SETTINGS_READ = 'settings:read' +const SCOPE_SETTINGS_WRITE = 'settings:write' +const SCOPE_PROFILE_READ = 'profile:read' + +function getUserId(authInfo?: AuthInfo): string { + const id = authInfo?.extra?.userId as string | undefined + if (!id) throw new Error('Unauthorized') + return id +} + +function hasScope(authInfo: AuthInfo | undefined, scope: string): boolean { + return authInfo?.scopes?.includes(scope) ?? false +} + +function requireScope(authInfo: AuthInfo | undefined, scope: string): void { + if (!hasScope(authInfo, scope)) { + throw new Error(`Missing required scope: ${scope}`) + } +} + +function getClientId(authInfo?: AuthInfo): string { + return (authInfo?.extra?.clientId as string) ?? 'unknown' +} + +export function createServer(): McpServer { + const server = new McpServer( + { name: 'One Calendar MCP', version: '1.0.0' }, + { capabilities: { tools: {} } }, + ) + + registerEventTools(server) + registerCategoryTools(server) + registerCountdownTools(server) + registerSettingsTools(server) + registerProfileTool(server) + + return server +} + +function registerEventTools(server: McpServer): void { + server.tool( + 'list_events', + '查询日历事件列表,可按时间范围、关键字搜索', + { + start_date: z.string().optional().describe('开始日期 (ISO 8601)'), + end_date: z.string().optional().describe('结束日期 (ISO 8601)'), + query: z.string().optional().describe('搜索关键字'), + page: z.number().optional().default(1).describe('页码'), + limit: z.number().optional().default(50).describe('每页数量 (最大 50)'), + }, + async (params, extra) => { + const authInfo = extra.authInfo + requireScope(authInfo, SCOPE_EVENTS_READ) + const userId = getUserId(authInfo) + try { + const { listEvents } = await import('./event-tools') + const result = await listEvents( + userId, + params.start_date, + params.end_date, + params.query, + params.page ?? 1, + Math.min(params.limit ?? 50, 50), + ) + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }, + ) + + server.tool( + 'get_event', + '获取单个事件的详细信息', + { + event_id: z.string().describe('事件 ID'), + }, + async (params, extra) => { + requireScope(extra.authInfo, SCOPE_EVENTS_READ) + const userId = getUserId(extra.authInfo) + try { + const { getEvent } = await import('./event-tools') + const result = await getEvent(userId, params.event_id) + if (!result) { + return { + content: [{ type: 'text' as const, text: 'Event not found' }], + isError: true, + } + } + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }, + ) + + server.tool( + 'create_event', + '创建新的日历事件', + { + title: z.string().describe('事件标题'), + description: z.string().optional().describe('事件描述'), + location: z.string().optional().describe('地点'), + start_date: z.string().describe('开始时间 (ISO 8601)'), + end_date: z.string().describe('结束时间 (ISO 8601)'), + is_all_day: z.boolean().optional().default(false), + color: z.string().optional().describe('颜色 (十六进制)'), + category_id: z.string().optional(), + notification_minutes: z.number().optional(), + }, + async (params, extra) => { + requireScope(extra.authInfo, SCOPE_EVENTS_WRITE) + const userId = getUserId(extra.authInfo) + try { + const { createEvent } = await import('./event-tools') + const result = await createEvent(userId, params) + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }, + ) + + server.tool( + 'update_event', + '修改已有的事件', + { + event_id: z.string().describe('事件 ID'), + title: z.string().optional(), + description: z.string().optional(), + location: z.string().optional(), + start_date: z.string().optional(), + end_date: z.string().optional(), + is_all_day: z.boolean().optional(), + color: z.string().optional(), + category_id: z.string().optional(), + notification_minutes: z.number().optional(), + }, + async (params, extra) => { + requireScope(extra.authInfo, SCOPE_EVENTS_WRITE) + const userId = getUserId(extra.authInfo) + try { + const { updateEvent } = await import('./event-tools') + const result = await updateEvent(userId, params.event_id, params) + if (!result) { + return { + content: [{ type: 'text' as const, text: 'Event not found' }], + isError: true, + } + } + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }, + ) + + server.tool( + 'delete_event', + '删除一个事件', + { + event_id: z.string().describe('事件 ID'), + }, + async (params, extra) => { + requireScope(extra.authInfo, SCOPE_EVENTS_WRITE) + const userId = getUserId(extra.authInfo) + try { + const { deleteEvent } = await import('./event-tools') + await deleteEvent(userId, params.event_id) + return { content: [{ type: 'text' as const, text: 'Event deleted' }] } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }, + ) +} + +function registerCategoryTools(server: McpServer): void { + server.tool('list_categories', '查询所有分类', {}, async (_params, extra) => { + requireScope(extra.authInfo, SCOPE_CATEGORIES_READ) + const userId = getUserId(extra.authInfo) + try { + const { listCategories } = await import('./category-tools') + const result = await listCategories(userId) + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }) + + server.tool( + 'create_category', + '创建新分类', + { + name: z.string().describe('分类名称'), + color: z.string().describe('颜色 (十六进制)'), + sort_order: z.number().optional().default(0), + }, + async (params, extra) => { + requireScope(extra.authInfo, SCOPE_CATEGORIES_WRITE) + const userId = getUserId(extra.authInfo) + try { + const { createCategory } = await import('./category-tools') + const result = await createCategory(userId, params) + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }, + ) + + server.tool( + 'update_category', + '修改分类', + { + category_id: z.string(), + name: z.string().optional(), + color: z.string().optional(), + sort_order: z.number().optional(), + }, + async (params, extra) => { + requireScope(extra.authInfo, SCOPE_CATEGORIES_WRITE) + const userId = getUserId(extra.authInfo) + try { + const { updateCategory } = await import('./category-tools') + const result = await updateCategory(userId, params.category_id, params) + if (!result) { + return { + content: [{ type: 'text' as const, text: 'Category not found' }], + isError: true, + } + } + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }, + ) + + server.tool( + 'delete_category', + '删除分类', + { + category_id: z.string(), + }, + async (params, extra) => { + requireScope(extra.authInfo, SCOPE_CATEGORIES_WRITE) + const userId = getUserId(extra.authInfo) + try { + const { deleteCategory } = await import('./category-tools') + await deleteCategory(userId, params.category_id) + return { + content: [{ type: 'text' as const, text: 'Category deleted' }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }, + ) +} + +function registerCountdownTools(server: McpServer): void { + server.tool( + 'list_countdowns', + '查询所有倒计时', + {}, + async (_params, extra) => { + requireScope(extra.authInfo, SCOPE_COUNTDOWNS_READ) + const userId = getUserId(extra.authInfo) + try { + const { listCountdowns } = await import('./countdown-tools') + const result = await listCountdowns(userId) + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }, + ) + + server.tool( + 'create_countdown', + '创建新的倒计时', + { + name: z.string().describe('倒计时名称'), + target_date: z.string().describe('目标日期 (ISO 8601)'), + description: z.string().optional(), + color: z.string().optional(), + icon: z.string().optional(), + }, + async (params, extra) => { + requireScope(extra.authInfo, SCOPE_COUNTDOWNS_WRITE) + const userId = getUserId(extra.authInfo) + try { + const { createCountdown } = await import('./countdown-tools') + const result = await createCountdown(userId, params) + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }, + ) + + server.tool( + 'update_countdown', + '修改倒计时', + { + countdown_id: z.string(), + name: z.string().optional(), + target_date: z.string().optional(), + description: z.string().optional(), + color: z.string().optional(), + icon: z.string().optional(), + }, + async (params, extra) => { + requireScope(extra.authInfo, SCOPE_COUNTDOWNS_WRITE) + const userId = getUserId(extra.authInfo) + try { + const { updateCountdown } = await import('./countdown-tools') + const result = await updateCountdown( + userId, + params.countdown_id, + params, + ) + if (!result) { + return { + content: [{ type: 'text' as const, text: 'Countdown not found' }], + isError: true, + } + } + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }, + ) + + server.tool( + 'delete_countdown', + '删除倒计时', + { + countdown_id: z.string(), + }, + async (params, extra) => { + requireScope(extra.authInfo, SCOPE_COUNTDOWNS_WRITE) + const userId = getUserId(extra.authInfo) + try { + const { deleteCountdown } = await import('./countdown-tools') + await deleteCountdown(userId, params.countdown_id) + return { + content: [{ type: 'text' as const, text: 'Countdown deleted' }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }, + ) +} + +function registerSettingsTools(server: McpServer): void { + server.tool('get_settings', '获取用户设置', {}, async (_params, extra) => { + requireScope(extra.authInfo, SCOPE_SETTINGS_READ) + const userId = getUserId(extra.authInfo) + try { + const { getSettings } = await import('./settings-tools') + const result = await getSettings(userId) + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }) + + server.tool( + 'update_settings', + '更新用户设置', + { + language: z.string().optional(), + timezone: z.string().optional(), + default_view: z.string().optional(), + time_format: z.string().optional(), + first_day_of_week: z.number().optional(), + theme: z.string().optional(), + enable_shortcuts: z.boolean().optional(), + }, + async (params, extra) => { + requireScope(extra.authInfo, SCOPE_SETTINGS_WRITE) + const userId = getUserId(extra.authInfo) + try { + const { updateSettings } = await import('./settings-tools') + await updateSettings(userId, params) + return { + content: [{ type: 'text' as const, text: 'Settings updated' }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }, + ) +} + +function registerProfileTool(server: McpServer): void { + server.tool( + 'get_profile', + '获取当前用户信息(名称、邮箱等)', + {}, + async (_params, extra) => { + requireScope(extra.authInfo, SCOPE_PROFILE_READ) + const userId = getUserId(extra.authInfo) + try { + const { getProfile } = await import('./profile-tools') + const result = await getProfile(userId) + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } + } + }, + ) +} diff --git a/apps/calendar/lib/mcp/settings-tools.ts b/apps/calendar/lib/mcp/settings-tools.ts new file mode 100644 index 00000000..18a66d24 --- /dev/null +++ b/apps/calendar/lib/mcp/settings-tools.ts @@ -0,0 +1,43 @@ +import { getDb } from '@/lib/drizzle/client' +import { settings } from '@/lib/drizzle/schema' +import { eq } from 'drizzle-orm' + +export async function getSettings(userId: string) { + const db = await getDb() + const [row] = await db + .select() + .from(settings) + .where(eq(settings.userId, userId)) + + return row?.data ?? {} +} + +export async function updateSettings( + userId: string, + data: Record, +) { + const db = await getDb() + const [existing] = await db + .select() + .from(settings) + .where(eq(settings.userId, userId)) + + const merged = { + ...((existing?.data as Record) ?? {}), + ...data, + } + + await db + .insert(settings) + .values({ + userId, + data: merged, + }) + .onConflictDoUpdate({ + target: settings.userId, + set: { + data: merged, + updatedAt: new Date(), + }, + }) +} From 2d25e5d0fe30ccc474242323ec3e92f87a29a1b7 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Wed, 29 Jul 2026 14:06:50 +0800 Subject: [PATCH 16/36] feat: add MCP management API routes - API Key CRUD (list, create, revoke, update scopes) - MCP settings toggle (enable/disable, rate limit) - OAuth authorized apps list and revoke - Audit log retrieval with pagination - All routes use existing session-based auth --- apps/calendar/app/api/mcp/api-keys/route.ts | 78 +++++++++++++++++++ apps/calendar/app/api/mcp/audit-logs/route.ts | 30 +++++++ .../app/api/mcp/authorized-apps/route.ts | 53 +++++++++++++ apps/calendar/app/api/mcp/settings/route.ts | 33 ++++++++ 4 files changed, 194 insertions(+) create mode 100644 apps/calendar/app/api/mcp/api-keys/route.ts create mode 100644 apps/calendar/app/api/mcp/audit-logs/route.ts create mode 100644 apps/calendar/app/api/mcp/authorized-apps/route.ts create mode 100644 apps/calendar/app/api/mcp/settings/route.ts diff --git a/apps/calendar/app/api/mcp/api-keys/route.ts b/apps/calendar/app/api/mcp/api-keys/route.ts new file mode 100644 index 00000000..cd87c2a8 --- /dev/null +++ b/apps/calendar/app/api/mcp/api-keys/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getAuthedUser } from '@/lib/api-helpers' +import { + generateApiKey, + listApiKeys, + revokeApiKey, + updateApiKeyScopes, +} from '@/lib/mcp/auth' + +export const runtime = 'nodejs' + +export async function GET() { + const user = await getAuthedUser() + if (!user) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const keys = await listApiKeys(user.id) + return NextResponse.json({ keys }) +} + +export async function POST(request: NextRequest) { + const user = await getAuthedUser() + if (!user) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const body = await request.json().catch(() => ({})) + const { name, scopes } = body as { name?: string; scopes?: string[] } + + if (!name || typeof name !== 'string') { + return NextResponse.json({ error: 'Name is required' }, { status: 400 }) + } + + const key = await generateApiKey(name, user.id, scopes ?? []) + + return NextResponse.json({ + key, + message: 'Save this key now — it will not be shown again', + }) +} + +export async function PUT(request: NextRequest) { + const user = await getAuthedUser() + if (!user) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const body = await request.json().catch(() => ({})) + const { id, scopes } = body as { id?: string; scopes?: string[] } + + if (!id) { + return NextResponse.json({ error: 'Key ID is required' }, { status: 400 }) + } + + if (scopes) { + await updateApiKeyScopes(id, user.id, scopes) + } + + return NextResponse.json({ success: true }) +} + +export async function DELETE(request: NextRequest) { + const user = await getAuthedUser() + if (!user) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const body = await request.json().catch(() => ({})) + const { id } = body as { id?: string } + + if (!id) { + return NextResponse.json({ error: 'Key ID is required' }, { status: 400 }) + } + + const revoked = await revokeApiKey(id, user.id) + if (!revoked) { + return NextResponse.json({ error: 'Key not found' }, { status: 404 }) + } + + return NextResponse.json({ success: true }) +} diff --git a/apps/calendar/app/api/mcp/audit-logs/route.ts b/apps/calendar/app/api/mcp/audit-logs/route.ts new file mode 100644 index 00000000..b992e010 --- /dev/null +++ b/apps/calendar/app/api/mcp/audit-logs/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getAuthedUser } from '@/lib/api-helpers' +import { getAuditLogs, getAuditLogsCount } from '@/lib/mcp/audit' + +export const runtime = 'nodejs' + +export async function GET(request: NextRequest) { + const user = await getAuthedUser() + if (!user) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { searchParams } = request.nextUrl + const page = Number(searchParams.get('page') ?? 1) + const limit = Math.min(Number(searchParams.get('limit') ?? 50), 100) + + const [logs, total] = await Promise.all([ + getAuditLogs(user.id, limit, (page - 1) * limit), + getAuditLogsCount(user.id), + ]) + + return NextResponse.json({ + logs, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }) +} diff --git a/apps/calendar/app/api/mcp/authorized-apps/route.ts b/apps/calendar/app/api/mcp/authorized-apps/route.ts new file mode 100644 index 00000000..96ecac5c --- /dev/null +++ b/apps/calendar/app/api/mcp/authorized-apps/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getAuthedUser } from '@/lib/api-helpers' +import { getDb } from '@/lib/drizzle/client' +import { mcpTokens } from '@/lib/drizzle/schema' +import { eq, and, gte } from 'drizzle-orm' + +export const runtime = 'nodejs' + +export async function GET() { + const user = await getAuthedUser() + if (!user) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const db = await getDb() + const apps = await db + .select({ + id: mcpTokens.id, + clientId: mcpTokens.clientId, + clientName: mcpTokens.clientName, + scopes: mcpTokens.scopes, + createdAt: mcpTokens.createdAt, + expiresAt: mcpTokens.expiresAt, + isRevoked: mcpTokens.isRevoked, + }) + .from(mcpTokens) + .where( + and(eq(mcpTokens.userId, user.id), gte(mcpTokens.expiresAt, new Date())), + ) + .orderBy(mcpTokens.createdAt) + + return NextResponse.json({ apps }) +} + +export async function DELETE(request: NextRequest) { + const user = await getAuthedUser() + if (!user) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const body = await request.json().catch(() => ({})) + const { id } = body as { id?: string } + + if (!id) { + return NextResponse.json({ error: 'Token ID is required' }, { status: 400 }) + } + + const db = await getDb() + await db + .update(mcpTokens) + .set({ isRevoked: true }) + .where(and(eq(mcpTokens.id, id), eq(mcpTokens.userId, user.id))) + + return NextResponse.json({ success: true }) +} diff --git a/apps/calendar/app/api/mcp/settings/route.ts b/apps/calendar/app/api/mcp/settings/route.ts new file mode 100644 index 00000000..d0fb88a2 --- /dev/null +++ b/apps/calendar/app/api/mcp/settings/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getAuthedUser } from '@/lib/api-helpers' +import { getMcpSettings, updateMcpSettings } from '@/lib/mcp/settings' + +export const runtime = 'nodejs' + +export async function GET() { + const user = await getAuthedUser() + if (!user) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const settings = await getMcpSettings(user.id) + return NextResponse.json({ settings }) +} + +export async function PUT(request: NextRequest) { + const user = await getAuthedUser() + if (!user) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const body = await request.json().catch(() => ({})) + const { enabled, rate_limit_rpm } = body as { + enabled?: boolean + rate_limit_rpm?: number + } + + const updated = await updateMcpSettings(user.id, { + enabled, + rateLimitRpm: rate_limit_rpm, + }) + + return NextResponse.json({ settings: updated }) +} From bf1e69679dc74c51f8d45a764ba60d48ff3969a0 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Wed, 29 Jul 2026 14:09:38 +0800 Subject: [PATCH 17/36] feat: add MCP settings UI components - Create MCPSettings component with tabbed interface - Implement Overview tab with MCP toggle and endpoint info - Add API Key management (create, list, revoke, edit scopes) - Add OAuth authorized apps viewer with revoke capability - Add audit log viewer with pagination - Integrate MCP section into main settings page --- .../components/app/profile/settings.tsx | 4 + .../app/settings/mcp/mcp-settings.tsx | 656 ++++++++++++++++++ 2 files changed, 660 insertions(+) create mode 100644 apps/calendar/components/app/settings/mcp/mcp-settings.tsx diff --git a/apps/calendar/components/app/profile/settings.tsx b/apps/calendar/components/app/profile/settings.tsx index 6960c16f..5358f06a 100644 --- a/apps/calendar/components/app/profile/settings.tsx +++ b/apps/calendar/components/app/profile/settings.tsx @@ -50,6 +50,7 @@ import { ArrowLeft, } from 'lucide-react' import { Kbd } from '@zntr/ui/kbd' +import MCPSettings from '@/components/app/settings/mcp/mcp-settings' interface SettingsProps { language: string @@ -397,6 +398,9 @@ export default function Settings({ />
+
+ +
diff --git a/apps/calendar/components/app/settings/mcp/mcp-settings.tsx b/apps/calendar/components/app/settings/mcp/mcp-settings.tsx new file mode 100644 index 00000000..f88b8c29 --- /dev/null +++ b/apps/calendar/components/app/settings/mcp/mcp-settings.tsx @@ -0,0 +1,656 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { Button } from '@zntr/ui/button' +import { Switch } from '@zntr/ui/switch' +import { Label } from '@zntr/ui/label' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@zntr/ui/dialog' + + +import { + Key, + Globe, + ClipboardCopy, + Trash2, + Plus, + Loader2, + CheckCircle, + XCircle, + Bot, + Eye, + EyeOff, +} from 'lucide-react' + +const ALL_SCOPE_OPTIONS = [ + { value: 'events:read', label: 'events:read' }, + { value: 'events:write', label: 'events:write' }, + { value: 'categories:read', label: 'categories:read' }, + { value: 'categories:write', label: 'categories:write' }, + { value: 'countdowns:read', label: 'countdowns:read' }, + { value: 'countdowns:write', label: 'countdowns:write' }, + { value: 'settings:read', label: 'settings:read' }, + { value: 'settings:write', label: 'settings:write' }, + { value: 'profile:read', label: 'profile:read' }, +] + +type Tab = 'overview' | 'api-keys' | 'oauth' | 'audit-logs' + +interface ApiKey { + id: string + name: string + keyPrefix: string + scopes: string[] + isActive: boolean + lastUsedAt: string | null + createdAt: string +} + +interface AuthorizedApp { + id: string + clientId: string + clientName: string + scopes: string[] + createdAt: string + expiresAt: string + isRevoked: boolean +} + +interface AuditLog { + id: string + authType: string + action: string + resourceType: string | null + success: boolean + errorMessage: string | null + createdAt: string +} + +export default function MCPSettings() { + const [activeTab, setActiveTab] = useState('overview') + + return ( +
+
+ + MCP (Model Context Protocol) +
+

+ Let AI agents access and manage your calendar data securely. +

+ +
+ {(['overview', 'api-keys', 'oauth', 'audit-logs'] as Tab[]).map( + (tab) => ( + + ), + )} +
+ + {activeTab === 'overview' && } + {activeTab === 'api-keys' && } + {activeTab === 'oauth' && } + {activeTab === 'audit-logs' && } +
+ ) +} + +function MCPOverview() { + const [enabled, setEnabled] = useState(true) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetch('/api/mcp/settings') + .then((r) => r.json()) + .then((data) => { + setEnabled(data.settings?.enabled ?? true) + }) + .finally(() => setLoading(false)) + }, []) + + const toggleMcp = async (value: boolean) => { + setEnabled(value) + await fetch('/api/mcp/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: value }), + }) + } + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+
+ +

+ Allow AI agents to connect to your calendar via MCP protocol +

+
+ +
+
+ +
+

MCP Endpoint

+
+ + {typeof window !== 'undefined' + ? `${window.location.origin}/api/mcp` + : '/api/mcp'} + + +
+

+ Configure your AI agent to connect to this endpoint using Bearer + authentication with an API key or OAuth token. +

+
+ +
+

Quick Start

+
    +
  1. Create an API key in the API Keys tab
  2. +
  3. Copy the endpoint URL above
  4. +
  5. + Configure your AI agent (Claude, ChatGPT, etc.) with the endpoint + and API key +
  6. +
  7. The agent can now query and manage your calendar
  8. +
+
+
+ ) +} + +function MCPApiKeys() { + const [keys, setKeys] = useState([]) + const [loading, setLoading] = useState(true) + const [showCreate, setShowCreate] = useState(false) + const [newKeyName, setNewKeyName] = useState('') + const [newKeyScopes, setNewKeyScopes] = useState(['events:read']) + const [createdKey, setCreatedKey] = useState(null) + const [editingScopes, setEditingScopes] = useState<{ + id: string + scopes: string[] + } | null>(null) + const [showFullKey, setShowFullKey] = useState(null) + + const loadKeys = useCallback(async () => { + const res = await fetch('/api/mcp/api-keys') + const data = await res.json() + setKeys(data.keys ?? []) + setLoading(false) + }, []) + + useEffect(() => { + loadKeys() + }, [loadKeys]) + + const createKey = async () => { + if (!newKeyName.trim()) return + const res = await fetch('/api/mcp/api-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newKeyName, scopes: newKeyScopes }), + }) + const data = await res.json() + setCreatedKey(data.key) + setShowCreate(false) + setNewKeyName('') + loadKeys() + } + + const revokeKey = async (id: string) => { + await fetch('/api/mcp/api-keys', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }) + loadKeys() + } + + const saveScopes = async () => { + if (!editingScopes) return + await fetch('/api/mcp/api-keys', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: editingScopes.id, + scopes: editingScopes.scopes, + }), + }) + setEditingScopes(null) + loadKeys() + } + + const toggleScope = (scope: string) => { + if (!editingScopes) return + const scopes = editingScopes.scopes.includes(scope) + ? editingScopes.scopes.filter((s) => s !== scope) + : [...editingScopes.scopes, scope] + setEditingScopes({ ...editingScopes, scopes }) + } + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+ !o && setCreatedKey(null)} + > + + + API Key Created + +
+
+

+ Save this key now — it will not be shown again! +

+
+
+ + {showFullKey === createdKey + ? createdKey + : createdKey?.slice(0, 12) + '...'} + + + +
+ +
+
+
+ + + + + Create API Key + +
+
+ + setNewKeyName(e.target.value)} + /> +
+
+ +
+ {ALL_SCOPE_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+
+
+ +
+

API Keys

+ +
+ + {keys.length === 0 ? ( +

+ No API keys created yet. +

+ ) : ( +
+ {keys.map((key) => ( +
+
+
+ + {key.name} + {!key.isActive && ( + + Revoked + + )} +
+
+ + + + + + + Edit Scopes — {key.name} + +
+ {ALL_SCOPE_OPTIONS.map((opt) => ( + + ))} +
+ +
+
+ {key.isActive && ( + + )} +
+
+
+ {key.keyPrefix}... + {key.lastUsedAt && ( + + · Last used: {new Date(key.lastUsedAt).toLocaleDateString()} + + )} +
+
+ {key.scopes.map((scope) => ( + + {scope} + + ))} +
+
+ ))} +
+ )} +
+ ) +} + +function MCPOAuthApps() { + const [apps, setApps] = useState([]) + const [loading, setLoading] = useState(true) + + const loadApps = useCallback(async () => { + const res = await fetch('/api/mcp/authorized-apps') + const data = await res.json() + setApps(data.apps ?? []) + setLoading(false) + }, []) + + useEffect(() => { + loadApps() + }, [loadApps]) + + const revokeApp = async (id: string) => { + await fetch('/api/mcp/authorized-apps', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }) + loadApps() + } + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+

Authorized Applications

+ + {apps.length === 0 ? ( +

+ No OAuth applications authorized yet. +

+ ) : ( +
+ {apps.map((app) => ( +
+
+
+ + {app.clientName} +
+ +
+
+ {app.scopes.map((scope) => ( + + {scope} + + ))} +
+

+ Authorized {new Date(app.createdAt).toLocaleDateString()} + {app.expiresAt && + ` · Expires ${new Date(app.expiresAt).toLocaleDateString()}`} +

+
+ ))} +
+ )} +
+ ) +} + +function MCPAuditLogs() { + const [logs, setLogs] = useState([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + + const loadLogs = useCallback(async (p: number) => { + const res = await fetch(`/api/mcp/audit-logs?page=${p}&limit=20`) + const data = await res.json() + setLogs(data.logs ?? []) + setTotalPages(data.pagination?.totalPages ?? 1) + setLoading(false) + }, []) + + useEffect(() => { + loadLogs(page) + }, [page, loadLogs]) + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+

Audit Logs

+ + {logs.length === 0 ? ( +

+ No MCP operations logged yet. +

+ ) : ( + <> +
+ {logs.map((log) => ( +
+ {log.success ? ( + + ) : ( + + )} +
+
+ {log.action} + + {log.authType} + + {log.resourceType && ( + + {log.resourceType} + {log.resourceId && `:${log.resourceId.slice(0, 8)}`} + + )} +
+ {log.errorMessage && ( +

+ {log.errorMessage} +

+ )} +

+ {new Date(log.createdAt).toLocaleString()} +

+
+
+ ))} +
+ + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} + + )} +
+ ) +} From f16dc45c07a5b31215a296809647df19a9e23873 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Wed, 29 Jul 2026 14:11:05 +0800 Subject: [PATCH 18/36] feat: add rate limiting and audit logging for MCP - Implement in-memory rate limiter per user (configurable RPM) - Integrate rate limit check into MCP request handler - Add audit logging for both success and failure MCP requests - Return 429 with retry_after when rate limit exceeded --- apps/calendar/lib/mcp/handler.ts | 36 +++++++++++++++++++- apps/calendar/lib/mcp/rate-limiter.ts | 48 +++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 apps/calendar/lib/mcp/rate-limiter.ts diff --git a/apps/calendar/lib/mcp/handler.ts b/apps/calendar/lib/mcp/handler.ts index b6f447a6..925b181d 100644 --- a/apps/calendar/lib/mcp/handler.ts +++ b/apps/calendar/lib/mcp/handler.ts @@ -4,6 +4,7 @@ import { createServer } from './server' import { getMcpAuth } from './auth-helpers' import { logAudit } from './audit' import { getMcpSettings } from './settings' +import { checkRateLimit } from './rate-limiter' import { McpAuthError } from './types' let transport: WebStandardStreamableHTTPServerTransport | null = null @@ -41,6 +42,27 @@ export async function handleMcpRequest(request: Request): Promise { ) } + const rateLimit = await checkRateLimit(auth.user.userId) + if (!rateLimit.allowed) { + await logAudit({ + userId: auth.user.userId, + authType: auth.user.authType, + keyId: auth.user.keyId, + action: 'rate_limited', + success: false, + errorMessage: 'Rate limit exceeded', + ipAddress: request.headers.get('x-forwarded-for') ?? '', + userAgent: request.headers.get('user-agent') ?? '', + }) + return Response.json( + { + error: 'rate_limited', + retry_after: Math.ceil((rateLimit.resetAt - Date.now()) / 1000), + }, + { status: 429 }, + ) + } + const authInfo: AuthInfo = { token: auth.token, clientId: `user:${auth.user.userId}`, @@ -60,7 +82,19 @@ export async function handleMcpRequest(request: Request): Promise { try { const transport = getTransport() - return await transport.handleRequest(request, { authInfo }) + const response = await transport.handleRequest(request, { authInfo }) + + await logAudit({ + userId: auth.user.userId, + authType: auth.user.authType, + keyId: auth.user.keyId, + action: 'mcp_request', + success: response.status < 500, + ipAddress: clientIp, + userAgent, + }) + + return response } catch (mcpErr) { await logAudit({ userId: auth.user.userId, diff --git a/apps/calendar/lib/mcp/rate-limiter.ts b/apps/calendar/lib/mcp/rate-limiter.ts new file mode 100644 index 00000000..9c8bd930 --- /dev/null +++ b/apps/calendar/lib/mcp/rate-limiter.ts @@ -0,0 +1,48 @@ +import { getMcpSettings } from './settings' + +interface RateLimitEntry { + count: number + resetAt: number +} + +const store = new Map() + +setInterval(() => { + const now = Date.now() + for (const [key, entry] of store) { + if (entry.resetAt < now) store.delete(key) + } +}, 60_000) + +export async function checkRateLimit(userId: string): Promise<{ + allowed: boolean + remaining: number + resetAt: number +}> { + const settings = await getMcpSettings(userId) + const maxRpm = settings.rateLimitRpm + + const now = Date.now() + const windowKey = `${userId}:${Math.floor(now / 60_000)}` + + const entry = store.get(windowKey) + + if (!entry || entry.resetAt < now) { + store.set(windowKey, { + count: 1, + resetAt: now + 60_000, + }) + return { allowed: true, remaining: maxRpm - 1, resetAt: now + 60_000 } + } + + if (entry.count >= maxRpm) { + return { allowed: false, remaining: 0, resetAt: entry.resetAt } + } + + entry.count++ + return { + allowed: true, + remaining: maxRpm - entry.count, + resetAt: entry.resetAt, + } +} From fd1e5c886078ada6a870b989d3a1a216b26e1a6b Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Wed, 29 Jul 2026 14:19:18 +0800 Subject: [PATCH 19/36] chore: fix lint warnings in MCP code - Remove unused getClientId function - Clean up catch blocks without error variable usage - Remove unused router import in OAuth authorize page - Add Bot icon instead of unused user profile image - Fix unnecessary spread fallback in settings-tools --- .../calendar/app/api/oauth/authorize/route.ts | 2 +- apps/calendar/app/api/oauth/device/route.ts | 2 +- apps/calendar/app/api/oauth/token/route.ts | 2 +- apps/calendar/app/oauth/authorize/page.tsx | 31 +++++++------------ apps/calendar/lib/mcp/server.ts | 4 --- apps/calendar/lib/mcp/settings-tools.ts | 6 ++-- 6 files changed, 16 insertions(+), 31 deletions(-) diff --git a/apps/calendar/app/api/oauth/authorize/route.ts b/apps/calendar/app/api/oauth/authorize/route.ts index 2725d639..0346398c 100644 --- a/apps/calendar/app/api/oauth/authorize/route.ts +++ b/apps/calendar/app/api/oauth/authorize/route.ts @@ -50,7 +50,7 @@ export async function POST(request: NextRequest) { .where(eq(mcpDeviceCodes.id, record.id)) return NextResponse.json({ success: true }) - } catch (error) { + } catch { return NextResponse.json( { error: 'server_error', error_description: 'Internal server error' }, { status: 500 }, diff --git a/apps/calendar/app/api/oauth/device/route.ts b/apps/calendar/app/api/oauth/device/route.ts index bb3d7ba2..424bb6e8 100644 --- a/apps/calendar/app/api/oauth/device/route.ts +++ b/apps/calendar/app/api/oauth/device/route.ts @@ -36,7 +36,7 @@ export async function POST(request: NextRequest) { expires_in: 300, interval: 5, }) - } catch (error) { + } catch { return NextResponse.json( { error: 'invalid_request', diff --git a/apps/calendar/app/api/oauth/token/route.ts b/apps/calendar/app/api/oauth/token/route.ts index 26c89852..3a2f577f 100644 --- a/apps/calendar/app/api/oauth/token/route.ts +++ b/apps/calendar/app/api/oauth/token/route.ts @@ -99,7 +99,7 @@ export async function POST(request: NextRequest) { email: userInfo.email, }, }) - } catch (error) { + } catch { return NextResponse.json( { error: 'server_error', error_description: 'Internal server error' }, { status: 500 }, diff --git a/apps/calendar/app/oauth/authorize/page.tsx b/apps/calendar/app/oauth/authorize/page.tsx index 403ab2ea..85f773e3 100644 --- a/apps/calendar/app/oauth/authorize/page.tsx +++ b/apps/calendar/app/oauth/authorize/page.tsx @@ -1,24 +1,17 @@ 'use client' import { useState, useEffect } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' +import { useSearchParams } from 'next/navigation' import { Button } from '@zntr/ui/button' -import { Loader2, CheckCircle, XCircle } from 'lucide-react' +import { Loader2, CheckCircle, XCircle, Bot } from 'lucide-react' export default function OAuthAuthorizePage() { - const router = useRouter() const searchParams = useSearchParams() const code = searchParams.get('code') const [status, setStatus] = useState< 'checking' | 'ready' | 'authorizing' | 'success' | 'error' >('checking') - const [user, setUser] = useState<{ - id: string - name: string - email: string - image?: string - } | null>(null) const [errorMsg, setErrorMsg] = useState('') useEffect(() => { @@ -35,12 +28,18 @@ export default function OAuthAuthorizePage() { setStatus('authorizing') try { + const sessionRes = await fetch('/api/auth/get-session') + const session = await sessionRes.json() + if (!session?.user?.id) { + throw new Error('Not authenticated') + } + const res = await fetch('/api/oauth/authorize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_code: code, - user_id: user?.id || '', + user_id: session.user.id, }), }) @@ -101,16 +100,8 @@ export default function OAuthAuthorizePage() {
-
- Calendar { - const target = e.currentTarget - target.style.display = 'none' - }} - /> +
+
diff --git a/apps/calendar/lib/mcp/server.ts b/apps/calendar/lib/mcp/server.ts index 4f30a586..d44e559d 100644 --- a/apps/calendar/lib/mcp/server.ts +++ b/apps/calendar/lib/mcp/server.ts @@ -28,10 +28,6 @@ function requireScope(authInfo: AuthInfo | undefined, scope: string): void { } } -function getClientId(authInfo?: AuthInfo): string { - return (authInfo?.extra?.clientId as string) ?? 'unknown' -} - export function createServer(): McpServer { const server = new McpServer( { name: 'One Calendar MCP', version: '1.0.0' }, diff --git a/apps/calendar/lib/mcp/settings-tools.ts b/apps/calendar/lib/mcp/settings-tools.ts index 18a66d24..e96cec4d 100644 --- a/apps/calendar/lib/mcp/settings-tools.ts +++ b/apps/calendar/lib/mcp/settings-tools.ts @@ -22,10 +22,8 @@ export async function updateSettings( .from(settings) .where(eq(settings.userId, userId)) - const merged = { - ...((existing?.data as Record) ?? {}), - ...data, - } + const existingData = existing?.data as Record | undefined + const merged = { ...existingData, ...data } await db .insert(settings) From c679377e9d974f0bd7d45db95e810147ec7278ae Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Wed, 29 Jul 2026 14:27:47 +0800 Subject: [PATCH 20/36] fix: wrap useSearchParams in Suspense boundary on /oauth/authorize --- apps/calendar/app/oauth/authorize/page.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/calendar/app/oauth/authorize/page.tsx b/apps/calendar/app/oauth/authorize/page.tsx index 85f773e3..0cc067f4 100644 --- a/apps/calendar/app/oauth/authorize/page.tsx +++ b/apps/calendar/app/oauth/authorize/page.tsx @@ -1,11 +1,11 @@ 'use client' -import { useState, useEffect } from 'react' +import { Suspense, useState, useEffect } from 'react' import { useSearchParams } from 'next/navigation' import { Button } from '@zntr/ui/button' import { Loader2, CheckCircle, XCircle, Bot } from 'lucide-react' -export default function OAuthAuthorizePage() { +function AuthorizeForm() { const searchParams = useSearchParams() const code = searchParams.get('code') @@ -143,3 +143,17 @@ export default function OAuthAuthorizePage() {
) } + +export default function OAuthAuthorizePage() { + return ( + + +
+ } + > + + + ) +} From 66654a780974d245660883bb0675bb2b1cdc5120 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Wed, 29 Jul 2026 14:42:25 +0800 Subject: [PATCH 21/36] fix: create fresh MCP transport per request, add CORS origins - Transport cannot be reused in stateless JSON mode - Create new transport + server per request - Add allowedOrigins: ['*'] for cross-origin MCP clients --- apps/calendar/lib/mcp/handler.ts | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/apps/calendar/lib/mcp/handler.ts b/apps/calendar/lib/mcp/handler.ts index 925b181d..b1799f49 100644 --- a/apps/calendar/lib/mcp/handler.ts +++ b/apps/calendar/lib/mcp/handler.ts @@ -7,19 +7,6 @@ import { getMcpSettings } from './settings' import { checkRateLimit } from './rate-limiter' import { McpAuthError } from './types' -let transport: WebStandardStreamableHTTPServerTransport | null = null - -function getTransport(): WebStandardStreamableHTTPServerTransport { - if (!transport) { - transport = new WebStandardStreamableHTTPServerTransport({ - enableJsonResponse: true, - }) - const server = createServer() - server.connect(transport) - } - return transport -} - export async function handleMcpRequest(request: Request): Promise { try { const auth = await getMcpAuth(request) @@ -30,7 +17,7 @@ export async function handleMcpRequest(request: Request): Promise { auth_required: 'Bearer', authorization_endpoint: `${process.env.BETTER_AUTH_URL || 'http://localhost:3000'}/oauth/authorize`, }, - { status: 401 }, + { status: 401, headers: { 'WWW-Authenticate': 'Bearer' } }, ) } @@ -80,8 +67,15 @@ export async function handleMcpRequest(request: Request): Promise { '' const userAgent = request.headers.get('user-agent') ?? '' + const server = createServer() + const transport = new WebStandardStreamableHTTPServerTransport({ + enableJsonResponse: true, + allowedOrigins: ['*'], + }) + + await server.connect(transport) + try { - const transport = getTransport() const response = await transport.handleRequest(request, { authInfo }) await logAudit({ From 41cbba63fbec331e362dd0b8a137422ae66f6877 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Wed, 29 Jul 2026 15:13:40 +0800 Subject: [PATCH 22/36] fix: implement OAuth Authorization Code Grant with PKCE for ChatGPT compatibility - Add mcp_auth_requests table for PKCE auth request storage - Rewrite /oauth/authorize to handle both device_code and auth_code flows - Implement PKCE code challenge verification in token endpoint - Update .well-known to advertise authorization_code grant - Use Node.js crypto for PKCE S256 challenge generation --- .../oauth-authorization-server/route.ts | 5 +- .../calendar/app/api/oauth/authorize/route.ts | 111 +++++--- apps/calendar/app/api/oauth/token/route.ts | 263 +++++++++++++----- apps/calendar/app/oauth/authorize/page.tsx | 138 +++++++-- .../drizzle/0001_create_mcp_tables.sql | 23 +- apps/calendar/lib/drizzle/schema.ts | 32 +++ 6 files changed, 439 insertions(+), 133 deletions(-) diff --git a/apps/calendar/app/.well-known/oauth-authorization-server/route.ts b/apps/calendar/app/.well-known/oauth-authorization-server/route.ts index c3b77edf..ca7598ad 100644 --- a/apps/calendar/app/.well-known/oauth-authorization-server/route.ts +++ b/apps/calendar/app/.well-known/oauth-authorization-server/route.ts @@ -9,7 +9,10 @@ export async function GET() { token_endpoint: `${MCP_BASE_URL}/api/oauth/token`, device_authorization_endpoint: `${MCP_BASE_URL}/api/oauth/device`, response_types_supported: ['code'], - grant_types_supported: ['urn:ietf:params:oauth:grant-type:device_code'], + grant_types_supported: [ + 'authorization_code', + 'urn:ietf:params:oauth:grant-type:device_code', + ], token_endpoint_auth_methods_supported: ['none'], scopes_supported: [ 'events:read', diff --git a/apps/calendar/app/api/oauth/authorize/route.ts b/apps/calendar/app/api/oauth/authorize/route.ts index 0346398c..c294f2fd 100644 --- a/apps/calendar/app/api/oauth/authorize/route.ts +++ b/apps/calendar/app/api/oauth/authorize/route.ts @@ -1,55 +1,102 @@ import { NextRequest, NextResponse } from 'next/server' import { getDb } from '@/lib/drizzle/client' -import { mcpDeviceCodes } from '@/lib/drizzle/schema' +import { mcpDeviceCodes, mcpAuthRequests } from '@/lib/drizzle/schema' import { eq, and, gte } from 'drizzle-orm' +import crypto from 'crypto' export const runtime = 'nodejs' export async function POST(request: NextRequest) { try { const body = await request.json().catch(() => ({})) - const userCode = body.user_code || body.code const userId = body.user_id - if (!userCode || !userId) { + if (!userId) { return NextResponse.json( - { - error: 'invalid_request', - error_description: 'Missing user_code or user_id', - }, + { error: 'invalid_request', error_description: 'Missing user_id' }, { status: 400 }, ) } - const db = await getDb() - - const [record] = await db - .select() - .from(mcpDeviceCodes) - .where( - and( - eq(mcpDeviceCodes.userCode, userCode), - eq(mcpDeviceCodes.status, 'pending'), - gte(mcpDeviceCodes.expiresAt, new Date()), - ), - ) + // Handle Device Code Grant flow + if (body.user_code || body.code) { + const userCode = body.user_code || body.code + const db = await getDb() - if (!record) { - return NextResponse.json( - { - error: 'invalid_grant', - error_description: 'Invalid or expired code', - }, - { status: 400 }, - ) + const [record] = await db + .select() + .from(mcpDeviceCodes) + .where( + and( + eq(mcpDeviceCodes.userCode, userCode), + eq(mcpDeviceCodes.status, 'pending'), + gte(mcpDeviceCodes.expiresAt, new Date()), + ), + ) + + if (!record) { + return NextResponse.json( + { + error: 'invalid_grant', + error_description: 'Invalid or expired code', + }, + { status: 400 }, + ) + } + + await db + .update(mcpDeviceCodes) + .set({ status: 'approved', userId }) + .where(eq(mcpDeviceCodes.id, record.id)) + + return NextResponse.json({ success: true }) } - await db - .update(mcpDeviceCodes) - .set({ status: 'approved', userId }) - .where(eq(mcpDeviceCodes.id, record.id)) + // Handle Authorization Code Grant flow (PKCE) + if (body.response_type === 'code') { + const clientId = body.client_id + const redirectUri = body.redirect_uri + const scope = body.scope ?? '' + const codeChallenge = body.code_challenge + const codeChallengeMethod = body.code_challenge_method + const state = body.state + const resource = body.resource - return NextResponse.json({ success: true }) + if (!clientId || !redirectUri) { + return NextResponse.json( + { + error: 'invalid_request', + error_description: 'Missing client_id or redirect_uri', + }, + { status: 400 }, + ) + } + + const authorizationCode = crypto.randomUUID() + const db = await getDb() + + await db.insert(mcpAuthRequests).values({ + id: crypto.randomUUID(), + userId, + clientId, + redirectUri, + scopes: scope.split(' ').filter(Boolean), + codeChallenge: codeChallenge ?? null, + codeChallengeMethod: codeChallengeMethod ?? null, + state: state ?? null, + resource: resource ?? null, + authorizationCode, + codeExpiresAt: new Date(Date.now() + 10 * 60 * 1000), + status: 'approved', + }) + + return NextResponse.json({ code: authorizationCode }) + } + + return NextResponse.json( + { error: 'invalid_request', error_description: 'Unsupported flow' }, + { status: 400 }, + ) } catch { return NextResponse.json( { error: 'server_error', error_description: 'Internal server error' }, diff --git a/apps/calendar/app/api/oauth/token/route.ts b/apps/calendar/app/api/oauth/token/route.ts index 3a2f577f..98a27abb 100644 --- a/apps/calendar/app/api/oauth/token/route.ts +++ b/apps/calendar/app/api/oauth/token/route.ts @@ -1,7 +1,11 @@ import { NextRequest, NextResponse } from 'next/server' import { getDb } from '@/lib/drizzle/client' -import { mcpDeviceCodes, mcpTokens } from '@/lib/drizzle/schema' -import { eq } from 'drizzle-orm' +import { + mcpDeviceCodes, + mcpTokens, + mcpAuthRequests, +} from '@/lib/drizzle/schema' +import { eq, and, gte } from 'drizzle-orm' import { generateAccessToken, generateRefreshToken, @@ -17,92 +21,213 @@ export async function POST(request: NextRequest) { const body = await request.json().catch(() => ({})) const grantType = body.grant_type - if (grantType !== 'urn:ietf:params:oauth:grant-type:device_code') { - return NextResponse.json( - { error: 'unsupported_grant_type' }, - { status: 400 }, - ) + // --- Device Code Grant --- + if (grantType === 'urn:ietf:params:oauth:grant-type:device_code') { + return handleDeviceCodeGrant(body) } - const deviceCode = body.device_code - if (!deviceCode) { - return NextResponse.json( - { error: 'invalid_request', error_description: 'Missing device_code' }, - { status: 400 }, - ) + // --- Authorization Code Grant (PKCE) --- + if (grantType === 'authorization_code') { + return handleAuthorizationCodeGrant(body) } - const hashedDeviceCode = hashToken(deviceCode) - const db = await getDb() + return NextResponse.json( + { error: 'unsupported_grant_type' }, + { status: 400 }, + ) + } catch { + return NextResponse.json( + { error: 'server_error', error_description: 'Internal server error' }, + { status: 500 }, + ) + } +} + +async function handleDeviceCodeGrant(body: Record) { + const deviceCode = body.device_code as string | undefined + if (!deviceCode) { + return NextResponse.json( + { error: 'invalid_request', error_description: 'Missing device_code' }, + { status: 400 }, + ) + } + + const hashedDeviceCode = hashToken(deviceCode) + const db = await getDb() - const [record] = await db - .select() - .from(mcpDeviceCodes) - .where(eq(mcpDeviceCodes.deviceCode, hashedDeviceCode)) + const [record] = await db + .select() + .from(mcpDeviceCodes) + .where(eq(mcpDeviceCodes.deviceCode, hashedDeviceCode)) - if (!record) { - return NextResponse.json( - { error: 'invalid_grant', error_description: 'Invalid device code' }, - { status: 400 }, - ) - } + if (!record) { + return NextResponse.json( + { error: 'invalid_grant', error_description: 'Invalid device code' }, + { status: 400 }, + ) + } + + if (record.expiresAt < new Date()) { + return NextResponse.json( + { error: 'expired_token', error_description: 'Device code expired' }, + { status: 400 }, + ) + } + + if (record.status === 'pending') { + return NextResponse.json( + { error: 'authorization_pending' }, + { status: 400 }, + ) + } + + if (record.status !== 'approved' || !record.userId) { + return NextResponse.json({ error: 'invalid_grant' }, { status: 400 }) + } - if (record.expiresAt < new Date()) { + return issueTokens( + record.userId, + record.scopes as string[], + record.clientId, + record.clientName, + ) +} + +async function handleAuthorizationCodeGrant(body: Record) { + const code = body.code as string | undefined + const codeVerifier = body.code_verifier as string | undefined + const redirectUri = body.redirect_uri as string | undefined + const clientId = body.client_id as string | undefined + + if (!code) { + return NextResponse.json( + { error: 'invalid_request', error_description: 'Missing code' }, + { status: 400 }, + ) + } + + const db = await getDb() + + const [record] = await db + .select() + .from(mcpAuthRequests) + .where( + and( + eq(mcpAuthRequests.authorizationCode, code), + eq(mcpAuthRequests.status, 'approved'), + gte(mcpAuthRequests.codeExpiresAt, new Date()), + ), + ) + + if (!record) { + return NextResponse.json( + { + error: 'invalid_grant', + error_description: 'Invalid or expired authorization code', + }, + { status: 400 }, + ) + } + + // Verify redirect_uri + if (redirectUri && record.redirectUri && redirectUri !== record.redirectUri) { + return NextResponse.json( + { error: 'invalid_grant', error_description: 'redirect_uri mismatch' }, + { status: 400 }, + ) + } + + // Verify client_id + if (clientId && clientId !== record.clientId) { + return NextResponse.json( + { error: 'invalid_grant', error_description: 'client_id mismatch' }, + { status: 400 }, + ) + } + + // PKCE verification + if (record.codeChallenge && record.codeChallengeMethod) { + if (!codeVerifier) { return NextResponse.json( - { error: 'expired_token', error_description: 'Device code expired' }, + { error: 'invalid_grant', error_description: 'Missing code_verifier' }, { status: 400 }, ) } - if (record.status === 'pending') { + const expectedChallenge = await generateCodeChallenge( + codeVerifier, + record.codeChallengeMethod, + ) + if (expectedChallenge !== record.codeChallenge) { return NextResponse.json( - { error: 'authorization_pending' }, + { error: 'invalid_grant', error_description: 'code_verifier mismatch' }, { status: 400 }, ) } + } - if (record.status !== 'approved' || !record.userId) { - return NextResponse.json({ error: 'invalid_grant' }, { status: 400 }) - } + // Mark code as used + await db + .update(mcpAuthRequests) + .set({ status: 'used' }) + .where(eq(mcpAuthRequests.id, record.id)) - const accessToken = generateAccessToken() - const refreshToken = generateRefreshToken() - const userInfo = await getFullUserInfo(record.userId) - - await db.insert(mcpTokens).values({ - id: crypto.randomUUID(), - userId: record.userId, - tokenHash: hashToken(accessToken), - refreshTokenHash: hashToken(refreshToken), - tokenType: 'bearer', - scopes: record.scopes, - clientId: record.clientId, - clientName: record.clientName, - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), - refreshExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), - }) - - await db - .update(mcpDeviceCodes) - .set({ status: 'used' }) - .where(eq(mcpDeviceCodes.id, record.id)) - - return NextResponse.json({ - access_token: accessToken, - token_type: 'bearer', - expires_in: 86400, - refresh_token: refreshToken, - scope: record.scopes.join(' '), - user: { - id: record.userId, - name: userInfo.name, - email: userInfo.email, - }, - }) - } catch { + if (!record.userId) { return NextResponse.json( - { error: 'server_error', error_description: 'Internal server error' }, - { status: 500 }, + { error: 'invalid_grant', error_description: 'No user associated' }, + { status: 400 }, ) } + + return issueTokens( + record.userId, + record.scopes as string[], + record.clientId, + record.clientId.slice(0, 12) + '...', + ) +} + +function generateCodeChallenge(verifier: string, method: string): string { + if (method === 'S256') { + const hash = crypto + .createHash('sha256') + .update(verifier) + .digest('base64url') + return hash.replace(/=/g, '') + } + return verifier +} + +async function issueTokens( + userId: string, + scopes: string[], + clientId: string, + clientName: string, +) { + const accessToken = generateAccessToken() + const refreshToken = generateRefreshToken() + const userInfo = await getFullUserInfo(userId) + + const db = await getDb() + await db.insert(mcpTokens).values({ + id: crypto.randomUUID(), + userId, + tokenHash: hashToken(accessToken), + refreshTokenHash: hashToken(refreshToken), + tokenType: 'bearer', + scopes, + clientId, + clientName, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + refreshExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + }) + + return NextResponse.json({ + access_token: accessToken, + token_type: 'bearer', + expires_in: 86400, + refresh_token: refreshToken, + scope: scopes.join(' '), + user: { id: userId, name: userInfo.name, email: userInfo.email }, + }) } diff --git a/apps/calendar/app/oauth/authorize/page.tsx b/apps/calendar/app/oauth/authorize/page.tsx index 0cc067f4..c009cd1c 100644 --- a/apps/calendar/app/oauth/authorize/page.tsx +++ b/apps/calendar/app/oauth/authorize/page.tsx @@ -5,26 +5,53 @@ import { useSearchParams } from 'next/navigation' import { Button } from '@zntr/ui/button' import { Loader2, CheckCircle, XCircle, Bot } from 'lucide-react' +type Flow = 'device_code' | 'auth_code' | null + function AuthorizeForm() { const searchParams = useSearchParams() - const code = searchParams.get('code') const [status, setStatus] = useState< 'checking' | 'ready' | 'authorizing' | 'success' | 'error' >('checking') const [errorMsg, setErrorMsg] = useState('') + const [flow, setFlow] = useState(null) + const [clientInfo, setClientInfo] = useState({ + name: 'AI Agent', + scopes: [] as string[], + resource: '', + }) useEffect(() => { - if (!code) { + const responseType = searchParams.get('response_type') + const deviceUserCode = searchParams.get('code') + + if (responseType === 'code') { + setFlow('auth_code') + const scopes = (searchParams.get('scope') ?? '') + .split(' ') + .filter(Boolean) + setClientInfo({ + name: + (searchParams.get('client_id') ?? 'AI Agent').slice(0, 12) + '...', + scopes, + resource: searchParams.get('resource') ?? '', + }) + if (!searchParams.get('redirect_uri')) { + setStatus('error') + setErrorMsg('Missing redirect_uri parameter.') + return + } + setStatus('ready') + } else if (deviceUserCode) { + setFlow('device_code') + setStatus('ready') + } else { setStatus('error') - setErrorMsg('No authorization code provided.') - return + setErrorMsg('Invalid authorization request.') } - setStatus('ready') - }, [code]) + }, [searchParams]) const handleAuthorize = async () => { - if (!code) return setStatus('authorizing') try { @@ -34,24 +61,53 @@ function AuthorizeForm() { throw new Error('Not authenticated') } - const res = await fetch('/api/oauth/authorize', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - user_code: code, - user_id: session.user.id, - }), - }) - - if (!res.ok) { - const err = await res.json() - throw new Error(err.error_description || 'Authorization failed') + if (flow === 'device_code') { + const code = searchParams.get('code') + const res = await fetch('/api/oauth/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_code: code, + user_id: session.user.id, + }), + }) + if (!res.ok) { + const err = await res.json() + throw new Error(err.error_description || 'Authorization failed') + } + setStatus('success') + setTimeout(() => window.close(), 2000) + } else if (flow === 'auth_code') { + const redirectUri = searchParams.get('redirect_uri')! + const state = searchParams.get('state') ?? '' + + const res = await fetch('/api/oauth/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + response_type: 'code', + client_id: searchParams.get('client_id'), + redirect_uri: redirectUri, + scope: searchParams.get('scope'), + code_challenge: searchParams.get('code_challenge'), + code_challenge_method: searchParams.get('code_challenge_method'), + state, + resource: searchParams.get('resource'), + user_id: session.user.id, + }), + }) + + if (!res.ok) { + const err = await res.json() + throw new Error(err.error_description || 'Authorization failed') + } + + const data = await res.json() + const redirectUrl = new URL(redirectUri) + redirectUrl.searchParams.set('code', data.code) + if (state) redirectUrl.searchParams.set('state', state) + window.location.href = redirectUrl.toString() } - - setStatus('success') - setTimeout(() => { - window.close() - }, 2000) } catch (err) { setStatus('error') setErrorMsg(err instanceof Error ? err.message : 'Something went wrong') @@ -107,16 +163,38 @@ function AuthorizeForm() {

Authorize Application

- An AI agent is requesting access to your calendar data. + {flow === 'auth_code' + ? `"${clientInfo.name}" is requesting access to your calendar data.` + : 'An AI agent is requesting access to your calendar data.'}

-
-
- Code - {code} + {flow === 'auth_code' && clientInfo.scopes.length > 0 && ( +
+

+ Requested permissions: +

+
+ {clientInfo.scopes.map((scope) => ( + + {scope} + + ))} +
-
+ )} + + {flow === 'auth_code' && clientInfo.resource && ( +
+

+ Resource:{' '} + {clientInfo.resource} +

+
+ )}
This will allow the application to read and manage your calendar diff --git a/apps/calendar/drizzle/0001_create_mcp_tables.sql b/apps/calendar/drizzle/0001_create_mcp_tables.sql index 7ed4d31c..bb230c81 100644 --- a/apps/calendar/drizzle/0001_create_mcp_tables.sql +++ b/apps/calendar/drizzle/0001_create_mcp_tables.sql @@ -97,4 +97,25 @@ CREATE UNIQUE INDEX IF NOT EXISTS "mcp_device_codes_device_code_unique" ON "mcp_ CREATE UNIQUE INDEX IF NOT EXISTS "mcp_device_codes_user_code_unique" ON "mcp_device_codes" ("user_code");--> statement-breakpoint CREATE INDEX IF NOT EXISTS "idx_mcp_api_keys_user_id" ON "mcp_api_keys" ("user_id");--> statement-breakpoint CREATE INDEX IF NOT EXISTS "idx_mcp_audit_user_id" ON "mcp_audit_logs" ("user_id");--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "idx_mcp_audit_created_at" ON "mcp_audit_logs" ("created_at"); +CREATE INDEX IF NOT EXISTS "idx_mcp_audit_created_at" ON "mcp_audit_logs" ("created_at");--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "mcp_auth_requests" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text, + "client_id" text NOT NULL, + "redirect_uri" text, + "scopes" jsonb DEFAULT '[]'::jsonb NOT NULL, + "code_challenge" text, + "code_challenge_method" text, + "state" text, + "resource" text, + "authorization_code" text, + "code_expires_at" timestamp (3) with time zone, + "status" text DEFAULT 'pending' NOT NULL, + "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "mcp_auth_requests" ADD CONSTRAINT "mcp_auth_requests_user_id_User_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "mcp_auth_requests_authorization_code_unique" ON "mcp_auth_requests" ("authorization_code"); diff --git a/apps/calendar/lib/drizzle/schema.ts b/apps/calendar/lib/drizzle/schema.ts index ab4925e6..47d0a951 100644 --- a/apps/calendar/lib/drizzle/schema.ts +++ b/apps/calendar/lib/drizzle/schema.ts @@ -412,6 +412,28 @@ export const mcpSettings = pgTable('mcp_settings', { .notNull(), }) +// --- MCP OAuth Authorization Requests --- +export const mcpAuthRequests = pgTable('mcp_auth_requests', { + id: text('id').primaryKey(), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + clientId: text('client_id').notNull(), + redirectUri: text('redirect_uri'), + scopes: jsonb('scopes').notNull().default([]), + codeChallenge: text('code_challenge'), + codeChallengeMethod: text('code_challenge_method'), + state: text('state'), + resource: text('resource'), + authorizationCode: text('authorization_code').unique(), + codeExpiresAt: timestamp('code_expires_at', { + precision: 3, + withTimezone: true, + }), + status: text('status').notNull().default('pending'), + createdAt: timestamp('created_at', { precision: 3, withTimezone: true }) + .defaultNow() + .notNull(), +}) + // ============================================================ // Relations // ============================================================ @@ -520,3 +542,13 @@ export const mcpAuditLogsRelations = relations(mcpAuditLogs, ({ one }) => ({ export const mcpSettingsRelations = relations(mcpSettings, ({ one }) => ({ user: one(user, { fields: [mcpSettings.userId], references: [user.id] }), })) + +export const mcpAuthRequestsRelations = relations( + mcpAuthRequests, + ({ one }) => ({ + user: one(user, { + fields: [mcpAuthRequests.userId], + references: [user.id], + }), + }), +) From 862ca412f578b03c6411dcf581c5f37c03af689f Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Wed, 29 Jul 2026 15:27:38 +0800 Subject: [PATCH 23/36] fix: support application/x-www-form-urlencoded in OAuth token/authorize/device endpoints --- .gitignore | 3 ++- apps/calendar/app/api/oauth/authorize/route.ts | 9 ++++++++- apps/calendar/app/api/oauth/device/route.ts | 9 ++++++++- apps/calendar/app/api/oauth/token/route.ts | 14 +++++++++++++- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 7f13ec07..a01a8743 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,5 @@ next-env.d.ts .nx/ # session status (not committed) -STATUS.md \ No newline at end of file +STATUS.md +status.md \ No newline at end of file diff --git a/apps/calendar/app/api/oauth/authorize/route.ts b/apps/calendar/app/api/oauth/authorize/route.ts index c294f2fd..a02c3dbc 100644 --- a/apps/calendar/app/api/oauth/authorize/route.ts +++ b/apps/calendar/app/api/oauth/authorize/route.ts @@ -8,7 +8,14 @@ export const runtime = 'nodejs' export async function POST(request: NextRequest) { try { - const body = await request.json().catch(() => ({})) + const ct = request.headers.get('content-type') || '' + let body: Record + if (ct.includes('application/x-www-form-urlencoded')) { + const text = await request.text() + body = Object.fromEntries(new URLSearchParams(text).entries()) + } else { + body = await request.json().catch(() => ({})) + } const userId = body.user_id if (!userId) { diff --git a/apps/calendar/app/api/oauth/device/route.ts b/apps/calendar/app/api/oauth/device/route.ts index 424bb6e8..bdc36e5c 100644 --- a/apps/calendar/app/api/oauth/device/route.ts +++ b/apps/calendar/app/api/oauth/device/route.ts @@ -8,7 +8,14 @@ export const runtime = 'nodejs' export async function POST(request: NextRequest) { try { - const body = await request.json().catch(() => ({})) + const ct = request.headers.get('content-type') || '' + let body: Record + if (ct.includes('application/x-www-form-urlencoded')) { + const text = await request.text() + body = Object.fromEntries(new URLSearchParams(text).entries()) + } else { + body = await request.json().catch(() => ({})) + } const clientId = body.client_id || 'unknown' const clientName = body.client_name || body.client_id || 'Unknown Client' const scopes = body.scope ? body.scope.split(' ') : ['events:read'] diff --git a/apps/calendar/app/api/oauth/token/route.ts b/apps/calendar/app/api/oauth/token/route.ts index 98a27abb..e4858856 100644 --- a/apps/calendar/app/api/oauth/token/route.ts +++ b/apps/calendar/app/api/oauth/token/route.ts @@ -16,9 +16,21 @@ import crypto from 'crypto' export const runtime = 'nodejs' +async function parseBody( + request: NextRequest, +): Promise> { + const ct = request.headers.get('content-type') || '' + if (ct.includes('application/x-www-form-urlencoded')) { + const text = await request.text() + const params = new URLSearchParams(text) + return Object.fromEntries(params.entries()) + } + return request.json().catch(() => ({})) +} + export async function POST(request: NextRequest) { try { - const body = await request.json().catch(() => ({})) + const body = await parseBody(request) const grantType = body.grant_type // --- Device Code Grant --- From c90c5488f088c6a7c7a632768ad881e8d544db0c Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Wed, 29 Jul 2026 15:53:44 +0800 Subject: [PATCH 24/36] fix: restrict MCP tool colors to enum, add English descriptions --- apps/calendar/lib/mcp/countdown-tools.ts | 4 +- apps/calendar/lib/mcp/event-tools.ts | 4 +- apps/calendar/lib/mcp/server.ts | 291 +++++++++++++++++------ 3 files changed, 218 insertions(+), 81 deletions(-) diff --git a/apps/calendar/lib/mcp/countdown-tools.ts b/apps/calendar/lib/mcp/countdown-tools.ts index 0b0d225f..10da008e 100644 --- a/apps/calendar/lib/mcp/countdown-tools.ts +++ b/apps/calendar/lib/mcp/countdown-tools.ts @@ -23,7 +23,7 @@ export async function createCountdown( name: string target_date: string description?: string | null - color?: string | null + color: string icon?: string | null }, ) { @@ -38,7 +38,7 @@ export async function createCountdown( name: encryptField(id, data.name) ?? data.name, targetDate: new Date(data.target_date), description: data.description ? encryptField(id, data.description) : null, - color: data.color ?? null, + color: data.color, icon: data.icon ?? null, }) .returning() diff --git a/apps/calendar/lib/mcp/event-tools.ts b/apps/calendar/lib/mcp/event-tools.ts index def72beb..76159c0d 100644 --- a/apps/calendar/lib/mcp/event-tools.ts +++ b/apps/calendar/lib/mcp/event-tools.ts @@ -84,7 +84,7 @@ export async function createEvent( start_date: string end_date: string is_all_day?: boolean - color?: string | null + color: string category_id?: string | null notification_minutes?: number | null }, @@ -103,7 +103,7 @@ export async function createEvent( startDate: new Date(data.start_date), endDate: new Date(data.end_date), isAllDay: data.is_all_day ?? false, - color: data.color ?? null, + color: data.color, categoryId: data.category_id ?? null, notificationMinutes: data.notification_minutes ?? null, }) diff --git a/apps/calendar/lib/mcp/server.ts b/apps/calendar/lib/mcp/server.ts index d44e559d..d423ba20 100644 --- a/apps/calendar/lib/mcp/server.ts +++ b/apps/calendar/lib/mcp/server.ts @@ -12,6 +12,102 @@ const SCOPE_SETTINGS_READ = 'settings:read' const SCOPE_SETTINGS_WRITE = 'settings:write' const SCOPE_PROFILE_READ = 'profile:read' +const ALLOWED_HEX_COLORS = [ + '#3B82F6', + '#10B981', + '#F59E0B', + '#EF4444', + '#8B5CF6', + '#EC4899', + '#6366F1', + '#FB923C', + '#14B8A6', +] as const + +const HEX_TO_EVENT_BG: Record = { + '#3B82F6': 'bg-[#E6F6FD]', + '#10B981': 'bg-[#E7F8F2]', + '#F59E0B': 'bg-[#FEF5E6]', + '#EF4444': 'bg-[#FFE4E6]', + '#8B5CF6': 'bg-[#F3EEFE]', + '#EC4899': 'bg-[#FCE7F3]', + '#6366F1': 'bg-[#EEF2FF]', + '#FB923C': 'bg-[#FFF0E5]', + '#14B8A6': 'bg-[#E6FAF7]', +} + +const HEX_TO_COUNTDOWN_BG: Record = { + '#3B82F6': 'bg-blue-500', + '#10B981': 'bg-green-500', + '#F59E0B': 'bg-yellow-500', + '#EF4444': 'bg-red-500', + '#8B5CF6': 'bg-purple-500', + '#EC4899': 'bg-pink-500', + '#6366F1': 'bg-indigo-500', + '#FB923C': 'bg-orange-500', + '#14B8A6': 'bg-teal-500', +} + +const HEX_TO_CATEGORY_BG: Record = { + '#3B82F6': 'bg-blue-500', + '#10B981': 'bg-green-500', + '#F59E0B': 'bg-yellow-500', + '#EF4444': 'bg-red-500', + '#8B5CF6': 'bg-purple-500', + '#EC4899': 'bg-pink-500', + '#6366F1': 'bg-indigo-500', + '#FB923C': 'bg-orange-500', + '#14B8A6': 'bg-teal-500', +} + +const LANGUAGE_OPTIONS = [ + 'bn', + 'de', + 'el', + 'en', + 'en-GB', + 'es', + 'fi', + 'fr', + 'hi', + 'is', + 'it', + 'ja', + 'ko', + 'lt', + 'lv', + 'mk', + 'nb', + 'nl', + 'pl', + 'pt', + 'ro', + 'ru', + 'sl', + 'sq', + 'sr', + 'sv', + 'sw', + 'th', + 'tr', + 'uk', + 'vi', + 'yue', + 'zh-CN', + 'zh-HK', + 'zh-TW', +] as const + +const DEFAULT_VIEW_OPTIONS = [ + 'day', + 'week', + 'four-day', + 'month', + 'year', +] as const +const TIME_FORMAT_OPTIONS = ['24h', '12h'] as const +const THEME_OPTIONS = ['light', 'dark', 'system'] as const + function getUserId(authInfo?: AuthInfo): string { const id = authInfo?.extra?.userId as string | undefined if (!id) throw new Error('Unauthorized') @@ -46,13 +142,17 @@ export function createServer(): McpServer { function registerEventTools(server: McpServer): void { server.tool( 'list_events', - '查询日历事件列表,可按时间范围、关键字搜索', + 'List calendar events, filter by date range or keyword', { - start_date: z.string().optional().describe('开始日期 (ISO 8601)'), - end_date: z.string().optional().describe('结束日期 (ISO 8601)'), - query: z.string().optional().describe('搜索关键字'), - page: z.number().optional().default(1).describe('页码'), - limit: z.number().optional().default(50).describe('每页数量 (最大 50)'), + start_date: z.string().optional().describe('Start date (ISO 8601)'), + end_date: z.string().optional().describe('End date (ISO 8601)'), + query: z.string().optional().describe('Search keyword'), + page: z.number().optional().default(1).describe('Page number'), + limit: z + .number() + .optional() + .default(50) + .describe('Items per page (max 50)'), }, async (params, extra) => { const authInfo = extra.authInfo @@ -82,9 +182,9 @@ function registerEventTools(server: McpServer): void { server.tool( 'get_event', - '获取单个事件的详细信息', + 'Get detailed information about a single event', { - event_id: z.string().describe('事件 ID'), + event_id: z.string().describe('Event ID'), }, async (params, extra) => { requireScope(extra.authInfo, SCOPE_EVENTS_READ) @@ -112,15 +212,15 @@ function registerEventTools(server: McpServer): void { server.tool( 'create_event', - '创建新的日历事件', + 'Create a new calendar event', { - title: z.string().describe('事件标题'), - description: z.string().optional().describe('事件描述'), - location: z.string().optional().describe('地点'), - start_date: z.string().describe('开始时间 (ISO 8601)'), - end_date: z.string().describe('结束时间 (ISO 8601)'), + title: z.string().describe('Event title'), + description: z.string().optional().describe('Event description'), + location: z.string().optional().describe('Location'), + start_date: z.string().describe('Start time (ISO 8601)'), + end_date: z.string().describe('End time (ISO 8601)'), is_all_day: z.boolean().optional().default(false), - color: z.string().optional().describe('颜色 (十六进制)'), + color: z.enum(ALLOWED_HEX_COLORS).describe('Color'), category_id: z.string().optional(), notification_minutes: z.number().optional(), }, @@ -129,7 +229,10 @@ function registerEventTools(server: McpServer): void { const userId = getUserId(extra.authInfo) try { const { createEvent } = await import('./event-tools') - const result = await createEvent(userId, params) + const result = await createEvent(userId, { + ...params, + color: HEX_TO_EVENT_BG[params.color], + }) return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], } @@ -144,16 +247,16 @@ function registerEventTools(server: McpServer): void { server.tool( 'update_event', - '修改已有的事件', + 'Update an existing event', { - event_id: z.string().describe('事件 ID'), + event_id: z.string().describe('Event ID'), title: z.string().optional(), description: z.string().optional(), location: z.string().optional(), start_date: z.string().optional(), end_date: z.string().optional(), is_all_day: z.boolean().optional(), - color: z.string().optional(), + color: z.enum(ALLOWED_HEX_COLORS).optional(), category_id: z.string().optional(), notification_minutes: z.number().optional(), }, @@ -162,7 +265,11 @@ function registerEventTools(server: McpServer): void { const userId = getUserId(extra.authInfo) try { const { updateEvent } = await import('./event-tools') - const result = await updateEvent(userId, params.event_id, params) + const eventParams = { ...params } + if (eventParams.color) { + eventParams.color = HEX_TO_EVENT_BG[eventParams.color] + } + const result = await updateEvent(userId, params.event_id, eventParams) if (!result) { return { content: [{ type: 'text' as const, text: 'Event not found' }], @@ -183,9 +290,9 @@ function registerEventTools(server: McpServer): void { server.tool( 'delete_event', - '删除一个事件', + 'Delete an event', { - event_id: z.string().describe('事件 ID'), + event_id: z.string().describe('Event ID'), }, async (params, extra) => { requireScope(extra.authInfo, SCOPE_EVENTS_WRITE) @@ -205,29 +312,34 @@ function registerEventTools(server: McpServer): void { } function registerCategoryTools(server: McpServer): void { - server.tool('list_categories', '查询所有分类', {}, async (_params, extra) => { - requireScope(extra.authInfo, SCOPE_CATEGORIES_READ) - const userId = getUserId(extra.authInfo) - try { - const { listCategories } = await import('./category-tools') - const result = await listCategories(userId) - return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - } - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, + server.tool( + 'list_categories', + 'List all categories', + {}, + async (_params, extra) => { + requireScope(extra.authInfo, SCOPE_CATEGORIES_READ) + const userId = getUserId(extra.authInfo) + try { + const { listCategories } = await import('./category-tools') + const result = await listCategories(userId) + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } } - } - }) + }, + ) server.tool( 'create_category', - '创建新分类', + 'Create a new category', { - name: z.string().describe('分类名称'), - color: z.string().describe('颜色 (十六进制)'), + name: z.string().describe('Category name'), + color: z.enum(ALLOWED_HEX_COLORS).describe('Color'), sort_order: z.number().optional().default(0), }, async (params, extra) => { @@ -235,7 +347,10 @@ function registerCategoryTools(server: McpServer): void { const userId = getUserId(extra.authInfo) try { const { createCategory } = await import('./category-tools') - const result = await createCategory(userId, params) + const result = await createCategory(userId, { + ...params, + color: HEX_TO_CATEGORY_BG[params.color], + }) return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], } @@ -250,11 +365,11 @@ function registerCategoryTools(server: McpServer): void { server.tool( 'update_category', - '修改分类', + 'Update a category', { category_id: z.string(), name: z.string().optional(), - color: z.string().optional(), + color: z.enum(ALLOWED_HEX_COLORS).optional(), sort_order: z.number().optional(), }, async (params, extra) => { @@ -262,7 +377,15 @@ function registerCategoryTools(server: McpServer): void { const userId = getUserId(extra.authInfo) try { const { updateCategory } = await import('./category-tools') - const result = await updateCategory(userId, params.category_id, params) + const categoryParams = { ...params } + if (categoryParams.color) { + categoryParams.color = HEX_TO_CATEGORY_BG[categoryParams.color] + } + const result = await updateCategory( + userId, + params.category_id, + categoryParams, + ) if (!result) { return { content: [{ type: 'text' as const, text: 'Category not found' }], @@ -283,7 +406,7 @@ function registerCategoryTools(server: McpServer): void { server.tool( 'delete_category', - '删除分类', + 'Delete a category', { category_id: z.string(), }, @@ -309,7 +432,7 @@ function registerCategoryTools(server: McpServer): void { function registerCountdownTools(server: McpServer): void { server.tool( 'list_countdowns', - '查询所有倒计时', + 'List all countdowns', {}, async (_params, extra) => { requireScope(extra.authInfo, SCOPE_COUNTDOWNS_READ) @@ -331,12 +454,12 @@ function registerCountdownTools(server: McpServer): void { server.tool( 'create_countdown', - '创建新的倒计时', + 'Create a new countdown', { - name: z.string().describe('倒计时名称'), - target_date: z.string().describe('目标日期 (ISO 8601)'), + name: z.string().describe('Countdown name'), + target_date: z.string().describe('Target date (ISO 8601)'), description: z.string().optional(), - color: z.string().optional(), + color: z.enum(ALLOWED_HEX_COLORS).describe('Color'), icon: z.string().optional(), }, async (params, extra) => { @@ -344,7 +467,10 @@ function registerCountdownTools(server: McpServer): void { const userId = getUserId(extra.authInfo) try { const { createCountdown } = await import('./countdown-tools') - const result = await createCountdown(userId, params) + const result = await createCountdown(userId, { + ...params, + color: HEX_TO_COUNTDOWN_BG[params.color], + }) return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], } @@ -359,13 +485,13 @@ function registerCountdownTools(server: McpServer): void { server.tool( 'update_countdown', - '修改倒计时', + 'Update a countdown', { countdown_id: z.string(), name: z.string().optional(), target_date: z.string().optional(), description: z.string().optional(), - color: z.string().optional(), + color: z.enum(ALLOWED_HEX_COLORS).optional(), icon: z.string().optional(), }, async (params, extra) => { @@ -373,10 +499,14 @@ function registerCountdownTools(server: McpServer): void { const userId = getUserId(extra.authInfo) try { const { updateCountdown } = await import('./countdown-tools') + const countdownParams = { ...params } + if (countdownParams.color) { + countdownParams.color = HEX_TO_COUNTDOWN_BG[countdownParams.color] + } const result = await updateCountdown( userId, params.countdown_id, - params, + countdownParams, ) if (!result) { return { @@ -398,7 +528,7 @@ function registerCountdownTools(server: McpServer): void { server.tool( 'delete_countdown', - '删除倒计时', + 'Delete a countdown', { countdown_id: z.string(), }, @@ -422,33 +552,40 @@ function registerCountdownTools(server: McpServer): void { } function registerSettingsTools(server: McpServer): void { - server.tool('get_settings', '获取用户设置', {}, async (_params, extra) => { - requireScope(extra.authInfo, SCOPE_SETTINGS_READ) - const userId = getUserId(extra.authInfo) - try { - const { getSettings } = await import('./settings-tools') - const result = await getSettings(userId) - return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - } - } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, + server.tool( + 'get_settings', + 'Get user settings', + {}, + async (_params, extra) => { + requireScope(extra.authInfo, SCOPE_SETTINGS_READ) + const userId = getUserId(extra.authInfo) + try { + const { getSettings } = await import('./settings-tools') + const result = await getSettings(userId) + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + } + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true, + } } - } - }) + }, + ) server.tool( 'update_settings', - '更新用户设置', + 'Update user settings', { - language: z.string().optional(), + language: z.enum(LANGUAGE_OPTIONS).optional(), timezone: z.string().optional(), - default_view: z.string().optional(), - time_format: z.string().optional(), - first_day_of_week: z.number().optional(), - theme: z.string().optional(), + default_view: z.enum(DEFAULT_VIEW_OPTIONS).optional(), + time_format: z.enum(TIME_FORMAT_OPTIONS).optional(), + first_day_of_week: z + .union([z.literal(0), z.literal(1), z.literal(6)]) + .optional(), + theme: z.enum(THEME_OPTIONS).optional(), enable_shortcuts: z.boolean().optional(), }, async (params, extra) => { @@ -473,7 +610,7 @@ function registerSettingsTools(server: McpServer): void { function registerProfileTool(server: McpServer): void { server.tool( 'get_profile', - '获取当前用户信息(名称、邮箱等)', + 'Get current user info (name, email, etc.)', {}, async (_params, extra) => { requireScope(extra.authInfo, SCOPE_PROFILE_READ) From 785b3af46b6ccd14457ddad437f9038ffca1f52e Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Thu, 30 Jul 2026 17:20:03 +0800 Subject: [PATCH 25/36] refactor: replace hand-rolled UI with @zntr/ui library components in MCPSettings --- .../app/settings/mcp/mcp-settings.tsx | 201 +++++++++--------- 1 file changed, 95 insertions(+), 106 deletions(-) diff --git a/apps/calendar/components/app/settings/mcp/mcp-settings.tsx b/apps/calendar/components/app/settings/mcp/mcp-settings.tsx index f88b8c29..76a7ebf8 100644 --- a/apps/calendar/components/app/settings/mcp/mcp-settings.tsx +++ b/apps/calendar/components/app/settings/mcp/mcp-settings.tsx @@ -11,7 +11,12 @@ import { DialogTitle, DialogTrigger, } from '@zntr/ui/dialog' - +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@zntr/ui/tabs' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@zntr/ui/card' +import { Input } from '@zntr/ui/input' +import { Checkbox } from '@zntr/ui/checkbox' +import { Badge } from '@zntr/ui/badge' +import { Spinner } from '@zntr/ui/spinner' import { Key, @@ -19,7 +24,6 @@ import { ClipboardCopy, Trash2, Plus, - Loader2, CheckCircle, XCircle, Bot, @@ -39,8 +43,6 @@ const ALL_SCOPE_OPTIONS = [ { value: 'profile:read', label: 'profile:read' }, ] -type Tab = 'overview' | 'api-keys' | 'oauth' | 'audit-logs' - interface ApiKey { id: string name: string @@ -66,14 +68,13 @@ interface AuditLog { authType: string action: string resourceType: string | null + resourceId: string | null success: boolean errorMessage: string | null createdAt: string } export default function MCPSettings() { - const [activeTab, setActiveTab] = useState('overview') - return (
@@ -84,28 +85,18 @@ export default function MCPSettings() { Let AI agents access and manage your calendar data securely.

-
- {(['overview', 'api-keys', 'oauth', 'audit-logs'] as Tab[]).map( - (tab) => ( - - ), - )} -
- - {activeTab === 'overview' && } - {activeTab === 'api-keys' && } - {activeTab === 'oauth' && } - {activeTab === 'audit-logs' && } + + + Overview + API Keys + Authorized Apps + Audit Logs + + + + + +
) } @@ -135,62 +126,72 @@ function MCPOverview() { if (loading) { return (
- +
) } return (
-
-
-
- -

- Allow AI agents to connect to your calendar via MCP protocol -

+ + +
+
+ Enable MCP + + Allow AI agents to connect to your calendar via MCP protocol + +
+
- -
-
- -
-

MCP Endpoint

-
- - {typeof window !== 'undefined' - ? `${window.location.origin}/api/mcp` - : '/api/mcp'} - - -
-

- Configure your AI agent to connect to this endpoint using Bearer - authentication with an API key or OAuth token. -

-
- -
-

Quick Start

-
    -
  1. Create an API key in the API Keys tab
  2. -
  3. Copy the endpoint URL above
  4. -
  5. - Configure your AI agent (Claude, ChatGPT, etc.) with the endpoint - and API key -
  6. -
  7. The agent can now query and manage your calendar
  8. -
-
+ + + + + + MCP Endpoint + + +
+ + {typeof window !== 'undefined' + ? `${window.location.origin}/api/mcp` + : '/api/mcp'} + + +
+ + Configure your AI agent to connect to this endpoint using Bearer + authentication with an API key or OAuth token. + +
+
+ + + + Quick Start + + +
    +
  1. Create an API key in the API Keys tab
  2. +
  3. Copy the endpoint URL above
  4. +
  5. + Configure your AI agent (Claude, ChatGPT, etc.) with the endpoint + and API key +
  6. +
  7. The agent can now query and manage your calendar
  8. +
+
+
) } @@ -267,7 +268,7 @@ function MCPApiKeys() { if (loading) { return (
- +
) } @@ -330,10 +331,10 @@ function MCPApiKeys() { Create API Key
-
- - + + setNewKeyName(e.target.value)} @@ -347,10 +348,9 @@ function MCPApiKeys() { key={opt.value} className="flex items-center gap-2 text-sm p-2 rounded hover:bg-muted cursor-pointer" > - { + onCheckedChange={() => { setNewKeyScopes((prev) => prev.includes(opt.value) ? prev.filter((s) => s !== opt.value) @@ -391,9 +391,7 @@ function MCPApiKeys() { {key.name} {!key.isActive && ( - - Revoked - + Revoked )}
@@ -419,13 +417,12 @@ function MCPApiKeys() { key={opt.value} className="flex items-center gap-2 text-sm p-2 rounded hover:bg-muted cursor-pointer" > - toggleScope(opt.value)} + onCheckedChange={() => toggleScope(opt.value)} /> {opt.label} @@ -457,12 +454,9 @@ function MCPApiKeys() {
{key.scopes.map((scope) => ( - + {scope} - + ))}
@@ -500,7 +494,7 @@ function MCPOAuthApps() { if (loading) { return (
- +
) } @@ -532,12 +526,9 @@ function MCPOAuthApps() {
{app.scopes.map((scope) => ( - + {scope} - + ))}

@@ -574,7 +565,7 @@ function MCPAuditLogs() { if (loading) { return (

- +
) } @@ -603,9 +594,7 @@ function MCPAuditLogs() {
{log.action} - - {log.authType} - + {log.authType} {log.resourceType && ( {log.resourceType} From 198c20a4d45900742138eebf7f466d7596b64f28 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Thu, 30 Jul 2026 17:23:39 +0800 Subject: [PATCH 26/36] refactor: move OAuth auth check to proxy.ts, simplify authorize page UI --- apps/calendar/app/oauth/authorize/page.tsx | 129 ++++++--------------- apps/calendar/proxy.ts | 6 +- 2 files changed, 42 insertions(+), 93 deletions(-) diff --git a/apps/calendar/app/oauth/authorize/page.tsx b/apps/calendar/app/oauth/authorize/page.tsx index c009cd1c..4f086ae1 100644 --- a/apps/calendar/app/oauth/authorize/page.tsx +++ b/apps/calendar/app/oauth/authorize/page.tsx @@ -3,23 +3,22 @@ import { Suspense, useState, useEffect } from 'react' import { useSearchParams } from 'next/navigation' import { Button } from '@zntr/ui/button' -import { Loader2, CheckCircle, XCircle, Bot } from 'lucide-react' +import { Spinner } from '@zntr/ui/spinner' +import { Avatar, AvatarImage, AvatarFallback } from '@zntr/ui/avatar' +import { CheckCircle, XCircle } from 'lucide-react' +import { authClient } from '@/lib/auth/client' type Flow = 'device_code' | 'auth_code' | null function AuthorizeForm() { const searchParams = useSearchParams() + const { data: session, isPending: sessionLoading } = authClient.useSession() const [status, setStatus] = useState< - 'checking' | 'ready' | 'authorizing' | 'success' | 'error' - >('checking') + 'ready' | 'authorizing' | 'success' | 'error' + >('ready') const [errorMsg, setErrorMsg] = useState('') const [flow, setFlow] = useState(null) - const [clientInfo, setClientInfo] = useState({ - name: 'AI Agent', - scopes: [] as string[], - resource: '', - }) useEffect(() => { const responseType = searchParams.get('response_type') @@ -27,24 +26,13 @@ function AuthorizeForm() { if (responseType === 'code') { setFlow('auth_code') - const scopes = (searchParams.get('scope') ?? '') - .split(' ') - .filter(Boolean) - setClientInfo({ - name: - (searchParams.get('client_id') ?? 'AI Agent').slice(0, 12) + '...', - scopes, - resource: searchParams.get('resource') ?? '', - }) if (!searchParams.get('redirect_uri')) { setStatus('error') setErrorMsg('Missing redirect_uri parameter.') return } - setStatus('ready') } else if (deviceUserCode) { setFlow('device_code') - setStatus('ready') } else { setStatus('error') setErrorMsg('Invalid authorization request.') @@ -55,12 +43,6 @@ function AuthorizeForm() { setStatus('authorizing') try { - const sessionRes = await fetch('/api/auth/get-session') - const session = await sessionRes.json() - if (!session?.user?.id) { - throw new Error('Not authenticated') - } - if (flow === 'device_code') { const code = searchParams.get('code') const res = await fetch('/api/oauth/authorize', { @@ -68,7 +50,7 @@ function AuthorizeForm() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_code: code, - user_id: session.user.id, + user_id: session?.user?.id, }), }) if (!res.ok) { @@ -93,7 +75,7 @@ function AuthorizeForm() { code_challenge_method: searchParams.get('code_challenge_method'), state, resource: searchParams.get('resource'), - user_id: session.user.id, + user_id: session?.user?.id, }), }) @@ -114,10 +96,10 @@ function AuthorizeForm() { } } - if (status === 'checking') { + if (sessionLoading) { return (
- +
) } @@ -154,69 +136,32 @@ function AuthorizeForm() { return (
-
-
-
- -
- -
-

Authorize Application

-

- {flow === 'auth_code' - ? `"${clientInfo.name}" is requesting access to your calendar data.` - : 'An AI agent is requesting access to your calendar data.'} -

-
- - {flow === 'auth_code' && clientInfo.scopes.length > 0 && ( -
-

- Requested permissions: -

-
- {clientInfo.scopes.map((scope) => ( - - {scope} - - ))} -
-
- )} - - {flow === 'auth_code' && clientInfo.resource && ( -
-

- Resource:{' '} - {clientInfo.resource} -

-
- )} - -
- This will allow the application to read and manage your calendar - data based on the permissions configured in your MCP settings. -
- -
- - -
+
+
+ + + + {(session?.user?.name ?? 'U').charAt(0).toUpperCase()} + +
+ +

+ An application is requesting access to your calendar data. +
+ Please review and confirm if you trust this application. +

+ +
) @@ -227,7 +172,7 @@ export default function OAuthAuthorizePage() { - +
} > diff --git a/apps/calendar/proxy.ts b/apps/calendar/proxy.ts index caa9b63b..fbfb068d 100644 --- a/apps/calendar/proxy.ts +++ b/apps/calendar/proxy.ts @@ -24,9 +24,13 @@ export default function proxy(request: NextRequest) { return NextResponse.redirect(new URL('/sign-up', request.url)) } + if (!isLoggedIn && pathname.startsWith('/oauth/authorize')) { + return NextResponse.redirect(new URL('/sign-in', request.url)) + } + return NextResponse.next() } export const config = { - matcher: ['/', '/landing', '/app/:path*', '/sign-in', '/sign-up'], + matcher: ['/', '/landing', '/app/:path*', '/sign-in', '/sign-up', '/oauth/authorize'], } From 2d5a8ce4103e69a5ac591a3b3c6a6d8bce043c74 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Thu, 30 Jul 2026 17:34:44 +0800 Subject: [PATCH 27/36] feat: show redirect URI and scopes on OAuth authorize page, validate HTTPS redirect --- .../calendar/app/api/oauth/authorize/route.ts | 28 ++++ apps/calendar/app/oauth/authorize/page.tsx | 138 ++++++++++++++---- 2 files changed, 136 insertions(+), 30 deletions(-) diff --git a/apps/calendar/app/api/oauth/authorize/route.ts b/apps/calendar/app/api/oauth/authorize/route.ts index a02c3dbc..77a3743f 100644 --- a/apps/calendar/app/api/oauth/authorize/route.ts +++ b/apps/calendar/app/api/oauth/authorize/route.ts @@ -79,6 +79,34 @@ export async function POST(request: NextRequest) { ) } + try { + const uri = new URL(redirectUri) + if ( + uri.protocol !== 'https:' && + !( + uri.hostname === 'localhost' || + uri.hostname === '127.0.0.1' + ) + ) { + return NextResponse.json( + { + error: 'invalid_request', + error_description: + 'redirect_uri must use HTTPS (except localhost)', + }, + { status: 400 }, + ) + } + } catch { + return NextResponse.json( + { + error: 'invalid_request', + error_description: 'Invalid redirect_uri', + }, + { status: 400 }, + ) + } + const authorizationCode = crypto.randomUUID() const db = await getDb() diff --git a/apps/calendar/app/oauth/authorize/page.tsx b/apps/calendar/app/oauth/authorize/page.tsx index 4f086ae1..56e9eb51 100644 --- a/apps/calendar/app/oauth/authorize/page.tsx +++ b/apps/calendar/app/oauth/authorize/page.tsx @@ -5,7 +5,9 @@ import { useSearchParams } from 'next/navigation' import { Button } from '@zntr/ui/button' import { Spinner } from '@zntr/ui/spinner' import { Avatar, AvatarImage, AvatarFallback } from '@zntr/ui/avatar' -import { CheckCircle, XCircle } from 'lucide-react' +import { Badge } from '@zntr/ui/badge' +import { Card, CardContent } from '@zntr/ui/card' +import { CheckCircle, XCircle, ExternalLink, ShieldAlert } from 'lucide-react' import { authClient } from '@/lib/auth/client' type Flow = 'device_code' | 'auth_code' | null @@ -19,6 +21,9 @@ function AuthorizeForm() { >('ready') const [errorMsg, setErrorMsg] = useState('') const [flow, setFlow] = useState(null) + const [clientId, setClientId] = useState('') + const [redirectUri, setRedirectUri] = useState('') + const [scopes, setScopes] = useState([]) useEffect(() => { const responseType = searchParams.get('response_type') @@ -26,6 +31,11 @@ function AuthorizeForm() { if (responseType === 'code') { setFlow('auth_code') + setClientId(searchParams.get('client_id') ?? '') + setRedirectUri(searchParams.get('redirect_uri') ?? '') + setScopes( + (searchParams.get('scope') ?? '').split(' ').filter(Boolean), + ) if (!searchParams.get('redirect_uri')) { setStatus('error') setErrorMsg('Missing redirect_uri parameter.') @@ -60,9 +70,6 @@ function AuthorizeForm() { setStatus('success') setTimeout(() => window.close(), 2000) } else if (flow === 'auth_code') { - const redirectUri = searchParams.get('redirect_uri')! - const state = searchParams.get('state') ?? '' - const res = await fetch('/api/oauth/authorize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -73,7 +80,7 @@ function AuthorizeForm() { scope: searchParams.get('scope'), code_challenge: searchParams.get('code_challenge'), code_challenge_method: searchParams.get('code_challenge_method'), - state, + state: searchParams.get('state'), resource: searchParams.get('resource'), user_id: session?.user?.id, }), @@ -85,10 +92,11 @@ function AuthorizeForm() { } const data = await res.json() - const redirectUrl = new URL(redirectUri) - redirectUrl.searchParams.set('code', data.code) - if (state) redirectUrl.searchParams.set('state', state) - window.location.href = redirectUrl.toString() + const url = new URL(redirectUri) + url.searchParams.set('code', data.code) + const state = searchParams.get('state') + if (state) url.searchParams.set('state', state) + window.location.href = url.toString() } } catch (err) { setStatus('error') @@ -135,33 +143,103 @@ function AuthorizeForm() { } return ( -
-
-
- +
+
+
+ - + {(session?.user?.name ?? 'U').charAt(0).toUpperCase()} +
+

{session?.user?.name}

+

+ Authorize access to your calendar +

+
-

- An application is requesting access to your calendar data. -
- Please review and confirm if you trust this application. -

- - + {flow === 'auth_code' && ( + + +
+

+ Application +

+

+ {clientId} +

+
+ +
+

+ Redirect URI +

+
+ + + {redirectUri} + +
+
+ + {scopes.length > 0 && ( +
+

+ Permissions +

+
+ {scopes.map((scope) => ( + + {scope} + + ))} +
+
+ )} +
+
+ )} + + {flow === 'device_code' && ( + + +

+ You are authorizing a device to access your calendar. +

+
+
+ )} + +
+ +

+ Make sure you trust this application. Authorizing will grant + access based on the permissions shown above. You can revoke + access anytime from your MCP settings. +

+
+ +
+ + +
) From 66318f387a01b6f578c2c7eba546048eb218433e Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Thu, 30 Jul 2026 17:53:24 +0800 Subject: [PATCH 28/36] fix: remove thick ring border from OAuth page avatar --- apps/calendar/app/oauth/authorize/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/calendar/app/oauth/authorize/page.tsx b/apps/calendar/app/oauth/authorize/page.tsx index 56e9eb51..c8f02ba3 100644 --- a/apps/calendar/app/oauth/authorize/page.tsx +++ b/apps/calendar/app/oauth/authorize/page.tsx @@ -146,7 +146,7 @@ function AuthorizeForm() {
- + {(session?.user?.name ?? 'U').charAt(0).toUpperCase()} From 834d451cf3c6fc11e16cbb3c87a03271b50ccc6e Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Thu, 30 Jul 2026 22:46:17 +0800 Subject: [PATCH 29/36] fix: decrypt MCP tool responses, add countdown pagination, improve audit and rate limiter - Fix listCategories/listCountdowns/create/update returning encrypted fields - Add pagination to listCountdowns matching spec (50/page) - Remove duplicated HEX_TO_BG color maps, pass through hex colors directly - Extract respond/respondError/respondMessage helpers, eliminate 16x try-catch boilerplate - Remove dead hasScope/requireScope in types.ts - Rename getFullUserInfo -> getUserNameAndEmail - Fix API key prefix display to spec (zc_ + last 4 chars) - Add per-tool action audit logging and OAuth discovery fields in 401 - Replace in-memory rate limiter Map with DB-backed query --- apps/calendar/app/api/mcp/settings/route.ts | 6 +- apps/calendar/app/api/oauth/token/route.ts | 4 +- apps/calendar/lib/mcp/auth.ts | 12 +- apps/calendar/lib/mcp/category-tools.ts | 15 +- apps/calendar/lib/mcp/countdown-tools.ts | 58 +++- apps/calendar/lib/mcp/handler.ts | 42 ++- apps/calendar/lib/mcp/rate-limiter.ts | 57 ++-- apps/calendar/lib/mcp/server.ts | 325 ++++++-------------- apps/calendar/lib/mcp/types.ts | 10 - 9 files changed, 207 insertions(+), 322 deletions(-) diff --git a/apps/calendar/app/api/mcp/settings/route.ts b/apps/calendar/app/api/mcp/settings/route.ts index d0fb88a2..27324e32 100644 --- a/apps/calendar/app/api/mcp/settings/route.ts +++ b/apps/calendar/app/api/mcp/settings/route.ts @@ -19,14 +19,14 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const body = await request.json().catch(() => ({})) - const { enabled, rate_limit_rpm } = body as { + const { enabled, rateLimitRpm } = body as { enabled?: boolean - rate_limit_rpm?: number + rateLimitRpm?: number } const updated = await updateMcpSettings(user.id, { enabled, - rateLimitRpm: rate_limit_rpm, + rateLimitRpm, }) return NextResponse.json({ settings: updated }) diff --git a/apps/calendar/app/api/oauth/token/route.ts b/apps/calendar/app/api/oauth/token/route.ts index e4858856..d51c3a5b 100644 --- a/apps/calendar/app/api/oauth/token/route.ts +++ b/apps/calendar/app/api/oauth/token/route.ts @@ -10,7 +10,7 @@ import { generateAccessToken, generateRefreshToken, hashToken, - getFullUserInfo, + getUserNameAndEmail, } from '@/lib/mcp/auth' import crypto from 'crypto' @@ -218,7 +218,7 @@ async function issueTokens( ) { const accessToken = generateAccessToken() const refreshToken = generateRefreshToken() - const userInfo = await getFullUserInfo(userId) + const userInfo = await getUserNameAndEmail(userId) const db = await getDb() await db.insert(mcpTokens).values({ diff --git a/apps/calendar/lib/mcp/auth.ts b/apps/calendar/lib/mcp/auth.ts index 9690cf23..2f38e038 100644 --- a/apps/calendar/lib/mcp/auth.ts +++ b/apps/calendar/lib/mcp/auth.ts @@ -6,20 +6,18 @@ import bcrypt from 'bcryptjs' import { type McpAuthUser, ALL_SCOPES } from './types' const KEY_PREFIX = 'zc_' -const KEY_PREFIX_LENGTH = 12 export async function verifyApiKey(key: string): Promise { if (!key.startsWith(KEY_PREFIX)) return null + const keyPrefix = KEY_PREFIX + key.slice(-4) + const db = await getDb() const keys = await db .select() .from(mcpApiKeys) .where( - and( - eq(mcpApiKeys.isActive, true), - eq(mcpApiKeys.keyPrefix, key.slice(0, KEY_PREFIX_LENGTH)), - ), + and(eq(mcpApiKeys.isActive, true), eq(mcpApiKeys.keyPrefix, keyPrefix)), ) for (const row of keys) { @@ -79,7 +77,7 @@ export async function verifyOAuthToken( } } -export async function getFullUserInfo( +export async function getUserNameAndEmail( userId: string, ): Promise<{ email: string; name: string }> { const { user } = await import('@/lib/drizzle/schema') @@ -110,7 +108,7 @@ export async function generateApiKey( ): Promise { const raw = crypto.randomBytes(32).toString('hex') const key = `${KEY_PREFIX}${raw}` - const prefix = key.slice(0, KEY_PREFIX_LENGTH) + const prefix = KEY_PREFIX + raw.slice(-4) const hash = await bcrypt.hash(key, 10) const { mcpApiKeys: keysTable } = await import('@/lib/drizzle/schema') diff --git a/apps/calendar/lib/mcp/category-tools.ts b/apps/calendar/lib/mcp/category-tools.ts index d2415941..57d7b0d1 100644 --- a/apps/calendar/lib/mcp/category-tools.ts +++ b/apps/calendar/lib/mcp/category-tools.ts @@ -1,7 +1,7 @@ import { getDb } from '@/lib/drizzle/client' import { calendarCategories } from '@/lib/drizzle/schema' import { eq, and } from 'drizzle-orm' -import { encryptField } from '@/lib/field-crypto' +import { encryptField, decryptField } from '@/lib/field-crypto' import crypto from 'crypto' export async function listCategories(userId: string) { @@ -13,7 +13,7 @@ export async function listCategories(userId: string) { return rows.map((cat) => ({ ...cat, - name: cat.name, + name: decryptField(cat.id, cat.name) ?? cat.name, })) } @@ -35,7 +35,10 @@ export async function createCategory( }) .returning() - return row + return { + ...row, + name: decryptField(row.id, row.name) ?? row.name, + } } export async function updateCategory( @@ -62,7 +65,11 @@ export async function updateCategory( ) .returning() - return row ?? null + if (!row) return null + return { + ...row, + name: decryptField(row.id, row.name) ?? row.name, + } } export async function deleteCategory( diff --git a/apps/calendar/lib/mcp/countdown-tools.ts b/apps/calendar/lib/mcp/countdown-tools.ts index 10da008e..543300f1 100644 --- a/apps/calendar/lib/mcp/countdown-tools.ts +++ b/apps/calendar/lib/mcp/countdown-tools.ts @@ -1,20 +1,47 @@ import { getDb } from '@/lib/drizzle/client' import { countdowns } from '@/lib/drizzle/schema' -import { eq, and } from 'drizzle-orm' -import { encryptField } from '@/lib/field-crypto' +import { eq, and, desc, sql } from 'drizzle-orm' +import { encryptField, decryptField } from '@/lib/field-crypto' import crypto from 'crypto' -export async function listCountdowns(userId: string) { +export async function listCountdowns( + userId: string, + page: number = 1, + limit: number = 50, +) { const db = await getDb() - const rows = await db - .select() - .from(countdowns) - .where(eq(countdowns.userId, userId)) + const offset = (page - 1) * limit - return rows.map((c) => ({ + const [rows, countResult] = await Promise.all([ + db + .select() + .from(countdowns) + .where(eq(countdowns.userId, userId)) + .orderBy(desc(countdowns.createdAt)) + .limit(limit) + .offset(offset), + db + .select({ count: sql`count(*)` }) + .from(countdowns) + .where(eq(countdowns.userId, userId)), + ]) + + const items = rows.map((c) => ({ ...c, - name: c.name, + name: decryptField(c.id, c.name) ?? c.name, + description: decryptField(c.id, c.description), })) + const total = countResult[0]?.count ?? 0 + + return { + items, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + } } export async function createCountdown( @@ -43,7 +70,11 @@ export async function createCountdown( }) .returning() - return row + return { + ...row, + name: decryptField(row.id, row.name) ?? row.name, + description: decryptField(row.id, row.description), + } } export async function updateCountdown( @@ -75,7 +106,12 @@ export async function updateCountdown( .where(and(eq(countdowns.id, countdownId), eq(countdowns.userId, userId))) .returning() - return row ?? null + if (!row) return null + return { + ...row, + name: decryptField(row.id, row.name) ?? row.name, + description: decryptField(row.id, row.description), + } } export async function deleteCountdown( diff --git a/apps/calendar/lib/mcp/handler.ts b/apps/calendar/lib/mcp/handler.ts index b1799f49..829c973f 100644 --- a/apps/calendar/lib/mcp/handler.ts +++ b/apps/calendar/lib/mcp/handler.ts @@ -7,15 +7,30 @@ import { getMcpSettings } from './settings' import { checkRateLimit } from './rate-limiter' import { McpAuthError } from './types' +async function parseToolName(request: Request): Promise { + try { + const cloned = request.clone() + const text = await cloned.text() + const body = JSON.parse(text) + return body?.params?.name ?? 'mcp_request' + } catch { + return 'mcp_request' + } +} + export async function handleMcpRequest(request: Request): Promise { try { const auth = await getMcpAuth(request) if (!auth) { + const baseUrl = process.env.BETTER_AUTH_URL || 'http://localhost:3000' return Response.json( { error: 'unauthorized', auth_required: 'Bearer', - authorization_endpoint: `${process.env.BETTER_AUTH_URL || 'http://localhost:3000'}/oauth/authorize`, + authorization_endpoint: `${baseUrl}/oauth/authorize`, + issuer: baseUrl, + token_endpoint: `${baseUrl}/api/oauth/token`, + device_authorization_endpoint: `${baseUrl}/api/oauth/device`, }, { status: 401, headers: { 'WWW-Authenticate': 'Bearer' } }, ) @@ -29,17 +44,24 @@ export async function handleMcpRequest(request: Request): Promise { ) } + const toolName = await parseToolName(request) + const clientIp = + request.headers.get('x-forwarded-for') ?? + request.headers.get('x-real-ip') ?? + '' + const userAgent = request.headers.get('user-agent') ?? '' + const rateLimit = await checkRateLimit(auth.user.userId) if (!rateLimit.allowed) { await logAudit({ userId: auth.user.userId, authType: auth.user.authType, keyId: auth.user.keyId, - action: 'rate_limited', + action: toolName, success: false, errorMessage: 'Rate limit exceeded', - ipAddress: request.headers.get('x-forwarded-for') ?? '', - userAgent: request.headers.get('user-agent') ?? '', + ipAddress: clientIp, + userAgent, }) return Response.json( { @@ -61,12 +83,6 @@ export async function handleMcpRequest(request: Request): Promise { }, } - const clientIp = - request.headers.get('x-forwarded-for') ?? - request.headers.get('x-real-ip') ?? - '' - const userAgent = request.headers.get('user-agent') ?? '' - const server = createServer() const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true, @@ -82,7 +98,8 @@ export async function handleMcpRequest(request: Request): Promise { userId: auth.user.userId, authType: auth.user.authType, keyId: auth.user.keyId, - action: 'mcp_request', + action: toolName, + resourceType: toolName.split('_')[0], success: response.status < 500, ipAddress: clientIp, userAgent, @@ -94,7 +111,8 @@ export async function handleMcpRequest(request: Request): Promise { userId: auth.user.userId, authType: auth.user.authType, keyId: auth.user.keyId, - action: 'mcp_request', + action: toolName, + resourceType: toolName.split('_')[0], success: false, errorMessage: String(mcpErr), ipAddress: clientIp, diff --git a/apps/calendar/lib/mcp/rate-limiter.ts b/apps/calendar/lib/mcp/rate-limiter.ts index 9c8bd930..db02053a 100644 --- a/apps/calendar/lib/mcp/rate-limiter.ts +++ b/apps/calendar/lib/mcp/rate-limiter.ts @@ -1,19 +1,8 @@ +import { getDb } from '@/lib/drizzle/client' +import { mcpAuditLogs } from '@/lib/drizzle/schema' +import { eq, and, gte, sql } from 'drizzle-orm' import { getMcpSettings } from './settings' -interface RateLimitEntry { - count: number - resetAt: number -} - -const store = new Map() - -setInterval(() => { - const now = Date.now() - for (const [key, entry] of store) { - if (entry.resetAt < now) store.delete(key) - } -}, 60_000) - export async function checkRateLimit(userId: string): Promise<{ allowed: boolean remaining: number @@ -21,28 +10,26 @@ export async function checkRateLimit(userId: string): Promise<{ }> { const settings = await getMcpSettings(userId) const maxRpm = settings.rateLimitRpm - const now = Date.now() - const windowKey = `${userId}:${Math.floor(now / 60_000)}` - - const entry = store.get(windowKey) - - if (!entry || entry.resetAt < now) { - store.set(windowKey, { - count: 1, - resetAt: now + 60_000, - }) - return { allowed: true, remaining: maxRpm - 1, resetAt: now + 60_000 } + const windowStart = new Date(now - 60_000) + + const db = await getDb() + const [row] = await db + .select({ count: sql`count(*)` }) + .from(mcpAuditLogs) + .where( + and( + eq(mcpAuditLogs.userId, userId), + gte(mcpAuditLogs.createdAt, windowStart), + ), + ) + + const count = row?.count ?? 0 + const resetAt = Math.ceil(now / 60_000) * 60_000 + + if (count >= maxRpm) { + return { allowed: false, remaining: 0, resetAt } } - if (entry.count >= maxRpm) { - return { allowed: false, remaining: 0, resetAt: entry.resetAt } - } - - entry.count++ - return { - allowed: true, - remaining: maxRpm - entry.count, - resetAt: entry.resetAt, - } + return { allowed: true, remaining: maxRpm - count - 1, resetAt } } diff --git a/apps/calendar/lib/mcp/server.ts b/apps/calendar/lib/mcp/server.ts index d423ba20..d9f6af14 100644 --- a/apps/calendar/lib/mcp/server.ts +++ b/apps/calendar/lib/mcp/server.ts @@ -24,42 +24,6 @@ const ALLOWED_HEX_COLORS = [ '#14B8A6', ] as const -const HEX_TO_EVENT_BG: Record = { - '#3B82F6': 'bg-[#E6F6FD]', - '#10B981': 'bg-[#E7F8F2]', - '#F59E0B': 'bg-[#FEF5E6]', - '#EF4444': 'bg-[#FFE4E6]', - '#8B5CF6': 'bg-[#F3EEFE]', - '#EC4899': 'bg-[#FCE7F3]', - '#6366F1': 'bg-[#EEF2FF]', - '#FB923C': 'bg-[#FFF0E5]', - '#14B8A6': 'bg-[#E6FAF7]', -} - -const HEX_TO_COUNTDOWN_BG: Record = { - '#3B82F6': 'bg-blue-500', - '#10B981': 'bg-green-500', - '#F59E0B': 'bg-yellow-500', - '#EF4444': 'bg-red-500', - '#8B5CF6': 'bg-purple-500', - '#EC4899': 'bg-pink-500', - '#6366F1': 'bg-indigo-500', - '#FB923C': 'bg-orange-500', - '#14B8A6': 'bg-teal-500', -} - -const HEX_TO_CATEGORY_BG: Record = { - '#3B82F6': 'bg-blue-500', - '#10B981': 'bg-green-500', - '#F59E0B': 'bg-yellow-500', - '#EF4444': 'bg-red-500', - '#8B5CF6': 'bg-purple-500', - '#EC4899': 'bg-pink-500', - '#6366F1': 'bg-indigo-500', - '#FB923C': 'bg-orange-500', - '#14B8A6': 'bg-teal-500', -} - const LANGUAGE_OPTIONS = [ 'bn', 'de', @@ -114,16 +78,27 @@ function getUserId(authInfo?: AuthInfo): string { return id } -function hasScope(authInfo: AuthInfo | undefined, scope: string): boolean { - return authInfo?.scopes?.includes(scope) ?? false -} - function requireScope(authInfo: AuthInfo | undefined, scope: string): void { - if (!hasScope(authInfo, scope)) { + if (!authInfo?.scopes?.includes(scope)) { throw new Error(`Missing required scope: ${scope}`) } } +function respond(data: unknown) { + return { content: [{ type: 'text' as const, text: JSON.stringify(data) }] } +} + +function respondError(err: unknown) { + return { + content: [{ type: 'text' as const, text: `Error: ${err}` }], + isError: true as const, + } +} + +function respondMessage(msg: string) { + return { content: [{ type: 'text' as const, text: msg }] } +} + export function createServer(): McpServer { const server = new McpServer( { name: 'One Calendar MCP', version: '1.0.0' }, @@ -155,27 +130,22 @@ function registerEventTools(server: McpServer): void { .describe('Items per page (max 50)'), }, async (params, extra) => { - const authInfo = extra.authInfo - requireScope(authInfo, SCOPE_EVENTS_READ) - const userId = getUserId(authInfo) + requireScope(extra.authInfo, SCOPE_EVENTS_READ) + const userId = getUserId(extra.authInfo) try { const { listEvents } = await import('./event-tools') - const result = await listEvents( - userId, - params.start_date, - params.end_date, - params.query, - params.page ?? 1, - Math.min(params.limit ?? 50, 50), + return respond( + await listEvents( + userId, + params.start_date, + params.end_date, + params.query, + params.page ?? 1, + Math.min(params.limit ?? 50, 50), + ), ) - return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - } } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } + return respondError(err) } }, ) @@ -183,29 +153,17 @@ function registerEventTools(server: McpServer): void { server.tool( 'get_event', 'Get detailed information about a single event', - { - event_id: z.string().describe('Event ID'), - }, + { event_id: z.string().describe('Event ID') }, async (params, extra) => { requireScope(extra.authInfo, SCOPE_EVENTS_READ) const userId = getUserId(extra.authInfo) try { const { getEvent } = await import('./event-tools') const result = await getEvent(userId, params.event_id) - if (!result) { - return { - content: [{ type: 'text' as const, text: 'Event not found' }], - isError: true, - } - } - return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - } + if (!result) return respondMessage('Event not found') + return respond(result) } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } + return respondError(err) } }, ) @@ -229,18 +187,9 @@ function registerEventTools(server: McpServer): void { const userId = getUserId(extra.authInfo) try { const { createEvent } = await import('./event-tools') - const result = await createEvent(userId, { - ...params, - color: HEX_TO_EVENT_BG[params.color], - }) - return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - } + return respond(await createEvent(userId, params)) } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } + return respondError(err) } }, ) @@ -265,25 +214,12 @@ function registerEventTools(server: McpServer): void { const userId = getUserId(extra.authInfo) try { const { updateEvent } = await import('./event-tools') - const eventParams = { ...params } - if (eventParams.color) { - eventParams.color = HEX_TO_EVENT_BG[eventParams.color] - } - const result = await updateEvent(userId, params.event_id, eventParams) - if (!result) { - return { - content: [{ type: 'text' as const, text: 'Event not found' }], - isError: true, - } - } - return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - } + const { event_id, ...data } = params + const result = await updateEvent(userId, event_id, data) + if (!result) return respondMessage('Event not found') + return respond(result) } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } + return respondError(err) } }, ) @@ -291,21 +227,16 @@ function registerEventTools(server: McpServer): void { server.tool( 'delete_event', 'Delete an event', - { - event_id: z.string().describe('Event ID'), - }, + { event_id: z.string().describe('Event ID') }, async (params, extra) => { requireScope(extra.authInfo, SCOPE_EVENTS_WRITE) const userId = getUserId(extra.authInfo) try { const { deleteEvent } = await import('./event-tools') await deleteEvent(userId, params.event_id) - return { content: [{ type: 'text' as const, text: 'Event deleted' }] } + return respondMessage('Event deleted') } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } + return respondError(err) } }, ) @@ -321,15 +252,9 @@ function registerCategoryTools(server: McpServer): void { const userId = getUserId(extra.authInfo) try { const { listCategories } = await import('./category-tools') - const result = await listCategories(userId) - return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - } + return respond(await listCategories(userId)) } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } + return respondError(err) } }, ) @@ -347,18 +272,9 @@ function registerCategoryTools(server: McpServer): void { const userId = getUserId(extra.authInfo) try { const { createCategory } = await import('./category-tools') - const result = await createCategory(userId, { - ...params, - color: HEX_TO_CATEGORY_BG[params.color], - }) - return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - } + return respond(await createCategory(userId, params)) } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } + return respondError(err) } }, ) @@ -377,29 +293,12 @@ function registerCategoryTools(server: McpServer): void { const userId = getUserId(extra.authInfo) try { const { updateCategory } = await import('./category-tools') - const categoryParams = { ...params } - if (categoryParams.color) { - categoryParams.color = HEX_TO_CATEGORY_BG[categoryParams.color] - } - const result = await updateCategory( - userId, - params.category_id, - categoryParams, - ) - if (!result) { - return { - content: [{ type: 'text' as const, text: 'Category not found' }], - isError: true, - } - } - return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - } + const { category_id, ...data } = params + const result = await updateCategory(userId, category_id, data) + if (!result) return respondMessage('Category not found') + return respond(result) } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } + return respondError(err) } }, ) @@ -407,23 +306,16 @@ function registerCategoryTools(server: McpServer): void { server.tool( 'delete_category', 'Delete a category', - { - category_id: z.string(), - }, + { category_id: z.string() }, async (params, extra) => { requireScope(extra.authInfo, SCOPE_CATEGORIES_WRITE) const userId = getUserId(extra.authInfo) try { const { deleteCategory } = await import('./category-tools') await deleteCategory(userId, params.category_id) - return { - content: [{ type: 'text' as const, text: 'Category deleted' }], - } + return respondMessage('Category deleted') } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } + return respondError(err) } }, ) @@ -433,21 +325,28 @@ function registerCountdownTools(server: McpServer): void { server.tool( 'list_countdowns', 'List all countdowns', - {}, - async (_params, extra) => { + { + page: z.number().optional().default(1).describe('Page number'), + limit: z + .number() + .optional() + .default(50) + .describe('Items per page (max 50)'), + }, + async (params, extra) => { requireScope(extra.authInfo, SCOPE_COUNTDOWNS_READ) const userId = getUserId(extra.authInfo) try { const { listCountdowns } = await import('./countdown-tools') - const result = await listCountdowns(userId) - return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - } + return respond( + await listCountdowns( + userId, + params.page ?? 1, + Math.min(params.limit ?? 50, 50), + ), + ) } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } + return respondError(err) } }, ) @@ -467,18 +366,9 @@ function registerCountdownTools(server: McpServer): void { const userId = getUserId(extra.authInfo) try { const { createCountdown } = await import('./countdown-tools') - const result = await createCountdown(userId, { - ...params, - color: HEX_TO_COUNTDOWN_BG[params.color], - }) - return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - } + return respond(await createCountdown(userId, params)) } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } + return respondError(err) } }, ) @@ -499,29 +389,12 @@ function registerCountdownTools(server: McpServer): void { const userId = getUserId(extra.authInfo) try { const { updateCountdown } = await import('./countdown-tools') - const countdownParams = { ...params } - if (countdownParams.color) { - countdownParams.color = HEX_TO_COUNTDOWN_BG[countdownParams.color] - } - const result = await updateCountdown( - userId, - params.countdown_id, - countdownParams, - ) - if (!result) { - return { - content: [{ type: 'text' as const, text: 'Countdown not found' }], - isError: true, - } - } - return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - } + const { countdown_id, ...data } = params + const result = await updateCountdown(userId, countdown_id, data) + if (!result) return respondMessage('Countdown not found') + return respond(result) } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } + return respondError(err) } }, ) @@ -529,23 +402,16 @@ function registerCountdownTools(server: McpServer): void { server.tool( 'delete_countdown', 'Delete a countdown', - { - countdown_id: z.string(), - }, + { countdown_id: z.string() }, async (params, extra) => { requireScope(extra.authInfo, SCOPE_COUNTDOWNS_WRITE) const userId = getUserId(extra.authInfo) try { const { deleteCountdown } = await import('./countdown-tools') await deleteCountdown(userId, params.countdown_id) - return { - content: [{ type: 'text' as const, text: 'Countdown deleted' }], - } + return respondMessage('Countdown deleted') } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } + return respondError(err) } }, ) @@ -561,15 +427,9 @@ function registerSettingsTools(server: McpServer): void { const userId = getUserId(extra.authInfo) try { const { getSettings } = await import('./settings-tools') - const result = await getSettings(userId) - return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - } + return respond(await getSettings(userId)) } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } + return respondError(err) } }, ) @@ -594,14 +454,9 @@ function registerSettingsTools(server: McpServer): void { try { const { updateSettings } = await import('./settings-tools') await updateSettings(userId, params) - return { - content: [{ type: 'text' as const, text: 'Settings updated' }], - } + return respondMessage('Settings updated') } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } + return respondError(err) } }, ) @@ -617,15 +472,9 @@ function registerProfileTool(server: McpServer): void { const userId = getUserId(extra.authInfo) try { const { getProfile } = await import('./profile-tools') - const result = await getProfile(userId) - return { - content: [{ type: 'text' as const, text: JSON.stringify(result) }], - } + return respond(await getProfile(userId)) } catch (err) { - return { - content: [{ type: 'text' as const, text: `Error: ${err}` }], - isError: true, - } + return respondError(err) } }, ) diff --git a/apps/calendar/lib/mcp/types.ts b/apps/calendar/lib/mcp/types.ts index 90e1ded9..9bf50be2 100644 --- a/apps/calendar/lib/mcp/types.ts +++ b/apps/calendar/lib/mcp/types.ts @@ -30,16 +30,6 @@ export const ALL_SCOPES: McpScope[] = [ 'profile:read', ] -export function hasScope(user: McpAuthUser, requiredScope: McpScope): boolean { - return user.scopes.includes(requiredScope) -} - -export function requireScope(user: McpAuthUser, requiredScope: McpScope): void { - if (!hasScope(user, requiredScope)) { - throw new McpAuthError(`Missing required scope: ${requiredScope}`, 403) - } -} - export class McpAuthError extends Error { constructor( message: string, From e8e59c141f144261a7af393b654ff9b22d5371f6 Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Thu, 30 Jul 2026 23:45:38 +0800 Subject: [PATCH 30/36] fix: skip auth for GET/SSE requests in MCP handler --- apps/calendar/lib/mcp/handler.ts | 134 +++++++++++++++++-------------- 1 file changed, 73 insertions(+), 61 deletions(-) diff --git a/apps/calendar/lib/mcp/handler.ts b/apps/calendar/lib/mcp/handler.ts index 829c973f..6ff32518 100644 --- a/apps/calendar/lib/mcp/handler.ts +++ b/apps/calendar/lib/mcp/handler.ts @@ -21,7 +21,21 @@ async function parseToolName(request: Request): Promise { export async function handleMcpRequest(request: Request): Promise { try { const auth = await getMcpAuth(request) - if (!auth) { + + const authInfo: AuthInfo | undefined = auth + ? { + token: auth.token, + clientId: `user:${auth.user.userId}`, + scopes: auth.user.scopes, + extra: { + userId: auth.user.userId, + clientId: auth.user.keyId ?? auth.user.authType, + authType: auth.user.authType, + }, + } + : undefined + + if (request.method === 'POST' && !auth) { const baseUrl = process.env.BETTER_AUTH_URL || 'http://localhost:3000' return Response.json( { @@ -36,51 +50,42 @@ export async function handleMcpRequest(request: Request): Promise { ) } - const settings = await getMcpSettings(auth.user.userId) - if (!settings.enabled) { - return Response.json( - { error: 'MCP is disabled for this account' }, - { status: 403 }, - ) - } - - const toolName = await parseToolName(request) + const toolName = auth ? await parseToolName(request) : 'mcp_request' const clientIp = request.headers.get('x-forwarded-for') ?? request.headers.get('x-real-ip') ?? '' const userAgent = request.headers.get('user-agent') ?? '' - const rateLimit = await checkRateLimit(auth.user.userId) - if (!rateLimit.allowed) { - await logAudit({ - userId: auth.user.userId, - authType: auth.user.authType, - keyId: auth.user.keyId, - action: toolName, - success: false, - errorMessage: 'Rate limit exceeded', - ipAddress: clientIp, - userAgent, - }) - return Response.json( - { - error: 'rate_limited', - retry_after: Math.ceil((rateLimit.resetAt - Date.now()) / 1000), - }, - { status: 429 }, - ) - } + if (auth) { + const settings = await getMcpSettings(auth.user.userId) + if (!settings.enabled) { + return Response.json( + { error: 'MCP is disabled for this account' }, + { status: 403 }, + ) + } - const authInfo: AuthInfo = { - token: auth.token, - clientId: `user:${auth.user.userId}`, - scopes: auth.user.scopes, - extra: { - userId: auth.user.userId, - clientId: auth.user.keyId ?? auth.user.authType, - authType: auth.user.authType, - }, + const rateLimit = await checkRateLimit(auth.user.userId) + if (!rateLimit.allowed) { + await logAudit({ + userId: auth.user.userId, + authType: auth.user.authType, + keyId: auth.user.keyId, + action: toolName, + success: false, + errorMessage: 'Rate limit exceeded', + ipAddress: clientIp, + userAgent, + }) + return Response.json( + { + error: 'rate_limited', + retry_after: Math.ceil((rateLimit.resetAt - Date.now()) / 1000), + }, + { status: 429 }, + ) + } } const server = createServer() @@ -92,32 +97,39 @@ export async function handleMcpRequest(request: Request): Promise { await server.connect(transport) try { - const response = await transport.handleRequest(request, { authInfo }) + const response = await transport.handleRequest( + request, + authInfo ? { authInfo } : undefined, + ) - await logAudit({ - userId: auth.user.userId, - authType: auth.user.authType, - keyId: auth.user.keyId, - action: toolName, - resourceType: toolName.split('_')[0], - success: response.status < 500, - ipAddress: clientIp, - userAgent, - }) + if (auth) { + await logAudit({ + userId: auth.user.userId, + authType: auth.user.authType, + keyId: auth.user.keyId, + action: toolName, + resourceType: toolName.split('_')[0], + success: response.status < 500, + ipAddress: clientIp, + userAgent, + }) + } return response } catch (mcpErr) { - await logAudit({ - userId: auth.user.userId, - authType: auth.user.authType, - keyId: auth.user.keyId, - action: toolName, - resourceType: toolName.split('_')[0], - success: false, - errorMessage: String(mcpErr), - ipAddress: clientIp, - userAgent, - }) + if (auth) { + await logAudit({ + userId: auth.user.userId, + authType: auth.user.authType, + keyId: auth.user.keyId, + action: toolName, + resourceType: toolName.split('_')[0], + success: false, + errorMessage: String(mcpErr), + ipAddress: clientIp, + userAgent, + }) + } throw mcpErr } } catch (err) { From 55763b79c89091eb5bc462b59031fab097ed709d Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Thu, 30 Jul 2026 23:52:33 +0800 Subject: [PATCH 31/36] fix: query API keys by both old and new prefix formats --- apps/calendar/lib/mcp/auth.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/calendar/lib/mcp/auth.ts b/apps/calendar/lib/mcp/auth.ts index 2f38e038..3641a7a0 100644 --- a/apps/calendar/lib/mcp/auth.ts +++ b/apps/calendar/lib/mcp/auth.ts @@ -1,6 +1,6 @@ import { getDb } from '@/lib/drizzle/client' import { mcpApiKeys, mcpTokens, mcpSettings } from '@/lib/drizzle/schema' -import { eq, and, gte } from 'drizzle-orm' +import { eq, and, gte, or } from 'drizzle-orm' import crypto from 'crypto' import bcrypt from 'bcryptjs' import { type McpAuthUser, ALL_SCOPES } from './types' @@ -10,14 +10,18 @@ const KEY_PREFIX = 'zc_' export async function verifyApiKey(key: string): Promise { if (!key.startsWith(KEY_PREFIX)) return null - const keyPrefix = KEY_PREFIX + key.slice(-4) - const db = await getDb() const keys = await db .select() .from(mcpApiKeys) .where( - and(eq(mcpApiKeys.isActive, true), eq(mcpApiKeys.keyPrefix, keyPrefix)), + and( + eq(mcpApiKeys.isActive, true), + or( + eq(mcpApiKeys.keyPrefix, key.slice(0, 12)), + eq(mcpApiKeys.keyPrefix, KEY_PREFIX + key.slice(-4)), + ), + ), ) for (const row of keys) { From a371108b1ce6e610f963df05da578a9fe2adc81d Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Fri, 31 Jul 2026 00:18:38 +0800 Subject: [PATCH 32/36] chore: strict original auth prefix + original handler with GET skip-auth --- apps/calendar/lib/mcp/auth.ts | 10 +- apps/calendar/lib/mcp/handler.ts | 179 +++++++++++++++---------------- 2 files changed, 90 insertions(+), 99 deletions(-) diff --git a/apps/calendar/lib/mcp/auth.ts b/apps/calendar/lib/mcp/auth.ts index 3641a7a0..1f8feeb1 100644 --- a/apps/calendar/lib/mcp/auth.ts +++ b/apps/calendar/lib/mcp/auth.ts @@ -1,11 +1,12 @@ import { getDb } from '@/lib/drizzle/client' import { mcpApiKeys, mcpTokens, mcpSettings } from '@/lib/drizzle/schema' -import { eq, and, gte, or } from 'drizzle-orm' +import { eq, and, gte } from 'drizzle-orm' import crypto from 'crypto' import bcrypt from 'bcryptjs' import { type McpAuthUser, ALL_SCOPES } from './types' const KEY_PREFIX = 'zc_' +const KEY_PREFIX_LENGTH = 12 export async function verifyApiKey(key: string): Promise { if (!key.startsWith(KEY_PREFIX)) return null @@ -17,10 +18,7 @@ export async function verifyApiKey(key: string): Promise { .where( and( eq(mcpApiKeys.isActive, true), - or( - eq(mcpApiKeys.keyPrefix, key.slice(0, 12)), - eq(mcpApiKeys.keyPrefix, KEY_PREFIX + key.slice(-4)), - ), + eq(mcpApiKeys.keyPrefix, key.slice(0, KEY_PREFIX_LENGTH)), ), ) @@ -112,7 +110,7 @@ export async function generateApiKey( ): Promise { const raw = crypto.randomBytes(32).toString('hex') const key = `${KEY_PREFIX}${raw}` - const prefix = KEY_PREFIX + raw.slice(-4) + const prefix = key.slice(0, KEY_PREFIX_LENGTH) const hash = await bcrypt.hash(key, 10) const { mcpApiKeys: keysTable } = await import('@/lib/drizzle/schema') diff --git a/apps/calendar/lib/mcp/handler.ts b/apps/calendar/lib/mcp/handler.ts index 6ff32518..05e1b05c 100644 --- a/apps/calendar/lib/mcp/handler.ts +++ b/apps/calendar/lib/mcp/handler.ts @@ -7,87 +7,89 @@ import { getMcpSettings } from './settings' import { checkRateLimit } from './rate-limiter' import { McpAuthError } from './types' -async function parseToolName(request: Request): Promise { - try { - const cloned = request.clone() - const text = await cloned.text() - const body = JSON.parse(text) - return body?.params?.name ?? 'mcp_request' - } catch { - return 'mcp_request' - } -} - export async function handleMcpRequest(request: Request): Promise { try { const auth = await getMcpAuth(request) - const authInfo: AuthInfo | undefined = auth - ? { - token: auth.token, - clientId: `user:${auth.user.userId}`, - scopes: auth.user.scopes, - extra: { - userId: auth.user.userId, - clientId: auth.user.keyId ?? auth.user.authType, - authType: auth.user.authType, - }, - } - : undefined + if (request.method === 'GET') { + const server = createServer() + const transport = new WebStandardStreamableHTTPServerTransport({ + enableJsonResponse: true, + allowedOrigins: ['*'], + }) + await server.connect(transport) + return transport.handleRequest( + request, + auth + ? { + authInfo: { + token: auth.token, + clientId: `user:${auth.user.userId}`, + scopes: auth.user.scopes, + extra: { + userId: auth.user.userId, + clientId: auth.user.keyId ?? auth.user.authType, + authType: auth.user.authType, + }, + }, + } + : undefined, + ) + } + + if (!auth) { + return Response.json( + { error: 'unauthorized' }, + { status: 401, headers: { 'WWW-Authenticate': 'Bearer' } }, + ) + } + + const settings = await getMcpSettings(auth.user.userId) + if (!settings.enabled) { + return Response.json( + { error: 'MCP is disabled for this account' }, + { status: 403 }, + ) + } - if (request.method === 'POST' && !auth) { - const baseUrl = process.env.BETTER_AUTH_URL || 'http://localhost:3000' + const rateLimit = await checkRateLimit(auth.user.userId) + if (!rateLimit.allowed) { + await logAudit({ + userId: auth.user.userId, + authType: auth.user.authType, + keyId: auth.user.keyId, + action: 'rate_limited', + success: false, + errorMessage: 'Rate limit exceeded', + ipAddress: request.headers.get('x-forwarded-for') ?? '', + userAgent: request.headers.get('user-agent') ?? '', + }) return Response.json( { - error: 'unauthorized', - auth_required: 'Bearer', - authorization_endpoint: `${baseUrl}/oauth/authorize`, - issuer: baseUrl, - token_endpoint: `${baseUrl}/api/oauth/token`, - device_authorization_endpoint: `${baseUrl}/api/oauth/device`, + error: 'rate_limited', + retry_after: Math.ceil((rateLimit.resetAt - Date.now()) / 1000), }, - { status: 401, headers: { 'WWW-Authenticate': 'Bearer' } }, + { status: 429 }, ) } - const toolName = auth ? await parseToolName(request) : 'mcp_request' + const authInfo: AuthInfo = { + token: auth.token, + clientId: `user:${auth.user.userId}`, + scopes: auth.user.scopes, + extra: { + userId: auth.user.userId, + clientId: auth.user.keyId ?? auth.user.authType, + authType: auth.user.authType, + }, + } + const clientIp = request.headers.get('x-forwarded-for') ?? request.headers.get('x-real-ip') ?? '' const userAgent = request.headers.get('user-agent') ?? '' - if (auth) { - const settings = await getMcpSettings(auth.user.userId) - if (!settings.enabled) { - return Response.json( - { error: 'MCP is disabled for this account' }, - { status: 403 }, - ) - } - - const rateLimit = await checkRateLimit(auth.user.userId) - if (!rateLimit.allowed) { - await logAudit({ - userId: auth.user.userId, - authType: auth.user.authType, - keyId: auth.user.keyId, - action: toolName, - success: false, - errorMessage: 'Rate limit exceeded', - ipAddress: clientIp, - userAgent, - }) - return Response.json( - { - error: 'rate_limited', - retry_after: Math.ceil((rateLimit.resetAt - Date.now()) / 1000), - }, - { status: 429 }, - ) - } - } - const server = createServer() const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true, @@ -97,39 +99,30 @@ export async function handleMcpRequest(request: Request): Promise { await server.connect(transport) try { - const response = await transport.handleRequest( - request, - authInfo ? { authInfo } : undefined, - ) + const response = await transport.handleRequest(request, { authInfo }) - if (auth) { - await logAudit({ - userId: auth.user.userId, - authType: auth.user.authType, - keyId: auth.user.keyId, - action: toolName, - resourceType: toolName.split('_')[0], - success: response.status < 500, - ipAddress: clientIp, - userAgent, - }) - } + await logAudit({ + userId: auth.user.userId, + authType: auth.user.authType, + keyId: auth.user.keyId, + action: 'mcp_request', + success: response.status < 500, + ipAddress: clientIp, + userAgent, + }) return response } catch (mcpErr) { - if (auth) { - await logAudit({ - userId: auth.user.userId, - authType: auth.user.authType, - keyId: auth.user.keyId, - action: toolName, - resourceType: toolName.split('_')[0], - success: false, - errorMessage: String(mcpErr), - ipAddress: clientIp, - userAgent, - }) - } + await logAudit({ + userId: auth.user.userId, + authType: auth.user.authType, + keyId: auth.user.keyId, + action: 'mcp_request', + success: false, + errorMessage: String(mcpErr), + ipAddress: clientIp, + userAgent, + }) throw mcpErr } } catch (err) { From f0e81f28acc5b090ae2c1947853650ad6221b4ad Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Fri, 31 Jul 2026 11:22:06 +0800 Subject: [PATCH 33/36] feat: add OAuth dynamic client registration to MCP server Implement RFC 7591 registration endpoint so OAuth clients like opencode can register dynamically. Add mcp_oauth_clients table, refresh_token grant support, and oauth-protected-resource metadata for scopes. --- .../oauth-authorization-server/route.ts | 1 + .../oauth-protected-resource/route.ts | 22 + apps/calendar/app/api/oauth/register/route.ts | 78 + apps/calendar/app/api/oauth/token/route.ts | 44 + .../drizzle/0002_create_mcp_oauth_clients.sql | 13 + apps/calendar/drizzle/meta/0002_snapshot.json | 1656 +++++++++++++++++ apps/calendar/drizzle/meta/_journal.json | 7 + apps/calendar/lib/drizzle/schema.ts | 23 + apps/calendar/lib/mcp/auth.ts | 120 +- 9 files changed, 1962 insertions(+), 2 deletions(-) create mode 100644 apps/calendar/app/.well-known/oauth-protected-resource/route.ts create mode 100644 apps/calendar/app/api/oauth/register/route.ts create mode 100644 apps/calendar/drizzle/0002_create_mcp_oauth_clients.sql create mode 100644 apps/calendar/drizzle/meta/0002_snapshot.json diff --git a/apps/calendar/app/.well-known/oauth-authorization-server/route.ts b/apps/calendar/app/.well-known/oauth-authorization-server/route.ts index ca7598ad..d000b033 100644 --- a/apps/calendar/app/.well-known/oauth-authorization-server/route.ts +++ b/apps/calendar/app/.well-known/oauth-authorization-server/route.ts @@ -7,6 +7,7 @@ export async function GET() { issuer: MCP_BASE_URL, authorization_endpoint: `${MCP_BASE_URL}/oauth/authorize`, token_endpoint: `${MCP_BASE_URL}/api/oauth/token`, + registration_endpoint: `${MCP_BASE_URL}/api/oauth/register`, device_authorization_endpoint: `${MCP_BASE_URL}/api/oauth/device`, response_types_supported: ['code'], grant_types_supported: [ diff --git a/apps/calendar/app/.well-known/oauth-protected-resource/route.ts b/apps/calendar/app/.well-known/oauth-protected-resource/route.ts new file mode 100644 index 00000000..854a17ce --- /dev/null +++ b/apps/calendar/app/.well-known/oauth-protected-resource/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server' + +const MCP_BASE_URL = process.env.BETTER_AUTH_URL || 'http://localhost:3000' + +export async function GET() { + return NextResponse.json({ + resource: MCP_BASE_URL, + authorization_servers: [MCP_BASE_URL], + scopes_supported: [ + 'events:read', + 'events:write', + 'categories:read', + 'categories:write', + 'countdowns:read', + 'countdowns:write', + 'settings:read', + 'settings:write', + 'profile:read', + ], + bearer_methods_supported: ['header'], + }) +} diff --git a/apps/calendar/app/api/oauth/register/route.ts b/apps/calendar/app/api/oauth/register/route.ts new file mode 100644 index 00000000..1d2990d5 --- /dev/null +++ b/apps/calendar/app/api/oauth/register/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server' +import { registerOAuthClient } from '@/lib/mcp/auth' +import { McpAuthError } from '@/lib/mcp/types' + +export const runtime = 'nodejs' + +export async function POST(request: NextRequest) { + try { + let body: Record + try { + body = await request.json() + } catch { + return NextResponse.json( + { + error: 'invalid_client_metadata', + error_description: 'Request body must be valid JSON', + }, + { status: 400 }, + ) + } + + const registered = await registerOAuthClient({ + redirect_uris: body.redirect_uris as string[], + token_endpoint_auth_method: body.token_endpoint_auth_method as + | string + | undefined, + grant_types: body.grant_types as string[] | undefined, + response_types: body.response_types as string[] | undefined, + client_name: body.client_name as string | undefined, + client_uri: body.client_uri as string | undefined, + logo_uri: body.logo_uri as string | undefined, + scope: body.scope as string | undefined, + contacts: body.contacts as string[] | undefined, + tos_uri: body.tos_uri as string | undefined, + policy_uri: body.policy_uri as string | undefined, + jwks_uri: body.jwks_uri as string | undefined, + jwks: body.jwks, + software_id: body.software_id as string | undefined, + software_version: body.software_version as string | undefined, + }) + + return NextResponse.json( + { + client_id: registered.clientId, + client_secret: registered.clientSecret, + client_id_issued_at: registered.clientIdIssuedAt, + client_secret_expires_at: registered.clientSecretExpiresAt, + redirect_uris: (body.redirect_uris as string[]) ?? [], + token_endpoint_auth_method: body.token_endpoint_auth_method ?? 'none', + grant_types: body.grant_types ?? [ + 'authorization_code', + 'refresh_token', + ], + response_types: body.response_types ?? ['code'], + client_name: body.client_name ?? 'MCP Client', + scope: body.scope ?? '', + }, + { status: 201 }, + ) + } catch (err) { + if (err instanceof McpAuthError) { + return NextResponse.json( + { + error: 'invalid_client_metadata', + error_description: err.message, + }, + { status: err.statusCode }, + ) + } + return NextResponse.json( + { + error: 'server_error', + error_description: 'Internal server error', + }, + { status: 500 }, + ) + } +} diff --git a/apps/calendar/app/api/oauth/token/route.ts b/apps/calendar/app/api/oauth/token/route.ts index d51c3a5b..e747e283 100644 --- a/apps/calendar/app/api/oauth/token/route.ts +++ b/apps/calendar/app/api/oauth/token/route.ts @@ -43,6 +43,11 @@ export async function POST(request: NextRequest) { return handleAuthorizationCodeGrant(body) } + // --- Refresh Token Grant --- + if (grantType === 'refresh_token') { + return handleRefreshTokenGrant(body) + } + return NextResponse.json( { error: 'unsupported_grant_type' }, { status: 400 }, @@ -199,6 +204,45 @@ async function handleAuthorizationCodeGrant(body: Record) { ) } +async function handleRefreshTokenGrant(body: Record) { + const refreshToken = body.refresh_token as string | undefined + const clientId = body.client_id as string | undefined + + if (!refreshToken) { + return NextResponse.json( + { error: 'invalid_request', error_description: 'Missing refresh_token' }, + { status: 400 }, + ) + } + + const refreshTokenHash = hashToken(refreshToken) + const db = await getDb() + + const [record] = await db + .select() + .from(mcpTokens) + .where(eq(mcpTokens.refreshTokenHash, refreshTokenHash)) + + if (!record || record.isRevoked) { + return NextResponse.json({ error: 'invalid_grant' }, { status: 400 }) + } + + if (record.refreshExpiresAt && record.refreshExpiresAt < new Date()) { + return NextResponse.json({ error: 'invalid_grant' }, { status: 400 }) + } + + if (clientId && clientId !== record.clientId) { + return NextResponse.json({ error: 'invalid_grant' }, { status: 400 }) + } + + return issueTokens( + record.userId, + record.scopes as string[], + record.clientId, + record.clientName, + ) +} + function generateCodeChallenge(verifier: string, method: string): string { if (method === 'S256') { const hash = crypto diff --git a/apps/calendar/drizzle/0002_create_mcp_oauth_clients.sql b/apps/calendar/drizzle/0002_create_mcp_oauth_clients.sql new file mode 100644 index 00000000..72a6696e --- /dev/null +++ b/apps/calendar/drizzle/0002_create_mcp_oauth_clients.sql @@ -0,0 +1,13 @@ +CREATE TABLE "mcp_oauth_clients" ( + "id" text PRIMARY KEY NOT NULL, + "client_secret_hash" text, + "client_name" text NOT NULL, + "redirect_uris" jsonb DEFAULT '[]'::jsonb NOT NULL, + "grant_types" jsonb DEFAULT '["authorization_code","refresh_token"]'::jsonb NOT NULL, + "response_types" jsonb DEFAULT '["code"]'::jsonb NOT NULL, + "token_endpoint_auth_method" text DEFAULT 'none' NOT NULL, + "scopes" jsonb DEFAULT '[]'::jsonb NOT NULL, + "is_revoked" boolean DEFAULT false NOT NULL, + "created_at" timestamp (3) with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL +); diff --git a/apps/calendar/drizzle/meta/0002_snapshot.json b/apps/calendar/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000..1638c688 --- /dev/null +++ b/apps/calendar/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1656 @@ +{ + "id": "a1ab0c92-d41f-44a6-96e6-6bd09a593a9d", + "prevId": "8689a702-6994-4064-a38d-ae2d7e845fb1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.Account": { + "name": "Account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "Account_providerId_accountId_key": { + "name": "Account_providerId_accountId_key", + "columns": [ + { + "expression": "providerId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "accountId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Account_userId_User_id_fk": { + "name": "Account_userId_User_id_fk", + "tableFrom": "Account", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bookmarked_events": { + "name": "bookmarked_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_bookmarks_user_id": { + "name": "idx_bookmarks_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bookmarks_event_id": { + "name": "idx_bookmarks_event_id", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bookmarked_events_user_id_User_id_fk": { + "name": "bookmarked_events_user_id_User_id_fk", + "tableFrom": "bookmarked_events", + "tableTo": "User", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarked_events_event_id_calendar_events_id_fk": { + "name": "bookmarked_events_event_id_calendar_events_id_fk", + "tableFrom": "bookmarked_events", + "tableTo": "calendar_events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "idx_bookmarks_user_event": { + "name": "idx_bookmarks_user_event", + "nullsNotDistinct": false, + "columns": ["user_id", "event_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendar_categories": { + "name": "calendar_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_categories_user_id": { + "name": "idx_categories_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_categories_user_id_User_id_fk": { + "name": "calendar_categories_user_id_User_id_fk", + "tableFrom": "calendar_categories", + "tableTo": "User", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendar_events": { + "name": "calendar_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_date": { + "name": "start_date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "is_all_day": { + "name": "is_all_day", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "participants": { + "name": "participants", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notification_minutes": { + "name": "notification_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_events_user_id": { + "name": "idx_events_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_events_date_range": { + "name": "idx_events_date_range", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "start_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_events_user_id_User_id_fk": { + "name": "calendar_events_user_id_User_id_fk", + "tableFrom": "calendar_events", + "tableTo": "User", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_events_category_id_calendar_categories_id_fk": { + "name": "calendar_events_category_id_calendar_categories_id_fk", + "tableFrom": "calendar_events", + "tableTo": "calendar_categories", + "columnsFrom": ["category_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.countdowns": { + "name": "countdowns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_date": { + "name": "target_date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "repeat": { + "name": "repeat", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_countdowns_user_id": { + "name": "idx_countdowns_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "countdowns_user_id_User_id_fk": { + "name": "countdowns_user_id_User_id_fk", + "tableFrom": "countdowns", + "tableTo": "User", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_api_keys": { + "name": "mcp_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_mcp_api_keys_user_id": { + "name": "idx_mcp_api_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_api_keys_user_id_User_id_fk": { + "name": "mcp_api_keys_user_id_User_id_fk", + "tableFrom": "mcp_api_keys", + "tableTo": "User", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_audit_logs": { + "name": "mcp_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_mcp_audit_user_id": { + "name": "idx_mcp_audit_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_mcp_audit_created_at": { + "name": "idx_mcp_audit_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_audit_logs_user_id_User_id_fk": { + "name": "mcp_audit_logs_user_id_User_id_fk", + "tableFrom": "mcp_audit_logs", + "tableTo": "User", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_auth_requests": { + "name": "mcp_auth_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code": { + "name": "authorization_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_expires_at": { + "name": "code_expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_auth_requests_user_id_User_id_fk": { + "name": "mcp_auth_requests_user_id_User_id_fk", + "tableFrom": "mcp_auth_requests", + "tableTo": "User", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_auth_requests_authorization_code_unique": { + "name": "mcp_auth_requests_authorization_code_unique", + "nullsNotDistinct": false, + "columns": ["authorization_code"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_device_codes": { + "name": "mcp_device_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_device_codes_user_id_User_id_fk": { + "name": "mcp_device_codes_user_id_User_id_fk", + "tableFrom": "mcp_device_codes", + "tableTo": "User", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_device_codes_device_code_unique": { + "name": "mcp_device_codes_device_code_unique", + "nullsNotDistinct": false, + "columns": ["device_code"] + }, + "mcp_device_codes_user_code_unique": { + "name": "mcp_device_codes_user_code_unique", + "nullsNotDistinct": false, + "columns": ["user_code"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_clients": { + "name": "mcp_oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_secret_hash": { + "name": "client_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "grant_types": { + "name": "grant_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"authorization_code\",\"refresh_token\"]'::jsonb" + }, + "response_types": { + "name": "response_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"code\"]'::jsonb" + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_settings": { + "name": "mcp_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_rpm": { + "name": "rate_limit_rpm", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_settings_user_id_User_id_fk": { + "name": "mcp_settings_user_id_User_id_fk", + "tableFrom": "mcp_settings", + "tableTo": "User", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tokens": { + "name": "mcp_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'bearer'" + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "refresh_expires_at": { + "name": "refresh_expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tokens_user_id_User_id_fk": { + "name": "mcp_tokens_user_id_User_id_fk", + "tableFrom": "mcp_tokens", + "tableTo": "User", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_tokens_token_hash_unique": { + "name": "mcp_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Session": { + "name": "Session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Session_userId_User_id_fk": { + "name": "Session_userId_User_id_fk", + "tableFrom": "Session", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Session_token_unique": { + "name": "Session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_User_id_fk": { + "name": "settings_user_id_User_id_fk", + "tableFrom": "settings", + "tableTo": "User", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shares": { + "name": "shares", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_payload": { + "name": "encrypted_payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "has_password": { + "name": "has_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "burn_after_read": { + "name": "burn_after_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_shares_user_id": { + "name": "idx_shares_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_shares_event_id": { + "name": "idx_shares_event_id", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shares_user_id_User_id_fk": { + "name": "shares_user_id_User_id_fk", + "tableFrom": "shares", + "tableTo": "User", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shares_event_id_calendar_events_id_fk": { + "name": "shares_event_id_calendar_events_id_fk", + "tableFrom": "shares", + "tableTo": "calendar_events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.twoFactor": { + "name": "twoFactor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backupCodes": { + "name": "backupCodes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "twoFactor_userId_User_id_fk": { + "name": "twoFactor_userId_User_id_fk", + "tableFrom": "twoFactor", + "tableTo": "User", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "twoFactor_userId_unique": { + "name": "twoFactor_userId_unique", + "nullsNotDistinct": false, + "columns": ["userId"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.User": { + "name": "User", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "twoFactorEnabled": { + "name": "twoFactorEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "User_email_unique": { + "name": "User_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Verification": { + "name": "Verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/calendar/drizzle/meta/_journal.json b/apps/calendar/drizzle/meta/_journal.json index c713be55..23e3bca3 100644 --- a/apps/calendar/drizzle/meta/_journal.json +++ b/apps/calendar/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1785000000000, "tag": "0001_create_mcp_tables", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1785467700889, + "tag": "0002_create_mcp_oauth_clients", + "breakpoints": true } ] } diff --git a/apps/calendar/lib/drizzle/schema.ts b/apps/calendar/lib/drizzle/schema.ts index 47d0a951..90962d13 100644 --- a/apps/calendar/lib/drizzle/schema.ts +++ b/apps/calendar/lib/drizzle/schema.ts @@ -412,6 +412,29 @@ export const mcpSettings = pgTable('mcp_settings', { .notNull(), }) +// --- MCP OAuth Clients (RFC 7591 dynamic registration) --- +export const mcpOauthClients = pgTable('mcp_oauth_clients', { + id: text('id').primaryKey(), + clientSecretHash: text('client_secret_hash'), + clientName: text('client_name').notNull(), + redirectUris: jsonb('redirect_uris').notNull().default([]), + grantTypes: jsonb('grant_types') + .notNull() + .default(['authorization_code', 'refresh_token']), + responseTypes: jsonb('response_types').notNull().default(['code']), + tokenEndpointAuthMethod: text('token_endpoint_auth_method') + .notNull() + .default('none'), + scopes: jsonb('scopes').notNull().default([]), + isRevoked: boolean('is_revoked').notNull().default(false), + createdAt: timestamp('created_at', { precision: 3, withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { precision: 3, withTimezone: true }) + .defaultNow() + .notNull(), +}) + // --- MCP OAuth Authorization Requests --- export const mcpAuthRequests = pgTable('mcp_auth_requests', { id: text('id').primaryKey(), diff --git a/apps/calendar/lib/mcp/auth.ts b/apps/calendar/lib/mcp/auth.ts index 1f8feeb1..f2128144 100644 --- a/apps/calendar/lib/mcp/auth.ts +++ b/apps/calendar/lib/mcp/auth.ts @@ -1,9 +1,14 @@ import { getDb } from '@/lib/drizzle/client' -import { mcpApiKeys, mcpTokens, mcpSettings } from '@/lib/drizzle/schema' +import { + mcpApiKeys, + mcpOauthClients, + mcpTokens, + mcpSettings, +} from '@/lib/drizzle/schema' import { eq, and, gte } from 'drizzle-orm' import crypto from 'crypto' import bcrypt from 'bcryptjs' -import { type McpAuthUser, ALL_SCOPES } from './types' +import { type McpAuthUser, ALL_SCOPES, McpAuthError } from './types' const KEY_PREFIX = 'zc_' const KEY_PREFIX_LENGTH = 12 @@ -174,6 +179,117 @@ export async function listApiKeys(userId: string) { .orderBy(mcpApiKeys.createdAt) } +const CLIENT_PREFIX = 'oc_' + +export interface OAuthClientMetadata { + redirect_uris: string[] + token_endpoint_auth_method?: string + grant_types?: string[] + response_types?: string[] + client_name?: string + client_uri?: string + logo_uri?: string + scope?: string + contacts?: string[] + tos_uri?: string + policy_uri?: string + jwks_uri?: string + jwks?: unknown + software_id?: string + software_version?: string +} + +export interface RegisteredOAuthClient { + clientId: string + clientSecret?: string + clientIdIssuedAt: number + clientSecretExpiresAt?: number +} + +export async function registerOAuthClient( + metadata: OAuthClientMetadata, +): Promise { + const db = await getDb() + + const redirectUris = Array.isArray(metadata.redirect_uris) + ? metadata.redirect_uris + : [] + if (redirectUris.length === 0) { + throw new McpAuthError('redirect_uris is required', 400) + } + + const grantTypes = + metadata.grant_types && metadata.grant_types.length > 0 + ? metadata.grant_types + : ['authorization_code', 'refresh_token'] + + const responseTypes = + metadata.response_types && metadata.response_types.length > 0 + ? metadata.response_types + : ['code'] + + const authMethod = metadata.token_endpoint_auth_method || 'none' + if ( + !['none', 'client_secret_post', 'client_secret_basic'].includes(authMethod) + ) { + throw new McpAuthError( + `Unsupported token_endpoint_auth_method: ${authMethod}`, + 400, + ) + } + + let clientSecret: string | undefined + let clientSecretHash: string | null = null + let clientSecretExpiresAt: number | undefined + + if (authMethod !== 'none') { + clientSecret = crypto.randomBytes(32).toString('hex') + clientSecretHash = bcrypt.hashSync(clientSecret, 10) + clientSecretExpiresAt = 0 + } + + const clientId = `${CLIENT_PREFIX}${crypto.randomBytes(16).toString('hex')}` + + await db.insert(mcpOauthClients).values({ + id: clientId, + clientSecretHash, + clientName: metadata.client_name || 'MCP Client', + redirectUris, + grantTypes, + responseTypes, + tokenEndpointAuthMethod: authMethod, + scopes: metadata.scope ? metadata.scope.split(' ') : [], + isRevoked: false, + }) + + return { + clientId, + clientSecret, + clientIdIssuedAt: Math.floor(Date.now() / 1000), + clientSecretExpiresAt, + } +} + +export async function verifyOAuthClientSecret( + clientId: string, + clientSecret: string | undefined, +): Promise { + if (!clientSecret) return false + const db = await getDb() + const [row] = await db + .select() + .from(mcpOauthClients) + .where( + and( + eq(mcpOauthClients.id, clientId), + eq(mcpOauthClients.isRevoked, false), + ), + ) + + if (!row?.clientSecretHash) return false + return bcrypt.compare(clientSecret, row.clientSecretHash) +} + export function generateAccessToken(): string { return crypto.randomBytes(32).toString('hex') } From cb0c8fc015d95eaf5f144682cf791a6f5e6a654b Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Fri, 31 Jul 2026 11:38:27 +0800 Subject: [PATCH 34/36] feat: lowercase auth table names and add audit log retention --- apps/calendar/app/api/mcp/cleanup/route.ts | 30 ++++++++++++++++++++ apps/calendar/drizzle/0003_rename_tables.sql | 7 +++++ apps/calendar/drizzle/meta/_journal.json | 7 +++++ apps/calendar/lib/drizzle/schema.ts | 22 +++++++------- apps/calendar/lib/mcp/audit.ts | 16 ++++++++++- apps/calendar/vercel.json | 4 +++ 6 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 apps/calendar/app/api/mcp/cleanup/route.ts create mode 100644 apps/calendar/drizzle/0003_rename_tables.sql diff --git a/apps/calendar/app/api/mcp/cleanup/route.ts b/apps/calendar/app/api/mcp/cleanup/route.ts new file mode 100644 index 00000000..86caf7d9 --- /dev/null +++ b/apps/calendar/app/api/mcp/cleanup/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' +import { cleanupAuditLogs } from '@/lib/mcp/audit' + +export const runtime = 'nodejs' + +export async function GET(request: Request) { + const cronSecret = process.env.CRON_SECRET + if (!cronSecret) { + return NextResponse.json({ error: 'Missing CRON_SECRET' }, { status: 500 }) + } + + const authHeader = request.headers.get('authorization') + if (authHeader !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const retentionDays = Number( + new URL(request.url).searchParams.get('retentionDays') ?? 30, + ) + + try { + const deleted = await cleanupAuditLogs(retentionDays) + return NextResponse.json({ deleted }) + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 }, + ) + } +} diff --git a/apps/calendar/drizzle/0003_rename_tables.sql b/apps/calendar/drizzle/0003_rename_tables.sql new file mode 100644 index 00000000..07034914 --- /dev/null +++ b/apps/calendar/drizzle/0003_rename_tables.sql @@ -0,0 +1,7 @@ +-- Lowercase Better Auth table names + rename settings -> calendar_settings +ALTER TABLE "public"."User" RENAME TO "user"; +ALTER TABLE "public"."Session" RENAME TO "session"; +ALTER TABLE "public"."Account" RENAME TO "account"; +ALTER TABLE "public"."Verification" RENAME TO "verification"; +ALTER TABLE "public"."twoFactor" RENAME TO "two_factor"; +ALTER TABLE "public"."settings" RENAME TO "calendar_settings"; diff --git a/apps/calendar/drizzle/meta/_journal.json b/apps/calendar/drizzle/meta/_journal.json index 23e3bca3..5b3b7d2a 100644 --- a/apps/calendar/drizzle/meta/_journal.json +++ b/apps/calendar/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1785467700889, "tag": "0002_create_mcp_oauth_clients", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1785571200000, + "tag": "0003_rename_tables", + "breakpoints": true } ] } diff --git a/apps/calendar/lib/drizzle/schema.ts b/apps/calendar/lib/drizzle/schema.ts index 90962d13..01cbc42d 100644 --- a/apps/calendar/lib/drizzle/schema.ts +++ b/apps/calendar/lib/drizzle/schema.ts @@ -11,8 +11,8 @@ import { } from 'drizzle-orm/pg-core' import { relations } from 'drizzle-orm' -// --- User (Table name: "User") --- -export const user = pgTable('User', { +// --- User --- +export const user = pgTable('user', { id: text('id').primaryKey(), name: text('name').notNull(), email: text('email').unique().notNull(), @@ -29,8 +29,8 @@ export const user = pgTable('User', { }).notNull(), }) -// --- Session (Table name: "Session") --- -export const session = pgTable('Session', { +// --- Session --- +export const session = pgTable('session', { id: text('id').primaryKey(), expiresAt: timestamp('expiresAt', { precision: 3, @@ -52,9 +52,9 @@ export const session = pgTable('Session', { .references(() => user.id, { onDelete: 'cascade' }), }) -// --- Account (Table name: "Account") --- +// --- Account --- export const account = pgTable( - 'Account', + 'account', { id: text('id').primaryKey(), accountId: text('accountId').notNull(), @@ -92,8 +92,8 @@ export const account = pgTable( }), ) -// --- Verification (Table name: "Verification") --- -export const verification = pgTable('Verification', { +// --- Verification --- +export const verification = pgTable('verification', { id: text('id').primaryKey(), identifier: text('identifier').notNull(), value: text('value').notNull(), @@ -105,8 +105,8 @@ export const verification = pgTable('Verification', { updatedAt: timestamp('updatedAt', { precision: 3, withTimezone: true }), }) -// --- TwoFactor (@@map("twoFactor")) --- -export const twoFactor = pgTable('twoFactor', { +// --- Two Factor --- +export const twoFactor = pgTable('two_factor', { id: text('id').primaryKey(), secret: text('secret').notNull(), backupCodes: text('backupCodes').notNull(), @@ -170,7 +170,7 @@ export const calendarEvents = pgTable( ) // --- Settings --- -export const settings = pgTable('settings', { +export const settings = pgTable('calendar_settings', { userId: text('user_id') .primaryKey() .references(() => user.id, { onDelete: 'cascade' }), diff --git a/apps/calendar/lib/mcp/audit.ts b/apps/calendar/lib/mcp/audit.ts index a77ffe6f..ae7a8915 100644 --- a/apps/calendar/lib/mcp/audit.ts +++ b/apps/calendar/lib/mcp/audit.ts @@ -1,6 +1,6 @@ import { getDb } from '@/lib/drizzle/client' import { mcpAuditLogs } from '@/lib/drizzle/schema' -import { eq, desc, sql } from 'drizzle-orm' +import { eq, desc, sql, lt } from 'drizzle-orm' import crypto from 'crypto' import type { AuditEntry } from './types' @@ -44,3 +44,17 @@ export async function getAuditLogsCount(userId: string): Promise { .where(eq(mcpAuditLogs.userId, userId)) return row?.count ?? 0 } + +const DEFAULT_RETENTION_DAYS = 30 + +export async function cleanupAuditLogs( + retentionDays: number = DEFAULT_RETENTION_DAYS, +): Promise { + const db = await getDb() + const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000) + const result = await db + .delete(mcpAuditLogs) + .where(lt(mcpAuditLogs.createdAt, cutoff)) + .returning({ id: mcpAuditLogs.id }) + return result.length +} diff --git a/apps/calendar/vercel.json b/apps/calendar/vercel.json index 62b31389..d3dab8fc 100644 --- a/apps/calendar/vercel.json +++ b/apps/calendar/vercel.json @@ -3,6 +3,10 @@ { "path": "/api/blob/check", "schedule": "0 0 * * *" + }, + { + "path": "/api/mcp/cleanup", + "schedule": "0 1 * * *" } ] } From 698a96a773d1ef5c63c3cc0565817e1807790b9f Mon Sep 17 00:00:00 2001 From: Evan Huang Date: Sat, 1 Aug 2026 17:31:20 +0800 Subject: [PATCH 35/36] feat: add Astro web marketing site with theme-aware logo favicon --- AGENTS.md | 17 +- apps/web/.gitignore | 9 + apps/web/astro.config.mjs | 20 + apps/web/package.json | 35 + apps/web/public/icon.svg | 118 ++ apps/web/src/components/Bg.astro | 268 +++ apps/web/src/components/Logo.astro | 24 + apps/web/src/layouts/Layout.astro | 35 + apps/web/src/pages/index.astro | 106 ++ apps/web/src/styles/global.css | 31 + apps/web/tsconfig.json | 13 + package.json | 4 + pnpm-lock.yaml | 2518 ++++++++++++++++++++++++---- pnpm-workspace.yaml | 6 + turbo.json | 2 +- 15 files changed, 2830 insertions(+), 376 deletions(-) create mode 100644 apps/web/.gitignore create mode 100644 apps/web/astro.config.mjs create mode 100644 apps/web/package.json create mode 100644 apps/web/public/icon.svg create mode 100644 apps/web/src/components/Bg.astro create mode 100644 apps/web/src/components/Logo.astro create mode 100644 apps/web/src/layouts/Layout.astro create mode 100644 apps/web/src/pages/index.astro create mode 100644 apps/web/src/styles/global.css create mode 100644 apps/web/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index e1c77b76..e34dfd7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ pnpm workspace + Turborepo. Package manager: `pnpm@11.5.2`. apps/ calendar/ Next.js 16 web app (one-calendar) — main product calendar-client/ Tauri + React + Vite desktop client + web/ Astro 7 marketing/site app (@astrojs/react + Tailwind v4) packages/ ui/ @zntr/ui — shadcn/ui components (radix-nova style) utils/ @zntr/utils — cn(), tailwind-merge, clsx @@ -16,13 +17,13 @@ packages/ ## Essential commands (run from root) -| Command | What it does | -|---|---| -| `pnpm dev` | Start all dev servers | -| `pnpm build` | Full build (i18n generate → mdx → next build) | -| `pnpm lint` | oxlint + eslint via turbo (both run per-package) | -| `pnpm type-check` | `tsc --noEmit` across all packages | -| `pnpm test` | `vitest run` across all packages | +| Command | What it does | +| ----------------- | ------------------------------------------------ | +| `pnpm dev` | Start all dev servers | +| `pnpm build` | Full build (i18n generate → mdx → next build) | +| `pnpm lint` | oxlint + eslint via turbo (both run per-package) | +| `pnpm type-check` | `tsc --noEmit` across all packages | +| `pnpm test` | `vitest run` across all packages | Single-package: use `pnpm --filter