diff --git a/apps/dashboard/app/(app)/api/auth/refresh/route.ts b/apps/dashboard/app/(app)/api/auth/refresh/route.ts index 0314fbb6a1..54eabb924e 100644 --- a/apps/dashboard/app/(app)/api/auth/refresh/route.ts +++ b/apps/dashboard/app/(app)/api/auth/refresh/route.ts @@ -1,34 +1,146 @@ -import { setCookie } from "@/lib/auth/cookies"; +import { getCookie, setCookie } from "@/lib/auth/cookies"; import { auth } from "@/lib/auth/server"; -import { UNKEY_SESSION_COOKIE } from "@/lib/auth/types"; +import { tokenManager } from "@/lib/auth/token-management-service"; +import { + UNKEY_ACCESS_MAX_AGE, + UNKEY_ACCESS_TOKEN, + UNKEY_REFRESH_MAX_AGE, + UNKEY_REFRESH_TOKEN, + UNKEY_SESSION_COOKIE, + UNKEY_USER_IDENTITY_COOKIE, + UNKEY_USER_IDENTITY_MAX_AGE, +} from "@/lib/auth/types"; +import { type NextRequest, NextResponse } from "next/server"; -export async function POST(request: Request) { +export async function POST(req: NextRequest) { try { - // Get the current token from the request - const currentToken = request.headers.get("x-current-token"); - if (!currentToken) { - console.error("Session refresh failed: no current token"); - return Response.json({ success: false, error: "Failed to refresh session" }, { status: 401 }); + // Get the refresh token from the cookie + const refreshToken = await getCookie(UNKEY_REFRESH_TOKEN); + + // Get the user identity from the request headers and cookies + const requestUserIdentity = req.headers.get("x-user-identity"); + const cookieUserIdentity = await getCookie(UNKEY_USER_IDENTITY_COOKIE); + + // If we don't have a refresh token, return error + if (!refreshToken) { + return NextResponse.json({ error: "No refresh token available" }, { status: 401 }); + } + + // Check user identity consistency + // 1. If we have a user identity in the cookie, it should match the request + // 2. If we don't have one in the cookie but have one in the request, accept it + if (cookieUserIdentity && requestUserIdentity && cookieUserIdentity !== requestUserIdentity) { + console.warn("User identity mismatch during refresh"); + return NextResponse.json({ error: "Invalid user identity" }, { status: 403 }); + } + + // Use either the cookie identity or request identity + const userIdentity = cookieUserIdentity || requestUserIdentity; + + // If we have a user identity, verify token ownership + if (userIdentity) { + const isValidOwner = tokenManager.verifyTokenOwnership({ + refreshToken, + userIdentity, + }); + + if (!isValidOwner) { + console.warn("Refresh token ownership verification failed"); + return NextResponse.json({ error: "Invalid refresh token ownership" }, { status: 403 }); + } + + // If the user identity is only in the request, set it as a cookie + if (!cookieUserIdentity && requestUserIdentity) { + await setCookie({ + name: UNKEY_USER_IDENTITY_COOKIE, + value: requestUserIdentity, + options: { + httpOnly: true, + secure: true, + sameSite: "strict", + maxAge: UNKEY_USER_IDENTITY_MAX_AGE, + path: "/", + }, + }); + } + } + + // continue with token refresh + const result = await auth.refreshAccessToken(refreshToken); + + if (!result || !result.session) { + // Remove the invalid token from our ownership mapping + tokenManager.removeToken(refreshToken); + + return NextResponse.json({ error: "Failed to refresh session" }, { status: 401 }); } - // Call refreshSession logic here and get new token - const { newToken, expiresAt } = await auth.refreshSession(currentToken); - // Set the new cookie + // Calculate remaining time for session in seconds + const sessionMaxAge = Math.floor((result.expiresAt.getTime() - Date.now()) / 1000); + + // Update session cookie await setCookie({ name: UNKEY_SESSION_COOKIE, - value: newToken, + value: result.sessionToken, options: { httpOnly: true, secure: true, - sameSite: "lax", + sameSite: "strict", + maxAge: sessionMaxAge, path: "/", - maxAge: Math.floor((expiresAt.getTime() - Date.now()) / 1000), // Convert to seconds }, }); - return Response.json({ success: true }); + // Set access token cookie if available + if (result.accessToken) { + await setCookie({ + name: UNKEY_ACCESS_TOKEN, + value: result.accessToken, + options: { + httpOnly: true, + secure: true, + sameSite: "strict", + maxAge: UNKEY_ACCESS_MAX_AGE, + path: "/", + }, + }); + } + + // Update refresh token if available + if (result.refreshToken && result.refreshToken !== refreshToken) { + await setCookie({ + name: UNKEY_REFRESH_TOKEN, + value: result.refreshToken, + options: { + httpOnly: true, + secure: true, + sameSite: "strict", + maxAge: UNKEY_REFRESH_MAX_AGE, + path: "/", + }, + }); + + // Update token ownership mapping + if (userIdentity) { + tokenManager.updateTokenOwnership({ + oldToken: refreshToken, + newToken: result.refreshToken, + userIdentity: userIdentity, + }); + } + } + + // Return success response with access token + return NextResponse.json({ + success: true, + userId: result.session.userId, + orgId: result.session.orgId, + role: result.session.role, + accessToken: result.accessToken, + expiresAt: result.expiresAt, + }); } catch (error) { - console.error("Session refresh failed:", error); - return Response.json({ success: false, error: "Failed to refresh session" }, { status: 401 }); + console.error("Refresh error:", error); + return NextResponse.json({ error: "Server error during refresh" }, { status: 500 }); } } diff --git a/apps/dashboard/app/(app)/api/auth/session/route.ts b/apps/dashboard/app/(app)/api/auth/session/route.ts new file mode 100644 index 0000000000..287dbf9236 --- /dev/null +++ b/apps/dashboard/app/(app)/api/auth/session/route.ts @@ -0,0 +1,18 @@ +import { getAuth } from "@/lib/auth/get-auth"; + +export const dynamic = "force-dynamic"; +export async function GET() { + try { + const { userId } = await getAuth(); + + if (!userId) { + return new Response(null, { status: 401 }); + } + + // Just return 200 OK if authenticated + return new Response(null, { status: 200 }); + } catch (error) { + console.error("Error checking session:", error); + return new Response(null, { status: 401 }); + } +} diff --git a/apps/dashboard/app/(app)/layout.tsx b/apps/dashboard/app/(app)/layout.tsx index c90af8707f..00379b854f 100644 --- a/apps/dashboard/app/(app)/layout.tsx +++ b/apps/dashboard/app/(app)/layout.tsx @@ -1,8 +1,10 @@ +import { randomUUID } from "node:crypto"; import { AppSidebar } from "@/components/navigation/sidebar/app-sidebar"; import { SidebarMobile } from "@/components/navigation/sidebar/sidebar-mobile"; import { SidebarProvider } from "@/components/ui/sidebar"; import { getIsImpersonator, getOrgId } from "@/lib/auth"; import { db } from "@/lib/db"; +import { AuthProvider } from "@/providers/AuthProvider"; import { Empty } from "@unkey/ui"; import Link from "next/link"; import { redirect } from "next/navigation"; @@ -29,57 +31,63 @@ export default async function Layout({ children }: LayoutProps) { return redirect("/new"); } + // Generate a secure random UUID on the server + // this will be a fall-back if there isn't already a cookie + const serverGeneratedIdentity = randomUUID(); + return ( -
- -
- {/* Desktop Sidebar */} - + +
+ +
+ {/* Desktop Sidebar */} + - {/* Main content area */} -
-
- {/* Mobile sidebar at the top of content */} - + {/* Main content area */} +
+
+ {/* Mobile sidebar at the top of content */} + -
- {workspace.enabled ? ( - {children} - ) : ( -
- - - This workspace is disabled - - Contact{" "} - - support@unkey.dev - - - -
- )} -
-
- {isImpersonator ? ( -
-
- Impersonation Mode. Do not change anything and log out after you are done. +
+ {workspace.enabled ? ( + {children} + ) : ( +
+ + + This workspace is disabled + + Contact{" "} + + support@unkey.dev + + + +
+ )}
- ) : null} + {isImpersonator ? ( +
+
+ Impersonation Mode. Do not change anything and log out after you are done. +
+
+ ) : null} +
-
- -
+ +
+ ); } diff --git a/apps/dashboard/app/auth/actions.ts b/apps/dashboard/app/auth/actions.ts index 9a74a585de..245d14cdb0 100644 --- a/apps/dashboard/app/auth/actions.ts +++ b/apps/dashboard/app/auth/actions.ts @@ -185,9 +185,9 @@ export async function completeOrgSelection( // Used in route handlers, like join export async function switchOrg(orgId: string): Promise<{ success: boolean; error?: string }> { try { - const { newToken, expiresAt } = await auth.switchOrg(orgId); + const { sessionToken, expiresAt } = await auth.switchOrg(orgId); - await SetSessionCookie({ token: newToken, expiresAt }); + await SetSessionCookie({ token: sessionToken, expiresAt }); return { success: true }; } catch (error) { diff --git a/apps/dashboard/app/auth/sso-callback/[[...sso-callback]]/route.ts b/apps/dashboard/app/auth/sso-callback/[[...sso-callback]]/route.ts index 4f510e1339..da4c61c7d1 100644 --- a/apps/dashboard/app/auth/sso-callback/[[...sso-callback]]/route.ts +++ b/apps/dashboard/app/auth/sso-callback/[[...sso-callback]]/route.ts @@ -4,7 +4,6 @@ import { AuthErrorCode, SIGN_IN_URL } from "@/lib/auth/types"; import { type NextRequest, NextResponse } from "next/server"; export async function GET(request: NextRequest) { const authResult = await auth.completeOAuthSignIn(request); - if (!authResult.success) { if ( (authResult.code === AuthErrorCode.ORGANIZATION_SELECTION_REQUIRED || @@ -25,7 +24,6 @@ export async function GET(request: NextRequest) { } const response = NextResponse.redirect(url); - return await setCookiesOnResponse(response, authResult.cookies); } diff --git a/apps/dashboard/app/new/create-workspace.tsx b/apps/dashboard/app/new/create-workspace.tsx index 2095b29158..1d5ae6e0d2 100644 --- a/apps/dashboard/app/new/create-workspace.tsx +++ b/apps/dashboard/app/new/create-workspace.tsx @@ -42,7 +42,7 @@ export const CreateWorkspace: React.FC = () => { options: { httpOnly: true, secure: true, - sameSite: "lax", + sameSite: "strict", path: "/", maxAge: Math.floor((sessionData.expiresAt!.getTime() - Date.now()) / 1000), }, diff --git a/apps/dashboard/lib/auth.ts b/apps/dashboard/lib/auth.ts index a9c4b17b87..30324254dc 100644 --- a/apps/dashboard/lib/auth.ts +++ b/apps/dashboard/lib/auth.ts @@ -1,14 +1,9 @@ -import { getAuth as noCacheGetAuth } from "@/lib/auth/get-auth"; +import { getAuth as baseGetAuth } from "@/lib/auth/get-auth"; import { auth } from "@/lib/auth/server"; -import type { User } from "@/lib/auth/types"; +import type { AuthResult, User } from "@/lib/auth/types"; import { redirect } from "next/navigation"; import { cache } from "react"; -type GetAuthResult = { - userId: string | null; - orgId: string | null; -}; - export async function getIsImpersonator(): Promise { const user = await getCurrentUser(); return user.impersonator !== undefined; @@ -26,8 +21,8 @@ export async function getIsImpersonator(): Promise { * @returns Authentication result containing userId and orgId if authenticated, null values otherwise * @throws Redirects to sign-in or organization/workspace creation pages if requirements aren't met */ -export const getAuth = cache(async (_req?: Request): Promise => { - const authResult = await noCacheGetAuth(); +export const getAuth = cache(async (_req?: Request): Promise => { + const authResult = await baseGetAuth(); if (!authResult.userId) { redirect("/auth/sign-in"); } diff --git a/apps/dashboard/lib/auth/base-provider.ts b/apps/dashboard/lib/auth/base-provider.ts index 8f6cc97d6b..e7b9765727 100644 --- a/apps/dashboard/lib/auth/base-provider.ts +++ b/apps/dashboard/lib/auth/base-provider.ts @@ -25,57 +25,259 @@ import { errorMessages, } from "./types"; +/** + * Abstract base class providing authentication and authorization functionality. + * Implements core methods for session management, user authentication, and organization control. + * + * Extend this class to implement provider-specific authentication logic. + */ export abstract class BaseAuthProvider { - // Session Management + /** + * Validates a user session token. + * + * @param sessionToken - The session token to validate + * @returns Promise resolving to a session validation result + */ abstract validateSession(sessionToken: string): Promise; - abstract refreshSession(sessionToken: string): Promise; - // Authentication + /** + * Refreshes an expired access token using a refresh token. + * + * @param currentRefreshToken - The refresh token to use for obtaining a new access token + * @returns Promise resolving to new session tokens + */ + abstract refreshAccessToken(currentRefreshToken: string): Promise; + + /** + * Initiates email-based authentication by sending an authentication code. + * + * @param email - The email address to authenticate + * @returns Promise resolving to email authentication result + */ abstract signInViaEmail(email: string): Promise; + + /** + * Verifies an authentication code sent to a user's email. + * + * @param params - Object containing verification parameters + * @param params.email - The email address that received the code + * @param params.code - The authentication code to verify + * @param params.invitationToken - Optional invitation token for invited users + * @returns Promise resolving to verification result + */ abstract verifyAuthCode(params: { email: string; code: string; invitationToken?: string; }): Promise; + + /** + * Verifies a user's email address using a verification code. + * + * @param params - Object containing verification parameters + * @param params.code - The verification code to check + * @param params.token - The token associated with this verification + * @returns Promise resolving to verification result + */ abstract verifyEmail(params: { code: string; token: string }): Promise; + + /** + * Resends an authentication code to a user's email address. + * + * @param email - The email address to resend the code to + * @returns Promise resolving to email authentication result + */ abstract resendAuthCode(email: string): Promise; + + /** + * Registers a new user via email. + * + * @param params - User data required for registration + * @returns Promise resolving to email authentication result + */ abstract signUpViaEmail(params: UserData): Promise; + + /** + * Gets the URL used for signing out users. + * + * @returns Promise resolving to the sign-out URL or null if not applicable + */ abstract getSignOutUrl(): Promise; + + /** + * Completes the organization selection process after authentication. + * + * @param params - Object containing selection parameters + * @param params.orgId - The ID of the selected organization + * @param params.pendingAuthToken - The pending authentication token + * @returns Promise resolving to verification result + */ abstract completeOrgSelection(params: { orgId: string; pendingAuthToken: string; }): Promise; - // OAuth Authentication + /** + * Generates a URL for OAuth authentication with a third-party provider. + * + * @param options - OAuth configuration options + * @returns URL string for redirecting to the OAuth provider + */ abstract signInViaOAuth(options: SignInViaOAuthOptions): string; + + /** + * Handles the OAuth callback after successful third-party authentication. + * + * @param callbackRequest - The request object from the OAuth callback + * @returns Promise resolving to OAuth result + */ abstract completeOAuthSignIn(callbackRequest: Request): Promise; - // User Management + /** + * Retrieves the currently authenticated user. + * + * @returns Promise resolving to the current user or null if not authenticated + */ abstract getCurrentUser(): Promise; + + /** + * Retrieves a user by their unique ID. + * + * @param userId - The ID of the user to retrieve + * @returns Promise resolving to the user or null if not found + */ abstract getUser(userId: string): Promise; + + /** + * Finds a user by their email address. + * + * @param email - The email address to search for + * @returns Promise resolving to the user or null if not found + */ abstract findUser(email: string): Promise; - // Organization Management + /** + * Creates a new tenant (organization) for a specific user. + * + * @param params - Object containing tenant creation parameters + * @param params.name - The name of the tenant to create + * @param params.userId - The ID of the user who will own the tenant + * @returns Promise resolving to the ID of the created tenant + */ abstract createTenant(params: { name: string; userId: string }): Promise; + + /** + * Updates an organization's details. + * + * @param params - Organization update parameters + * @returns Promise resolving to the updated organization + */ abstract updateOrg(params: UpdateOrgParams): Promise; + + /** + * Creates a new organization with the given name. + * Protected method intended for internal use by subclasses. + * + * @param name - The name of the organization to create + * @returns Promise resolving to the created organization + */ protected abstract createOrg(name: string): Promise; + + /** + * Retrieves an organization by its ID. + * + * @param orgId - The ID of the organization to retrieve + * @returns Promise resolving to the organization + */ abstract getOrg(orgId: string): Promise; + + /** + * Switches the current session to a different organization. + * + * @param newOrgId - The ID of the organization to switch to + * @returns Promise resolving to new session tokens + */ abstract switchOrg(newOrgId: string): Promise; - // Membership Management + /** + * Lists all memberships for a specific user. + * + * @param userId - The ID of the user whose memberships to list + * @returns Promise resolving to the membership list response + */ abstract listMemberships(userId: string): Promise; + + /** + * Retrieves all members of a specific organization. + * + * @param orgId - The ID of the organization to list members for + * @returns Promise resolving to the membership list response + */ abstract getOrganizationMemberList(orgId: string): Promise; + + /** + * Updates a user's membership properties. + * + * @param params - Membership update parameters + * @returns Promise resolving to the updated membership + */ abstract updateMembership(params: UpdateMembershipParams): Promise; + + /** + * Removes a user from an organization. + * + * @param membershipId - The ID of the membership to remove + * @returns Promise resolving when the membership is removed + */ abstract removeMembership(membershipId: string): Promise; - // Invitation Management + /** + * Invites a new member to join an organization. + * + * @param params - Organization invitation parameters + * @returns Promise resolving to the created invitation + */ abstract inviteMember(params: OrgInviteParams): Promise; + + /** + * Retrieves all pending invitations for an organization. + * + * @param orgId - The ID of the organization to list invitations for + * @returns Promise resolving to the invitation list response + */ abstract getInvitationList(orgId: string): Promise; + + /** + * Retrieves an invitation by its token. + * + * @param invitationToken - The token identifying the invitation + * @returns Promise resolving to the invitation or null if not found + */ abstract getInvitation(invitationToken: string): Promise; + + /** + * Revokes a pending organization invitation. + * + * @param invitationId - The ID of the invitation to revoke + * @returns Promise resolving when the invitation is revoked + */ abstract revokeOrgInvitation(invitationId: string): Promise; + + /** + * Accepts a pending organization invitation. + * + * @param invitationId - The ID of the invitation to accept + * @returns Promise resolving to the accepted invitation + */ abstract acceptInvitation(invitationId: string): Promise; - // Error Handling + /** + * Standardizes error handling across all authentication operations. + * Converts various error types to a consistent AuthErrorResponse format. + * + * @param error - The error to handle + * @returns Standardized authentication error response + */ protected handleError(error: unknown): AuthErrorResponse { console.error("Auth error:", error); @@ -108,11 +310,22 @@ export abstract class BaseAuthProvider { }; } - // Utility Methods + /** + * Creates a standard response for state change operations. + * + * @returns Standard state change response object + */ protected createStateChangeResponse(): StateChangeResponse { return { success: true }; } + /** + * Creates a standard response for operations requiring navigation. + * + * @param redirectTo - The URL to redirect to + * @param cookies - Cookies to include in the response + * @returns Standard navigation response object + */ protected createNavigationResponse( redirectTo: string, cookies: NavigationResponse["cookies"], @@ -124,11 +337,25 @@ export abstract class BaseAuthProvider { }; } + /** + * Determines if a path is public (exempt from authentication). + * + * @param pathname - The path to check + * @param publicPaths - Array of paths considered public + * @returns True if the path is public, false otherwise + */ protected isPublicPath(pathname: string, publicPaths: string[]): boolean { const isPublic = publicPaths.some((path) => pathname.startsWith(path)); return isPublic; } + /** + * Creates a response that redirects to the login page. + * + * @param request - The original request + * @param config - Middleware configuration + * @returns NextResponse configured to redirect to login + */ protected redirectToLogin(request: NextRequest, config: MiddlewareConfig): NextResponse { const signInUrl = new URL(config.loginPath, request.url); signInUrl.searchParams.set("redirect", request.nextUrl.pathname); diff --git a/apps/dashboard/lib/auth/cookies.ts b/apps/dashboard/lib/auth/cookies.ts index 870b9003b5..374f0195f3 100644 --- a/apps/dashboard/lib/auth/cookies.ts +++ b/apps/dashboard/lib/auth/cookies.ts @@ -70,7 +70,7 @@ export async function updateCookie( options: { httpOnly: true, secure: true, - sameSite: "lax", + sameSite: "strict", }, }); return; @@ -83,7 +83,7 @@ export async function updateCookie( } /** - * Set cookies on a NextResponse object + * Set cookies on a Response object * Useful when you need to set cookies during a redirect */ export async function setCookiesOnResponse( @@ -112,7 +112,7 @@ export async function SetSessionCookie(params: { options: { httpOnly: true, secure: true, - sameSite: "lax", + sameSite: "strict", path: "/", maxAge: Math.floor((expiresAt.getTime() - Date.now()) / 1000), }, diff --git a/apps/dashboard/lib/auth/get-auth.ts b/apps/dashboard/lib/auth/get-auth.ts index d469c775bc..b40fde8719 100644 --- a/apps/dashboard/lib/auth/get-auth.ts +++ b/apps/dashboard/lib/auth/get-auth.ts @@ -1,64 +1,245 @@ +import { cache } from "react"; +import { env } from "../env"; import { getCookie } from "./cookies"; import { auth } from "./server"; -import { UNKEY_SESSION_COOKIE } from "./types"; +import { tokenManager } from "./token-management-service"; +import { + type AuthResult, + type SessionValidationResult, + UNKEY_ACCESS_MAX_AGE, + UNKEY_ACCESS_TOKEN, + UNKEY_REFRESH_TOKEN, + UNKEY_SESSION_COOKIE, + UNKEY_USER_IDENTITY_COOKIE, +} from "./types"; -type GetAuthResult = { - userId: string | null; - orgId: string | null; -}; +const CACHE_TTL = 10 * 1000; -export async function getAuth(_req?: Request): Promise { +/** + * Simple cache for validation + * + * getAuth is called in the trpc context for every trpc route to ensure authentication. + * Pages may have client components that call multiple trpc routers to fetch data, + * resulting in validation being called multiple times. + * This cache will store the validation value for a short time, e.g. 10 seconds, + * just long enough for the concurrent trpc routes to use the same value before being invalidated. + */ +const sessionValidationCache = new Map< + string, + { + result: { + isValid: boolean; + shouldRefresh: boolean; + userId?: string; + orgId?: string | null; + role?: string | null; + accessToken?: string; + expiresAt?: Date; + }; + expiresAt: number; + } +>(); + +export async function validateSession( + sessionToken: string | null, +): Promise { + if (!sessionToken) { + return { isValid: false, shouldRefresh: false }; + } try { - const sessionToken = await getCookie(UNKEY_SESSION_COOKIE); + return await auth.validateSession(sessionToken); + } catch (error) { + console.error("Session validation error:", error); + return { isValid: false, shouldRefresh: false }; + } +} + +// Per-user mutex tracking for refresh operations +// maps user tokens to their refresh operation promises +// TODO: move this to redis +const refreshOperations = new Map>(); + +// Refresh token with per-user mutex protection, using the refresh token as the key +async function refreshTokenWithUserMutex( + refreshToken: string, + baseUrl: string, + userIdentity: string | null = null, +): Promise { + // If we have a user identity, verify token ownership + if (userIdentity) { + const isValidOwner = tokenManager.verifyTokenOwnership({ + refreshToken, + userIdentity, + }); + + if (!isValidOwner) { + console.warn("Refresh token ownership verification failed"); + return { userId: null, orgId: null, role: null }; + } + } + + // If refresh is already in progress for this specific user, wait for it + if (refreshOperations.has(refreshToken)) { + try { + return await refreshOperations.get(refreshToken)!; + } catch (error) { + console.error("Error while waiting for refresh:", error); + // fall-through to continue with a new refresh attempt if the existing one fails + } + } + + // Create a refresh promise for this specific user + const refreshPromise = (async (): Promise => { + try { + // Prepare headers + const headers: Record = {}; + + // Add the refresh token to headers + headers["x-refresh-token"] = refreshToken; + + // Add user identity if available + if (userIdentity) { + headers["x-user-identity"] = userIdentity; + } + + const refreshResult = await fetch(`${baseUrl}/api/auth/refresh`, { + method: "POST", + credentials: "include", + headers, + }); + + if (!refreshResult.ok) { + throw new Error(`Refresh failed: ${refreshResult.status}`); + } + + const refreshedData = await refreshResult.json(); + + // If we received a new refresh token and have user identity, update mapping + if ( + refreshedData.refreshToken && + refreshedData.refreshToken !== refreshToken && + userIdentity + ) { + tokenManager.updateTokenOwnership({ + oldToken: refreshToken, + newToken: refreshedData.refreshToken, + userIdentity, + }); + } - if (!sessionToken) { return { - userId: null, - orgId: null, + userId: refreshedData.userId || null, + orgId: refreshedData.orgId || null, + role: refreshedData.role || null, + accessToken: refreshedData.accessToken || null, + expiresAt: refreshedData.expiresAt || null, }; + } catch (error) { + console.error("Refresh error:", error); + return { userId: null, orgId: null, role: null }; + } finally { + // only remove this user's refresh operation when done + refreshOperations.delete(refreshToken); } + })(); - // Validate session - const validationResult = await auth.validateSession(sessionToken); - - let userId: string | undefined; - let orgId: string | null | undefined; - - if (!validationResult.isValid) { - if (validationResult.shouldRefresh) { - try { - const refreshedData = await auth.refreshSession(sessionToken); - if (!refreshedData.session) { - return { userId: null, orgId: null }; - } - userId = refreshedData.session.userId; - orgId = refreshedData.session.orgId; - } catch (error) { - console.error(error); - return { userId: null, orgId: null }; + // store this user's refresh promise in the map + refreshOperations.set(refreshToken, refreshPromise); + + // wait for the refresh to complete + return await refreshPromise; +} + +// main getAuth function using both caching and per-user mutex +export const getAuth = cache(async (_req?: Request): Promise => { + const VERCEL_URL = env().VERCEL_URL; + const baseUrl = VERCEL_URL || "http://localhost:3000"; + + try { + const sessionToken = await getCookie(UNKEY_SESSION_COOKIE); + const refreshToken = await getCookie(UNKEY_REFRESH_TOKEN); + const accessToken = await getCookie(UNKEY_ACCESS_TOKEN); + const userIdentity = await getCookie(UNKEY_USER_IDENTITY_COOKIE); + + if (!sessionToken || !refreshToken) { + return { userId: null, orgId: null, role: null }; + } + + // Check if we have a valid access token first + if (accessToken) { + // Use cache to reduce validation calls + const cacheKey = `${sessionToken}:${accessToken}:${refreshToken}`; + const cachedValidation = sessionValidationCache.get(cacheKey); + + if (cachedValidation && cachedValidation.expiresAt > Date.now()) { + const { result } = cachedValidation; + + // If cached result says session is valid, return the user data with access token + if (result.isValid && result.userId) { + return { + userId: result.userId, + orgId: result.orgId ?? null, + role: result.role ?? null, + accessToken: result.accessToken ?? null, + expiresAt: result.expiresAt ?? null, + }; + } + + // If cached result says refresh is needed, do that + if (result.shouldRefresh) { + return await refreshTokenWithUserMutex(refreshToken, baseUrl, userIdentity); } - } else { - return { userId: null, orgId: null }; + + // Session invalid and refresh not recommended + return { userId: null, orgId: null, role: null }; } - } else { - userId = validationResult.userId; - orgId = validationResult.orgId; } - // we should have user data from either validation or refresh - if (!userId) { - return { userId: null, orgId: null }; + // Validate the session + const validationResult = await validateSession(sessionToken); + + // Only cache valid results that have an access token + if (validationResult.isValid && validationResult.accessToken) { + const cacheKey = `${sessionToken}:${validationResult.accessToken}:${refreshToken}`; + sessionValidationCache.set(cacheKey, { + result: validationResult, + expiresAt: Date.now() + CACHE_TTL, + }); } - return { - userId, - orgId: orgId ?? null, - }; + // If session is valid, return user data with access token + if (validationResult.isValid && validationResult.userId) { + return { + userId: validationResult.userId, + orgId: validationResult.orgId ?? null, + role: validationResult.role ?? null, + accessToken: validationResult.accessToken ?? null, + expiresAt: new Date(Date.now() + UNKEY_ACCESS_MAX_AGE), + }; + } + + // If refresh is needed, use per-user mutex-protected refresh + if (validationResult.shouldRefresh) { + return await refreshTokenWithUserMutex(refreshToken, baseUrl, userIdentity); + } + + // Session is invalid and refresh not recommended + return { userId: null, orgId: null, role: null }; } catch (error) { - console.error("Auth validation error:", error); - return { - userId: null, - orgId: null, - }; + console.error("Auth error:", error); + return { userId: null, orgId: null, role: null }; } +}); + +// Export a function to clear a specific user's refresh operation +// useful to forcibly clear a stuck refresh operation +export function clearUserRefreshOperation(refreshToken: string): boolean { + const hadOperation = refreshOperations.has(refreshToken); + refreshOperations.delete(refreshToken); + return hadOperation; +} + +// for debugging: get count of ongoing refresh operations +export function getOngoingRefreshCount(): number { + return refreshOperations.size; } diff --git a/apps/dashboard/lib/auth/token-management-service.ts b/apps/dashboard/lib/auth/token-management-service.ts new file mode 100644 index 0000000000..8f3a3a737b --- /dev/null +++ b/apps/dashboard/lib/auth/token-management-service.ts @@ -0,0 +1,80 @@ +/** + * Service for managing refresh token ownership verification + * TODO: Replace with Redis or other global implementation (Planetscale?) + */ +export class TokenManager { + private static instance: TokenManager; + private tokenOwners: Map = new Map(); + + private constructor() { + // Private constructor to enforce singleton + } + + /** + * Get the singleton instance + */ + public static getInstance(): TokenManager { + if (!TokenManager.instance) { + TokenManager.instance = new TokenManager(); + } + return TokenManager.instance; + } + + /** + * Check if a refresh token is owned by the given user identity + * If no ownership is recorded, associate the token with this user + */ + public verifyTokenOwnership({ + refreshToken, + userIdentity, + }: { + refreshToken: string; + userIdentity: string; + }): boolean { + const owner = this.tokenOwners.get(refreshToken); + + // If we don't have ownership data, create it now + if (!owner) { + this.tokenOwners.set(refreshToken, userIdentity); + return true; + } + + return owner === userIdentity; + } + + /** + * Update token ownership mapping after a successful refresh + */ + public updateTokenOwnership({ + oldToken, + newToken, + userIdentity, + }: { + oldToken: string; + newToken: string; + userIdentity: string; + }): void { + // Remove the old mapping + this.tokenOwners.delete(oldToken); + + // Add the new mapping + this.tokenOwners.set(newToken, userIdentity); + } + + /** + * Remove a token from the ownership mapping + */ + public removeToken(token: string): boolean { + return this.tokenOwners.delete(token); + } + + /** + * Get the count of tracked tokens (for debugging) + */ + public getTokenCount(): number { + return this.tokenOwners.size; + } +} + +// Export a singleton instance +export const tokenManager = TokenManager.getInstance(); diff --git a/apps/dashboard/lib/auth/types.ts b/apps/dashboard/lib/auth/types.ts index e1e4d08dde..c2f616cda3 100644 --- a/apps/dashboard/lib/auth/types.ts +++ b/apps/dashboard/lib/auth/types.ts @@ -1,11 +1,19 @@ import type { Cookie } from "./cookies"; -// Core Types +// consts export const UNKEY_SESSION_COOKIE = "unkey-session"; +export const UNKEY_ACCESS_TOKEN = "unkey-access-token"; +export const UNKEY_REFRESH_TOKEN = "unkey-refresh-token"; +export const UNKEY_USER_IDENTITY_COOKIE = "unkey-user-identity"; export const PENDING_SESSION_COOKIE = "sess-temp"; export const SIGN_IN_URL = "/auth/sign-in"; export const SIGN_UP_URL = "/auth/sign-up"; +// Token expiration (in milliseconds) +export const UNKEY_ACCESS_MAX_AGE = 5 * 60 * 1000; // 5 minutes +export const UNKEY_REFRESH_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days +export const UNKEY_USER_IDENTITY_MAX_AGE = 90 * 24 * 60 * 60 * 1000; // 90 days + export interface User { id: string; orgId: string | null; @@ -39,6 +47,14 @@ interface AuthResponse { success: boolean; } +export type AuthResult = { + userId: string | null; + orgId: string | null; + role: string | null; + accessToken?: string | null; + expiresAt?: Date | null; +}; + // State change responses (for operations that update UI state) export interface StateChangeResponse extends AuthResponse { success: true; @@ -96,16 +112,32 @@ export type MembershipListResponse = ListResponse; export type InvitationListResponse = ListResponse; // Session Types -export interface SessionValidationResult { +interface BaseSessionValidationResult { isValid: boolean; shouldRefresh: boolean; - token?: string; - userId?: string; +} + +// valid sessions +interface ValidSessionResult extends BaseSessionValidationResult { + isValid: true; + sessionToken: string; + accessToken: string; + userId: string; orgId?: string | null; + role?: string | null; } +// invalid sessions +interface InvalidSessionResult extends BaseSessionValidationResult { + isValid: false; +} + +export type SessionValidationResult = ValidSessionResult | InvalidSessionResult; + export interface SessionRefreshResult { - newToken: string; + sessionToken: string; + accessToken?: string; + refreshToken?: string; expiresAt: Date; session: SessionData | null; } @@ -113,6 +145,7 @@ export interface SessionRefreshResult { export interface SessionData { userId: string; orgId: string | null; + role?: string | null; } // OAuth Types diff --git a/apps/dashboard/lib/auth/workos.ts b/apps/dashboard/lib/auth/workos.ts index 0437f9f4e9..54537098c9 100644 --- a/apps/dashboard/lib/auth/workos.ts +++ b/apps/dashboard/lib/auth/workos.ts @@ -21,6 +21,8 @@ import { type SessionRefreshResult, type SessionValidationResult, type SignInViaOAuthOptions, + UNKEY_ACCESS_TOKEN, + UNKEY_REFRESH_TOKEN, UNKEY_SESSION_COOKIE, type UpdateMembershipParams, type UpdateOrgParams, @@ -68,13 +70,20 @@ export class WorkOSAuthProvider extends BaseAuthProvider { if (authResult.authenticated) { return { isValid: true, + sessionToken: sessionToken, //return the same sessionToken + accessToken: authResult.accessToken, shouldRefresh: false, userId: authResult.user.id, orgId: authResult.organizationId ?? null, + role: authResult.role ?? null, }; } - return { isValid: false, shouldRefresh: true }; + // signal attempt to refresh + return { + isValid: false, + shouldRefresh: true, + }; } catch (error) { console.error("Session validation error:", { error: error instanceof Error ? error.message : "Unknown error", @@ -84,42 +93,45 @@ export class WorkOSAuthProvider extends BaseAuthProvider { } } - async refreshSession(sessionToken: string | null): Promise { - if (!sessionToken) { - throw new Error("No session token provided"); + async refreshAccessToken(currentRefreshToken: string | null): Promise { + if (!currentRefreshToken) { + throw new Error("No refresh token provided"); } - try { - const session = this.provider.userManagement.loadSealedSession({ - sessionData: sessionToken, - cookiePassword: this.cookiePassword, - }); - - const refreshResult = await session.refresh({ - cookiePassword: this.cookiePassword, - }); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); - if (refreshResult.authenticated && refreshResult.session) { - // Set expiration to 7 days from now - const expiresAt = new Date(); - expiresAt.setDate(expiresAt.getDate() + 7); - - return { - newToken: refreshResult.sealedSession!, - expiresAt, + try { + const { sealedSession, refreshToken, accessToken, user, organizationId } = + await this.provider.userManagement.authenticateWithRefreshToken({ + clientId: this.clientId, + refreshToken: currentRefreshToken, session: { - userId: refreshResult.session.user.id, - orgId: refreshResult.session.organizationId ?? null, + sealSession: true, + cookiePassword: this.cookiePassword, }, - }; + }); + + if (!sealedSession) { + // ensure that the sealedSession boolean is true + // otherwise WorkOS messed up + // make typescript happy with additional guard + throw new Error("Missing sealed session."); } - throw new Error("reason" in refreshResult ? refreshResult.reason : "Session refresh failed"); + return { + expiresAt, + sessionToken: sealedSession, + accessToken, + refreshToken, + session: { + userId: user.id, + orgId: organizationId ?? null, + role: null, // not returned in authenticateWithRefreshToken + }, + }; } catch (error) { - console.error("Session refresh error:", { - error: error instanceof Error ? error.message : "Unknown error", - token: sessionToken ? `${sessionToken.substring(0, 10)}...` : "no token", - }); + console.error("Access token failed to refresh: ", error); throw error; } } @@ -285,16 +297,23 @@ export class WorkOSAuthProvider extends BaseAuthProvider { throw new Error(`Organization switch failed ${errMsg}`); } + if (!refreshResult.sealedSession) { + // WorkOS messed up, it should always come back if authenticated + // make typescript happy with guard anyway + throw new Error("Missing sealed session"); + } + // Set expiration to 7 days from now const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + 7); return { - newToken: refreshResult.sealedSession!, + sessionToken: refreshResult.sealedSession, expiresAt, session: { userId: refreshResult.session.user.id, - orgId: newOrgId, + orgId: refreshResult.session.organizationId ?? null, + role: null, // doesn't come back on this session helper for some reason, but it comes back in others that use this return type }, }; } catch (error) { @@ -618,7 +637,7 @@ export class WorkOSAuthProvider extends BaseAuthProvider { options: { secure: true, httpOnly: true, - sameSite: "lax", + sameSite: "strict", }, }, ], @@ -639,7 +658,7 @@ export class WorkOSAuthProvider extends BaseAuthProvider { options: { secure: true, httpOnly: true, - sameSite: "lax", + sameSite: "strict", }, }, ], @@ -681,7 +700,7 @@ export class WorkOSAuthProvider extends BaseAuthProvider { options: { secure: true, httpOnly: true, - sameSite: "lax", + sameSite: "strict", }, }, ], @@ -703,7 +722,7 @@ export class WorkOSAuthProvider extends BaseAuthProvider { options: { secure: true, httpOnly: true, - sameSite: "lax", + sameSite: "strict", }, }, ], @@ -718,7 +737,7 @@ export class WorkOSAuthProvider extends BaseAuthProvider { pendingAuthToken: string; }): Promise { try { - const { sealedSession } = + const { sealedSession, accessToken, refreshToken } = await this.provider.userManagement.authenticateWithOrganizationSelection({ pendingAuthenticationToken: params.pendingAuthToken, organizationId: params.orgId, @@ -743,7 +762,25 @@ export class WorkOSAuthProvider extends BaseAuthProvider { options: { secure: true, httpOnly: true, - sameSite: "lax", + sameSite: "strict", + }, + }, + { + name: UNKEY_ACCESS_TOKEN, + value: accessToken, + options: { + secure: true, + httpOnly: true, + sameSite: "strict", + }, + }, + { + name: UNKEY_REFRESH_TOKEN, + value: refreshToken, + options: { + secure: true, + httpOnly: true, + sameSite: "strict", }, }, ], @@ -798,14 +835,15 @@ export class WorkOSAuthProvider extends BaseAuthProvider { } try { - const { sealedSession } = await this.provider.userManagement.authenticateWithCode({ - clientId: this.clientId, - code, - session: { - sealSession: true, - cookiePassword: this.cookiePassword, - }, - }); + const { sealedSession, accessToken, refreshToken } = + await this.provider.userManagement.authenticateWithCode({ + clientId: this.clientId, + code, + session: { + sealSession: true, + cookiePassword: this.cookiePassword, + }, + }); if (!sealedSession) { throw new Error("No sealed session returned"); @@ -825,7 +863,25 @@ export class WorkOSAuthProvider extends BaseAuthProvider { options: { secure: true, httpOnly: true, - sameSite: "lax", + sameSite: "strict", + }, + }, + { + name: UNKEY_ACCESS_TOKEN, + value: accessToken, + options: { + secure: true, + httpOnly: true, + sameSite: "strict", + }, + }, + { + name: UNKEY_REFRESH_TOKEN, + value: refreshToken, + options: { + secure: true, + httpOnly: true, + sameSite: "strict", }, }, ], @@ -845,7 +901,7 @@ export class WorkOSAuthProvider extends BaseAuthProvider { options: { secure: true, httpOnly: true, - sameSite: "lax", + sameSite: "strict", maxAge: 60, // user has 60 seconds to select an org before the cookie expires }, }, @@ -869,7 +925,7 @@ export class WorkOSAuthProvider extends BaseAuthProvider { options: { secure: true, httpOnly: true, - sameSite: "lax", + sameSite: "strict", maxAge: 60 * 10, // user has 10 mins seconds to verify their email before the cookie expires }, }, diff --git a/apps/dashboard/lib/trpc/routers/user/switchOrg.ts b/apps/dashboard/lib/trpc/routers/user/switchOrg.ts index cf01e31743..cb0e72af34 100644 --- a/apps/dashboard/lib/trpc/routers/user/switchOrg.ts +++ b/apps/dashboard/lib/trpc/routers/user/switchOrg.ts @@ -7,10 +7,10 @@ export const switchOrg = t.procedure .input(z.string()) .mutation(async ({ input: orgId }) => { try { - const { newToken, expiresAt } = await authProvider.switchOrg(orgId); + const { sessionToken, expiresAt } = await authProvider.switchOrg(orgId); return { success: true, - token: newToken, + token: sessionToken, expiresAt, }; } catch (error) { diff --git a/apps/dashboard/providers/AuthProvider.tsx b/apps/dashboard/providers/AuthProvider.tsx new file mode 100644 index 0000000000..2f01cfade7 --- /dev/null +++ b/apps/dashboard/providers/AuthProvider.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { UNKEY_USER_IDENTITY_COOKIE, UNKEY_USER_IDENTITY_MAX_AGE } from "@/lib/auth/types"; +import { useRouter } from "next/navigation"; +import { createContext, useCallback, useEffect, useState } from "react"; + +// Types +type AuthContextType = { + isAuthenticated: boolean; + isLoading: boolean; + accessToken: string | null; + expiresAt: Date | null; + refreshSession: () => Promise; +}; + +// Create the context with a default value +const AuthContext = createContext({ + isAuthenticated: false, + isLoading: true, + accessToken: null, + expiresAt: null, + refreshSession: async () => false, +}); + +// Helper function to get a cookie client-side +const getCookie = (name: string): string | null => { + const cookies = document.cookie.split(";"); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.startsWith(`${name}=`)) { + return cookie.substring(name.length + 1); + } + } + return null; +}; + +// Helper function to set a cookie client-side +const setCookie = ({ + name, + value, + maxAge, +}: { name: string; value: string; maxAge: number }): void => { + document.cookie = `${name}=${value}; path=/; max-age=${maxAge}; samesite=strict; secure=${window.location.protocol === "https:"}`; +}; + +export function AuthProvider({ + children, + requireAuth = false, + redirectTo = "/auth/sign-in", + serverGeneratedIdentity, +}: { + children: React.ReactNode; + requireAuth?: boolean; + redirectTo?: string; + serverGeneratedIdentity: string; +}) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [accessToken, setAccessToken] = useState(null); + const [expiresAt, setExpiresAt] = useState(null); + const router = useRouter(); + + // Helper to ensure a user identity exists + const ensureUserIdentity = useCallback((): string => { + // First check for existing cookie + let userIdentity = getCookie(UNKEY_USER_IDENTITY_COOKIE); + + // If we don't have an identity cookie yet, use the server-generated one or create a new one + if (!userIdentity) { + // Use server-generated identity if provided + userIdentity = serverGeneratedIdentity; + } + + // Store for 90 days + setCookie({ + name: UNKEY_USER_IDENTITY_COOKIE, + value: userIdentity, + maxAge: UNKEY_USER_IDENTITY_MAX_AGE, + }); + + return userIdentity; + }, [serverGeneratedIdentity]); + + // Ensure we have a user identity as early as possible + useEffect(() => { + ensureUserIdentity(); + }, [ensureUserIdentity]); + + // Verify authentication status + const checkAuth = useCallback(async () => { + try { + setIsLoading(true); + // This endpoint will use server-side cached getAuth + const response = await fetch("/api/auth/session", { + credentials: "include", + }); + + if (response.ok) { + const sessionData = await response.json(); + setIsAuthenticated(true); + + // Store access token in memory for easy access by client components + if (sessionData.accessToken) { + setAccessToken(sessionData.accessToken); + setExpiresAt(sessionData.expiresAt || null); + } + } else { + setIsAuthenticated(false); + setAccessToken(null); + setExpiresAt(null); + + if (requireAuth) { + router.push(redirectTo); + } + } + } catch (error) { + console.error("Auth check failed:", error); + setIsAuthenticated(false); + setAccessToken(null); + setExpiresAt(null); + + if (requireAuth) { + router.push(redirectTo); + } + } finally { + setIsLoading(false); + } + }, [requireAuth, router, redirectTo]); + + // Refresh session - this will use mutex-protected refresh + const refreshSession = useCallback(async () => { + try { + const userIdentity = ensureUserIdentity(); + + const response = await fetch("/api/auth/refresh", { + method: "POST", + credentials: "include", + headers: { + // Include the user identity in the request headers + "x-user-identity": userIdentity, + }, + }); + + if (response.ok) { + const refreshData = await response.json(); + setIsAuthenticated(true); + + // Update access token in memory + if (refreshData.accessToken) { + setAccessToken(refreshData.accessToken); + setExpiresAt(refreshData.expiresAt || null); + } + + return true; + } + setIsAuthenticated(false); + setAccessToken(null); + setExpiresAt(null); + + if (requireAuth) { + router.push(redirectTo); + } + + return false; + } catch (error) { + console.error("Session refresh failed:", error); + setIsAuthenticated(false); + setAccessToken(null); + setExpiresAt(null); + + if (requireAuth) { + router.push(redirectTo); + } + + return false; + } + }, [requireAuth, router, redirectTo, ensureUserIdentity]); + + // Determine if we need to refresh the token + const shouldRefreshToken = useCallback(() => { + if (!expiresAt) { + return true; + } + + // Refresh if we're within 1 minute of expiration + const now = new Date(); + const timeRemaining = expiresAt.getTime() - now.getTime(); + return timeRemaining < 60 * 1000; // Less than 1 minute remaining + }, [expiresAt]); + + // Set up token refresh interval + useEffect(() => { + let refreshInterval: NodeJS.Timeout; + + if (isAuthenticated) { + // Check tokens periodically and refresh if needed + refreshInterval = setInterval(() => { + if (shouldRefreshToken()) { + refreshSession(); + } + }, 60 * 1000); // Check every minute + } + + return () => { + if (refreshInterval) { + clearInterval(refreshInterval); + } + }; + }, [isAuthenticated, refreshSession, shouldRefreshToken]); + + // Add a refresh listener for when the tab becomes visible again + // helps with token expiration during long periods of inactivity + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === "visible" && isAuthenticated && shouldRefreshToken()) { + refreshSession(); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [isAuthenticated, refreshSession, shouldRefreshToken]); + + // Initial auth check + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + return ( + + {!requireAuth || (requireAuth && isAuthenticated) ? children : null} + + ); +} + +export { AuthContext }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90c634bb38..783171232a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1554,8 +1554,8 @@ packages: js-yaml: 4.1.0 dev: false - /@asamuzakjp/css-color@3.1.3: - resolution: {integrity: sha512-u25AyjuNrRFGb1O7KmWEu0ExN6iJMlUmDSlOPW/11JF8khOrIGG6oCoYpC+4mZlthNVhFUahk68lNrNI91f6Yg==} + /@asamuzakjp/css-color@3.1.4: + resolution: {integrity: sha512-SeuBV4rnjpFNjI8HSgKUwteuFdkHwkboq31HWzznuqgySQir+jSTczoWVVL4jvOjKjuH80fMDG0Fvg1Sb+OJsA==} dependencies: '@csstools/css-calc': 2.1.3(@csstools/css-parser-algorithms@3.0.4)(@csstools/css-tokenizer@3.0.3) '@csstools/css-color-parser': 3.0.9(@csstools/css-parser-algorithms@3.0.4)(@csstools/css-tokenizer@3.0.3) @@ -1581,8 +1581,8 @@ packages: '@stoplight/json': 3.21.0 '@stoplight/json-ref-readers': 1.2.2 '@stoplight/json-ref-resolver': 3.1.6 - '@stoplight/spectral-core': 1.19.5 - '@stoplight/spectral-functions': 1.9.4 + '@stoplight/spectral-core': 1.20.0 + '@stoplight/spectral-functions': 1.10.1 '@stoplight/spectral-parsers': 1.0.5 '@stoplight/spectral-ref-resolver': 1.0.5 '@stoplight/types': 13.20.0 @@ -5130,8 +5130,8 @@ packages: '@babel/core': 7.26.10 '@babel/helper-module-imports': 7.25.9 '@babel/types': 7.27.0 - '@radix-ui/react-dialog': 1.1.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-tooltip': 1.2.3(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-tooltip': 1.2.4(react-dom@18.3.1)(react@18.3.1) '@rollup/pluginutils': 5.1.4 cmdk: 0.2.1(react-dom@18.3.1)(react@18.3.1) esbuild: 0.20.2 @@ -6590,8 +6590,8 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-accordion@1.2.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-stDPylBV/3kFHBAFQK/GeyIFaN7q60zWaXthA5/p6egu8AclIN79zG+bv+Ps+exB4JE5rtW/u3Z7SDvmFuTzgA==} + /@radix-ui/react-accordion@1.2.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-c7OKBvO36PfQIUGIjj1Wko0hH937pYFU2tR5zbIJDUsmTzHoZVHHt4bmb7OOJbzTaWJtVELKWojBHa7OcnUHmQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -6604,7 +6604,7 @@ packages: optional: true dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collapsible': 1.1.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-collapsible': 1.1.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-collection': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.11)(react@18.3.1) @@ -6810,8 +6810,8 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-collapsible@1.1.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-zGFsPcFJNdQa/UNd6MOgF40BS054FIGj32oOWBllixz42f+AkQg3QJ1YT9pw7vs+Ai+EgWkh839h69GEK8oH2A==} + /@radix-ui/react-collapsible@1.1.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-hxEsLvK9WxIAPyxdDRULL4hcaSjMZCfP7fHB0Z1uUnDoDBat1Zh46hwYfa69DeZAbJrPckjf0AGAtEZyvDyJbw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -6827,7 +6827,7 @@ packages: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.11)(react@18.3.1) - '@radix-ui/react-presence': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.11)(react@18.3.1) @@ -7090,8 +7090,8 @@ packages: react-remove-scroll: 2.5.5(@types/react@18.3.11)(react@18.3.1) dev: false - /@radix-ui/react-dialog@1.1.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-m6pZb0gEM5uHPSb+i2nKKGQi/HMSVjARMsLMWQfKDP+eJ6B+uqryHnXhpnohTWElw+vEcMk/o4wJODtdRKHwqg==} + /@radix-ui/react-dialog@1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-yI7S1ipkP5/+99qhSI6nthfo/tR6bL6Zgxi/+1UO6qPa6UeM6nlafWcQ65vB4rU2XjgjMfMhI3k9Y5MztA62VQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -7111,7 +7111,7 @@ packages: '@radix-ui/react-focus-scope': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-portal': 1.1.6(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-presence': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-slot': 1.2.0(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.11)(react@18.3.1) @@ -7631,8 +7631,8 @@ packages: react-remove-scroll: 2.5.7(@types/react@18.3.11)(react@18.3.1) dev: false - /@radix-ui/react-navigation-menu@1.2.9(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-Z7lefjA5VAmEB5ZClxeHGWGQAqhGWgEc6u0MYviUmIVrgGCVLv5mv/jsfUY3tJWI71cVhpQ7dnf/Q6RtM3ylVA==} + /@radix-ui/react-navigation-menu@1.2.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-kGDqMVPj2SRB1vJmXN/jnhC66REAXNyDmDRubbbmJ+360zSIJUDmWGMKIJOf72PHMwPENrbtJVb3CMAUJDjEIA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -7651,7 +7651,7 @@ packages: '@radix-ui/react-direction': 1.1.1(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-dismissable-layer': 1.1.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.11)(react@18.3.1) - '@radix-ui/react-presence': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.11)(react@18.3.1) @@ -7699,8 +7699,8 @@ packages: react-remove-scroll: 2.5.5(@types/react@18.3.11)(react@18.3.1) dev: false - /@radix-ui/react-popover@1.1.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-IZN7b3sXqajiPsOzKuNJBSP9obF4MX5/5UhTgWNofw4r1H+eATWb0SyMlaxPD/kzA4vadFgy1s7Z1AEJ6WMyHQ==} + /@radix-ui/react-popover@1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-yFMfZkVA5G3GJnBgb2PxrrcLKm1ZLWXrbYVgdyTl//0TYEIHS9LJbnyz7WWcZ0qCq7hIlJZpRtxeSeIG5T5oJw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -7721,7 +7721,7 @@ packages: '@radix-ui/react-id': 1.1.1(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-popper': 1.2.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-portal': 1.1.6(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-presence': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-slot': 1.2.0(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.11)(react@18.3.1) @@ -8043,8 +8043,8 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-presence@1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==} + /@radix-ui/react-presence@1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -8311,8 +8311,8 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-scroll-area@1.2.5(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-VyLjxI8/gXYn+Wij1FLpXjZp6Z/uNklUFQQ75tOpJNESeNaZ2kCRfjiEDmHgWmLeUPeJGwrqbgRmcdFjtYEkMA==} + /@radix-ui/react-scroll-area@1.2.6(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-lj8OMlpPERXrQIHlEQdlXHJoRT52AMpBrgyPYylOhXYq5e/glsEdtOc/kCQlsTdtgN5U0iDbrrolDadvektJGQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -8329,7 +8329,7 @@ packages: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.11)(react@18.3.1) - '@radix-ui/react-presence': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.11)(react@18.3.1) @@ -8575,8 +8575,8 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-tabs@1.1.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-4iUaN9SYtG+/E+hJ7jRks/Nv90f+uAsRHbLYA6BcA9EsR6GNWgsvtS4iwU2SP0tOZfDGAyqIT0yz7ckgohEIFA==} + /@radix-ui/react-tabs@1.1.9(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-KIjtwciYvquiW/wAFkELZCVnaNLBsYNhTNcvl+zfMAbMhRkcvNuCLXDDd22L0j7tagpzVh/QwbFpwAATg7ILPw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -8592,7 +8592,7 @@ packages: '@radix-ui/react-context': 1.1.2(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.11)(react@18.3.1) - '@radix-ui/react-presence': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-roving-focus': 1.1.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.11)(react@18.3.1) @@ -8716,8 +8716,8 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-tooltip@1.2.3(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-0KX7jUYFA02np01Y11NWkk6Ip6TqMNmD4ijLelYAzeIndl2aVeltjJFJ2gwjNa1P8U/dgjQ+8cr9Y3Ni+ZNoRA==} + /@radix-ui/react-tooltip@1.2.4(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-DyW8VVeeMSSLFvAmnVnCwvI3H+1tpJFHT50r+tdOoMse9XqYDBCcyux8u3G2y+LOpt7fPQ6KKH0mhs+ce1+Z5w==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -8736,7 +8736,7 @@ packages: '@radix-ui/react-id': 1.1.1(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-popper': 1.2.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-portal': 1.1.6(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-presence': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-slot': 1.2.0(@types/react@18.3.11)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.11)(react@18.3.1) @@ -9742,8 +9742,8 @@ packages: engines: {node: '>=8'} dev: true - /@stoplight/spectral-core@1.19.5: - resolution: {integrity: sha512-i+njdliW7bAHGsHEgDvH0To/9IxiYiBELltkZ7ASVy4i+WXtZ40lQXpeRQRwePrBcSgQl0gcZFuKX10nmSHtbw==} + /@stoplight/spectral-core@1.20.0: + resolution: {integrity: sha512-5hBP81nCC1zn1hJXL/uxPNRKNcB+/pEIHgCjPRpl/w/qy9yC9ver04tw1W0l/PMiv0UeB5dYgozXVQ4j5a6QQQ==} engines: {node: ^16.20 || ^18.18 || >= 20.17} dependencies: '@stoplight/better-ajv-errors': 1.0.3(ajv@8.17.1) @@ -9776,20 +9776,20 @@ packages: engines: {node: ^16.20 || ^18.18 || >= 20.17} dependencies: '@stoplight/json': 3.21.0 - '@stoplight/spectral-core': 1.19.5 + '@stoplight/spectral-core': 1.20.0 '@types/json-schema': 7.0.15 tslib: 2.8.1 transitivePeerDependencies: - encoding dev: true - /@stoplight/spectral-functions@1.9.4: - resolution: {integrity: sha512-+dgu7QQ1JIZFsNLhNbQLPA9tniIT3KjOc9ORv0LYSCLvZjkWT2bN7vgmathbXsbmhnmhvl15H9sRqUIqzi+qoQ==} + /@stoplight/spectral-functions@1.10.1: + resolution: {integrity: sha512-obu8ZfoHxELOapfGsCJixKZXZcffjg+lSoNuttpmUFuDzVLT3VmH8QkPXfOGOL5Pz80BR35ClNAToDkdnYIURg==} engines: {node: ^16.20 || ^18.18 || >= 20.17} dependencies: '@stoplight/better-ajv-errors': 1.0.3(ajv@8.17.1) '@stoplight/json': 3.21.0 - '@stoplight/spectral-core': 1.19.5 + '@stoplight/spectral-core': 1.20.0 '@stoplight/spectral-formats': 1.8.2 '@stoplight/spectral-runtime': 1.1.4 ajv: 8.17.1 @@ -10238,7 +10238,7 @@ packages: /@types/accepts@1.3.7: resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==} dependencies: - '@types/node': 20.14.9 + '@types/node': 22.14.0 dev: false /@types/aria-query@5.0.4: @@ -10249,13 +10249,13 @@ packages: resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} dependencies: '@types/connect': 3.4.38 - '@types/node': 20.14.9 + '@types/node': 22.14.0 dev: false /@types/connect@3.4.38: resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} dependencies: - '@types/node': 20.14.9 + '@types/node': 22.14.0 dev: false /@types/content-disposition@0.5.8: @@ -10265,7 +10265,7 @@ packages: /@types/conventional-commits-parser@5.0.1: resolution: {integrity: sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ==} dependencies: - '@types/node': 20.14.9 + '@types/node': 22.14.0 dev: true optional: true @@ -10287,7 +10287,7 @@ packages: '@types/connect': 3.4.38 '@types/express': 4.17.21 '@types/keygrip': 1.0.6 - '@types/node': 20.14.9 + '@types/node': 22.14.0 dev: false /@types/cors@2.8.17: @@ -10395,7 +10395,7 @@ packages: /@types/express-serve-static-core@4.19.6: resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} dependencies: - '@types/node': 20.14.9 + '@types/node': 22.14.0 '@types/qs': 6.9.18 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 @@ -10460,7 +10460,7 @@ packages: '@types/http-errors': 2.0.4 '@types/keygrip': 1.0.6 '@types/koa-compose': 3.2.8 - '@types/node': 20.14.9 + '@types/node': 22.14.0 dev: false /@types/mdast@4.0.4: @@ -10493,7 +10493,7 @@ packages: /@types/node-fetch@2.6.12: resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} dependencies: - '@types/node': 20.14.9 + '@types/node': 22.14.0 form-data: 4.0.2 /@types/node@12.20.55: @@ -10573,14 +10573,14 @@ packages: resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} dependencies: '@types/mime': 1.3.5 - '@types/node': 20.14.9 + '@types/node': 22.14.0 dev: false /@types/serve-static@1.15.7: resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} dependencies: '@types/http-errors': 2.0.4 - '@types/node': 20.14.9 + '@types/node': 22.14.0 '@types/send': 0.17.4 dev: false @@ -11747,7 +11747,7 @@ packages: hasBin: true dependencies: caniuse-lite: 1.0.30001715 - electron-to-chromium: 1.5.139 + electron-to-chromium: 1.5.140 node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.24.4) @@ -12603,7 +12603,7 @@ packages: resolution: {integrity: sha512-ZgW+Jgdd7i52AaLYCriF8Mxqft0gD/R9i9wi6RWBhs1pqdPEzPjym7rvRKi397WmQFf3SlyUsszhw+VVCbx79Q==} engines: {node: '>=18'} dependencies: - '@asamuzakjp/css-color': 3.1.3 + '@asamuzakjp/css-color': 3.1.4 rrweb-cssom: 0.8.0 dev: true @@ -12851,7 +12851,7 @@ packages: dependencies: is-arguments: 1.2.0 is-date-object: 1.1.0 - is-regex: 1.2.1 + is-regex: 1.1.4 object-is: 1.1.6 object-keys: 1.1.1 regexp.prototype.flags: 1.5.4 @@ -12963,8 +12963,8 @@ packages: engines: {node: '>=8'} dev: true - /detect-libc@2.0.3: - resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + /detect-libc@2.0.4: + resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} /detect-node-es@1.1.0: @@ -13292,8 +13292,8 @@ packages: jake: 10.9.2 dev: true - /electron-to-chromium@1.5.139: - resolution: {integrity: sha512-GGnRYOTdN5LYpwbIr0rwP/ZHOQSvAF6TG0LSzp28uCBb9JiXHJGmaaKw29qjNJc5bGnnp6kXJqRnGMQoELwi5w==} + /electron-to-chromium@1.5.140: + resolution: {integrity: sha512-o82Rj+ONp4Ip7Cl1r7lrqx/pXhbp/lh9DpKcMNscFJdh8ebyRofnc7Sh01B4jx403RI0oqTBvlZ7OBIZLMr2+Q==} /emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} @@ -13399,6 +13399,12 @@ packages: /entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + dev: false + + /entities@6.0.0: + resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==} + engines: {node: '>=0.12'} + dev: true /env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} @@ -13499,8 +13505,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - /es-module-lexer@1.6.0: - resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + /es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} dev: false /es-object-atoms@1.1.1: @@ -14733,7 +14739,7 @@ packages: openapi-sampler: 1.6.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-hook-form: 7.56.0(react@18.3.1) + react-hook-form: 7.56.1(react@18.3.1) remark: 15.0.1 remark-rehype: 11.1.2 shiki: 1.29.2 @@ -14753,7 +14759,7 @@ packages: react: '>= 18' shiki: 1.x.x dependencies: - '@radix-ui/react-popover': 1.1.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-popover': 1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@shikijs/twoslash': 1.29.2(typescript@5.7.3) fumadocs-ui: 14.4.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(next@14.2.15)(react-dom@18.3.1)(react@18.3.1)(tailwindcss@3.4.15) mdast-util-from-markdown: 2.0.2 @@ -14799,15 +14805,15 @@ packages: tailwindcss: optional: true dependencies: - '@radix-ui/react-accordion': 1.2.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-collapsible': 1.1.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-dialog': 1.1.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-accordion': 1.2.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-collapsible': 1.1.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.11)(react@18.3.1) - '@radix-ui/react-navigation-menu': 1.2.9(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-popover': 1.1.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-scroll-area': 1.2.5(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-navigation-menu': 1.2.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-popover': 1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-scroll-area': 1.2.6(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-slot': 1.1.0(@types/react@18.3.11)(react@18.3.1) - '@radix-ui/react-tabs': 1.1.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-tabs': 1.1.9(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@tailwindcss/typography': 0.5.16(tailwindcss@3.4.15) class-variance-authority: 0.7.1 fumadocs-core: 14.4.0(@types/react@18.3.11)(next@14.2.15)(react-dom@18.3.1)(react@18.3.1) @@ -14838,15 +14844,15 @@ packages: tailwindcss: optional: true dependencies: - '@radix-ui/react-accordion': 1.2.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-collapsible': 1.1.7(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-dialog': 1.1.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-accordion': 1.2.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-collapsible': 1.1.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.11)(react@18.3.1) - '@radix-ui/react-navigation-menu': 1.2.9(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-popover': 1.1.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) - '@radix-ui/react-scroll-area': 1.2.5(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-navigation-menu': 1.2.10(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-popover': 1.1.11(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-scroll-area': 1.2.6(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-slot': 1.1.0(@types/react@18.3.11)(react@18.3.1) - '@radix-ui/react-tabs': 1.1.8(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-tabs': 1.1.9(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) class-variance-authority: 0.7.1 fumadocs-core: 14.5.4(@types/react@18.3.11)(next@14.2.15)(react-dom@18.3.1)(react@18.3.1) lodash.merge: 4.6.2 @@ -15308,7 +15314,7 @@ packages: '@types/hast': 3.0.4 devlop: 1.1.0 hast-util-from-parse5: 8.0.3 - parse5: 7.2.1 + parse5: 7.3.0 vfile: 6.0.3 vfile-message: 4.0.2 dev: true @@ -16361,7 +16367,7 @@ packages: https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.20 - parse5: 7.2.1 + parse5: 7.3.0 rrweb-cssom: 0.8.0 saxes: 6.0.0 symbol-tree: 3.2.4 @@ -18665,10 +18671,10 @@ packages: engines: {node: '>=0.10.0'} dev: true - /parse5@7.2.1: - resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} + /parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} dependencies: - entities: 4.5.0 + entities: 6.0.0 dev: true /parseley@0.12.1: @@ -19416,8 +19422,8 @@ packages: react: 18.3.1 dev: false - /react-hook-form@7.56.0(react@18.3.1): - resolution: {integrity: sha512-U2QQgx5z2Y8Z0qlXv3W19hWHJgfKdWMz0O/osuY+o+CYq568V2R/JhzC6OAXfR8k24rIN0Muan2Qliaq9eKs/g==} + /react-hook-form@7.56.1(react@18.3.1): + resolution: {integrity: sha512-qWAVokhSpshhcEuQDSANHx3jiAEFzu2HAaaQIzi/r9FNPm1ioAvuJSD4EuZzWd7Al7nTRKcKPnBKO7sRn+zavQ==} engines: {node: '>=18.0.0'} peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 @@ -20251,8 +20257,8 @@ packages: dependencies: loose-envify: 1.4.0 - /schema-utils@4.3.0: - resolution: {integrity: sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==} + /schema-utils@4.3.2: + resolution: {integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==} engines: {node: '>= 10.13.0'} dependencies: '@types/json-schema': 7.0.15 @@ -20394,7 +20400,7 @@ packages: requiresBuild: true dependencies: color: 4.2.3 - detect-libc: 2.0.3 + detect-libc: 2.0.4 semver: 7.7.1 optionalDependencies: '@img/sharp-darwin-arm64': 0.33.4 @@ -20423,7 +20429,7 @@ packages: requiresBuild: true dependencies: color: 4.2.3 - detect-libc: 2.0.3 + detect-libc: 2.0.4 semver: 7.7.1 optionalDependencies: '@img/sharp-darwin-arm64': 0.33.5 @@ -21408,7 +21414,7 @@ packages: '@swc/core': 1.3.101 esbuild: 0.19.11 jest-worker: 27.5.1 - schema-utils: 4.3.0 + schema-utils: 4.3.2 serialize-javascript: 6.0.2 terser: 5.39.0 webpack: 5.99.6(@swc/core@1.3.101)(esbuild@0.19.11) @@ -22756,7 +22762,7 @@ packages: browserslist: 4.24.4 chrome-trace-event: 1.0.4 enhanced-resolve: 5.18.1 - es-module-lexer: 1.6.0 + es-module-lexer: 1.7.0 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1 @@ -22765,7 +22771,7 @@ packages: loader-runner: 4.3.0 mime-types: 2.1.35 neo-async: 2.6.2 - schema-utils: 4.3.0 + schema-utils: 4.3.2 tapable: 2.2.1 terser-webpack-plugin: 5.3.14(@swc/core@1.3.101)(esbuild@0.19.11)(webpack@5.99.6) watchpack: 2.4.2