From 330af80999909eef81ef6e93f191b62a60fe7ab5 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:28:17 +0000 Subject: [PATCH 01/11] feat: Add SkyFi MCP satellite imagery OAuth connection and tool integration Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- .env.local.example | 5 + app/actions.tsx | 7 + app/api/skyfi/callback/route.ts | 87 ++ components/settings/components/settings.tsx | 16 +- .../components/tool-selection-form.tsx | 138 ++- components/skyfi-section.tsx | 54 + components/tool-badge.tsx | 5 +- .../0009_add_skyfi_oauth_tokens.sql | 16 + drizzle/migrations/meta/0006_snapshot.json | 1081 +++++++++++++++++ drizzle/migrations/meta/_journal.json | 28 + lib/actions/skyfi.ts | 166 +++ lib/actions/users.ts | 99 ++ lib/agents/researcher.tsx | 30 +- lib/agents/tools/index.tsx | 10 +- lib/agents/tools/skyfi.tsx | 293 +++++ lib/db/schema.ts | 24 +- lib/schema/skyfi.tsx | 39 + lib/skyfi/provider.ts | 179 +++ 18 files changed, 2260 insertions(+), 17 deletions(-) create mode 100644 app/api/skyfi/callback/route.ts create mode 100644 components/skyfi-section.tsx create mode 100644 drizzle/migrations/0009_add_skyfi_oauth_tokens.sql create mode 100644 drizzle/migrations/meta/0006_snapshot.json create mode 100644 lib/actions/skyfi.ts create mode 100644 lib/agents/tools/skyfi.tsx create mode 100644 lib/schema/skyfi.tsx create mode 100644 lib/skyfi/provider.ts diff --git a/.env.local.example b/.env.local.example index 0ff92ba0..61e6578e 100644 --- a/.env.local.example +++ b/.env.local.example @@ -36,3 +36,8 @@ DATABASE_URL=postgresql://postgres:[YOUR-POSTGRES-PASSWORD]@[YOUR-SUPABASE-DB-HO # Clerk Custom Sign-In/Up URLs NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up + +# SkyFi MCP Configuration +# Users connect their own accounts via OAuth 2.1 in Settings. +# The app redirect URI is built using NEXT_PUBLIC_APP_URL. +NEXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/app/actions.tsx b/app/actions.tsx index a14b3c1a..c9f8ecfd 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -21,6 +21,7 @@ import { Chat, AIMessage } from '@/lib/types' import { UserMessage } from '@/components/user-message' import { BotMessage } from '@/components/message' import { SearchSection } from '@/components/search-section' +import { SkyfiSection } from '@/components/skyfi-section' import SearchRelated from '@/components/search-related' import { GeoJsonLayer } from '@/components/map/geojson-layer' import { ResolutionCarousel } from '@/components/resolution-carousel' @@ -853,6 +854,12 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { ) searchResults.done(JSON.stringify(toolOutput)) switch (name) { + case 'skyfiQueryTool': + return { + id, + component: , + isCollapsed: isCollapsed.value + } case 'search': return { id, diff --git a/app/api/skyfi/callback/route.ts b/app/api/skyfi/callback/route.ts new file mode 100644 index 00000000..0e3c8d9f --- /dev/null +++ b/app/api/skyfi/callback/route.ts @@ -0,0 +1,87 @@ +import { NextResponse, NextRequest } from 'next/server'; +import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; +import { SkyfiOAuthProvider } from '@/lib/skyfi/provider'; +import { db } from '@/lib/db'; +import { skyfiOAuthTokens } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; + +export async function GET(request: NextRequest) { + try { + const userId = await getCurrentUserIdOnServer(); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized: No user session found.' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const code = searchParams.get('code'); + const state = searchParams.get('state'); + + if (!code || !state) { + return NextResponse.json({ error: 'Missing code or state parameters.' }, { status: 400 }); + } + + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; + const redirectUri = `${baseUrl}/api/skyfi/callback`; + const provider = new SkyfiOAuthProvider(userId, redirectUri); + + const storedState = await provider.state(); + if (state !== storedState) { + return NextResponse.json({ error: 'CSRF State mismatch.' }, { status: 400 }); + } + + const clientInfo = await provider.clientInformation(); + const verifier = await provider.codeVerifier(); + + if (!clientInfo?.client_id || !verifier) { + return NextResponse.json({ error: 'Missing client registration or PKCE verifier.' }, { status: 400 }); + } + + console.log('[SkyFiCallback] Exchanging code for tokens...'); + // Exchange the authorization code for tokens + const response = await fetch('https://mcp.skyfi.com/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientInfo.client_id, + code, + redirect_uri: redirectUri, + code_verifier: verifier, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[SkyFiCallback] Token exchange failed:', errorText); + return NextResponse.json({ error: `Token exchange failed: ${errorText}` }, { status: 500 }); + } + + const data = await response.json(); + console.log('[SkyFiCallback] Code successfully exchanged for tokens.'); + + // Save tokens in database + await provider.saveTokens({ + access_token: data.access_token, + refresh_token: data.refresh_token || undefined, + expires_at: data.expires_in ? Math.floor(Date.now() / 1000) + data.expires_in : undefined, + }); + + // Clear temporary state and code verifier + await db.update(skyfiOAuthTokens) + .set({ + state: null, + codeVerifier: null, + updatedAt: new Date(), + }) + .where(eq(skyfiOAuthTokens.userId, userId)); + + // Redirect user back to the settings page + return NextResponse.redirect(`${baseUrl}/settings`); + } catch (error: any) { + console.error('[SkyFiCallback] Unexpected error:', error.message); + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; + return NextResponse.redirect(`${baseUrl}/settings?error=skyfi_callback_failed`); + } +} diff --git a/components/settings/components/settings.tsx b/components/settings/components/settings.tsx index 1a3f7904..db83041f 100644 --- a/components/settings/components/settings.tsx +++ b/components/settings/components/settings.tsx @@ -22,7 +22,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Label } from "@/components/ui/label"; import { useToast } from "@/components/ui/hooks/use-toast" import { getSystemPrompt, saveSystemPrompt } from "../../../lib/actions/chat" -import { getSelectedModel, saveSelectedModel } from "../../../lib/actions/users" +import { getSelectedModel, saveSelectedModel, getSkyfiConfig, saveSkyfiConfig } from "../../../lib/actions/users" import { useCurrentUser } from "@/lib/auth/use-current-user" import { SettingsSkeleton } from './settings-skeleton' import { useUser } from '@clerk/nextjs' @@ -40,6 +40,7 @@ const settingsFormSchema = z.object({ selectedModel: z.string().refine(value => value.trim() !== '', { message: "Please select a tool.", }), + skyfiApiKey: z.string().optional(), users: z.array( z.object({ id: z.string(), @@ -59,6 +60,7 @@ const defaultValues: Partial = { systemPrompt: "You are a planetary copilot, an AI assistant designed to help users with information about planets, space exploration, and astronomy. Provide accurate, educational, and engaging responses about our solar system and beyond.", selectedModel: "QCX-Terra", + skyfiApiKey: "", users: [], domain: "", } @@ -97,9 +99,10 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { async function fetchData() { if (!userId || authLoading) return; - const [existingPrompt, selectedModel] = await Promise.all([ + const [existingPrompt, selectedModel, skyfiConfig] = await Promise.all([ getSystemPrompt(userId), getSelectedModel(), + getSkyfiConfig(), ]); if (existingPrompt) { @@ -108,6 +111,9 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { if (selectedModel) { form.setValue("selectedModel", selectedModel, { shouldValidate: true, shouldDirty: false }); } + if (skyfiConfig?.apiKey) { + form.setValue("skyfiApiKey", skyfiConfig.apiKey, { shouldValidate: true, shouldDirty: false }); + } } fetchData(); }, [form, userId, authLoading]); @@ -130,9 +136,10 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { try { // Save the system prompt and selected model - const [promptSaveResult, modelSaveResult] = await Promise.all([ + const [promptSaveResult, modelSaveResult, skyfiSaveResult] = await Promise.all([ saveSystemPrompt(userId, data.systemPrompt), saveSelectedModel(data.selectedModel), + saveSkyfiConfig({ apiKey: data.skyfiApiKey, initialized: !!data.skyfiApiKey }), ]); if (promptSaveResult?.error) { @@ -141,6 +148,9 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { if (modelSaveResult?.error) { throw new Error(modelSaveResult.error); } + if (skyfiSaveResult?.error) { + throw new Error(skyfiSaveResult.error); + } console.log("Submitted data:", data) diff --git a/components/settings/components/tool-selection-form.tsx b/components/settings/components/tool-selection-form.tsx index 1eaabc6f..0e432456 100644 --- a/components/settings/components/tool-selection-form.tsx +++ b/components/settings/components/tool-selection-form.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState, useEffect } from "react"; import type { UseFormReturn } from "react-hook-form"; import { FormField, @@ -19,7 +20,11 @@ import { import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Earth, Orbit } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { startSkyfiConnection, getSkyfiConnectionStatus, disconnectSkyfi } from "@/lib/actions/skyfi"; +import { Earth, Orbit, ShieldCheck, Loader2 } from "lucide-react"; +import { useToast } from "@/components/ui/hooks/use-toast"; +import { cn } from "@/lib/utils"; interface ToolSelectionFormProps { form: UseFormReturn; @@ -45,6 +50,31 @@ const tools = [ ]; export function ToolSelectionForm({ form }: ToolSelectionFormProps) { + const selectedModel = form.watch("selectedModel"); + const { toast } = useToast(); + const [skyfiConnected, setSkyfiConnected] = useState(false); + const [skyfiBudget, setSkyfiBudget] = useState(null); + const [loadingStatus, setLoadingStatus] = useState(true); + const [isConnecting, setIsConnecting] = useState(false); + + useEffect(() => { + async function loadStatus() { + if (selectedModel === "SkyFi") { + setLoadingStatus(true); + try { + const status = await getSkyfiConnectionStatus(); + setSkyfiConnected(status.connected); + setSkyfiBudget(status.budget || null); + } catch (err) { + console.error("Failed to load SkyFi connection status:", err); + } finally { + setLoadingStatus(false); + } + } + } + loadStatus(); + }, [selectedModel]); + return ( + + {selectedModel === "SkyFi" && ( + + +
+ +

SkyFi Account Connection

+
+ {loadingStatus ? ( +
+ + Checking SkyFi connection status... +
+ ) : skyfiConnected ? ( +
+
+ +
+

SkyFi Account Linked

+ {skyfiBudget ? ( +
+ {skyfiBudget} +
+ ) : ( +

Successfully authenticated and ready to query satellite imagery.

+ )} +
+ +
+
+ ) : ( +
+

+ Connect your SkyFi account to search existing satellite imagery, task new captures, and manage Areas of Interest (AOIs) directly from your chats. +

+ +
+ )} +
+
+ )} )} /> diff --git a/components/skyfi-section.tsx b/components/skyfi-section.tsx new file mode 100644 index 00000000..8f90e9ff --- /dev/null +++ b/components/skyfi-section.tsx @@ -0,0 +1,54 @@ +'use client' + +import { Section } from './section' +import { ToolBadge } from './tool-badge' +import { BotMessage } from './message' +import { SearchSkeleton } from './search-skeleton' +import { StreamableValue, useStreamableValue } from 'ai/rsc' + +export type SkyfiSectionProps = { + result?: StreamableValue +} + +export function SkyfiSection({ result }: SkyfiSectionProps) { + const [data, error, pending] = useStreamableValue(result) + + let queryType = 'Query' + let resultText = '' + let hasError = false + + if (data) { + try { + const parsed = JSON.parse(data) + queryType = parsed.queryType || 'Query' + resultText = parsed.result || '' + if (parsed.error) { + hasError = true + resultText = parsed.error + } + } catch { + resultText = data + } + } + + return ( +
+ {!pending && data ? ( + <> +
+ {`SkyFi MCP: ${queryType}`} +
+
+
+ +
+
+ + ) : ( +
+ +
+ )} +
+ ) +} diff --git a/components/tool-badge.tsx b/components/tool-badge.tsx index f2a22c10..eb39af41 100644 --- a/components/tool-badge.tsx +++ b/components/tool-badge.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Search } from 'lucide-react' +import { Search, Orbit } from 'lucide-react' import { Badge } from './ui/badge' type ToolBadgeProps = { @@ -14,7 +14,8 @@ export const ToolBadge: React.FC = ({ className }) => { const icon: Record = { - search: + search: , + skyfiQueryTool: } return ( diff --git a/drizzle/migrations/0009_add_skyfi_oauth_tokens.sql b/drizzle/migrations/0009_add_skyfi_oauth_tokens.sql new file mode 100644 index 00000000..faf355a4 --- /dev/null +++ b/drizzle/migrations/0009_add_skyfi_oauth_tokens.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS "skyfi_oauth_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "access_token" text, + "refresh_token" text, + "token_expiry" timestamp with time zone, + "client_id" text, + "client_secret" text, + "code_verifier" text, + "state" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "skyfi_oauth_tokens_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +ALTER TABLE "skyfi_oauth_tokens" ADD CONSTRAINT "skyfi_oauth_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; diff --git a/drizzle/migrations/meta/0006_snapshot.json b/drizzle/migrations/meta/0006_snapshot.json new file mode 100644 index 00000000..068620d1 --- /dev/null +++ b/drizzle/migrations/meta/0006_snapshot.json @@ -0,0 +1,1081 @@ +{ + "id": "cb88b9e7-0bb2-4a61-b151-a12cf1071b91", + "prevId": "fd89f5aa-13b0-4dd4-9f50-4e921eea0f4f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.calendar_notes": { + "name": "calendar_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_tags": { + "name": "location_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "user_tags": { + "name": "user_tags", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "map_feature_id": { + "name": "map_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "calendar_notes_user_id_users_id_fk": { + "name": "calendar_notes_user_id_users_id_fk", + "tableFrom": "calendar_notes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_notes_chat_id_chats_id_fk": { + "name": "calendar_notes_chat_id_chats_id_fk", + "tableFrom": "calendar_notes", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_participants": { + "name": "chat_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'collaborator'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_participants_chat_id_chats_id_fk": { + "name": "chat_participants_chat_id_chats_id_fk", + "tableFrom": "chat_participants", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_participants_user_id_users_id_fk": { + "name": "chat_participants_user_id_users_id_fk", + "tableFrom": "chat_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_participants_chat_user_unique": { + "name": "chat_participants_chat_user_unique", + "nullsNotDistinct": false, + "columns": [ + "chat_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Untitled Chat'" + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'private'" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "share_path": { + "name": "share_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shareable_link_id": { + "name": "shareable_link_id", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chats_user_id_users_id_fk": { + "name": "chats_user_id_users_id_fk", + "tableFrom": "chats", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chats_shareable_link_id_unique": { + "name": "chats_shareable_link_id_unique", + "nullsNotDistinct": false, + "columns": [ + "shareable_link_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_chunks": { + "name": "document_chunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_chunks_document_id_documents_id_fk": { + "name": "document_chunks_document_id_documents_id_fk", + "tableFrom": "document_chunks", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "documents_user_id_users_id_fk": { + "name": "documents_user_id_users_id_fk", + "tableFrom": "documents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "documents_chat_id_chats_id_fk": { + "name": "documents_chat_id_chats_id_fk", + "tableFrom": "documents", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.locations": { + "name": "locations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "geojson": { + "name": "geojson", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "geometry": { + "name": "geometry", + "type": "geometry(GEOMETRY, 4326)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "locations_user_id_users_id_fk": { + "name": "locations_user_id_users_id_fk", + "tableFrom": "locations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "locations_chat_id_chats_id_fk": { + "name": "locations_chat_id_chats_id_fk", + "tableFrom": "locations", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_user_id_users_id_fk": { + "name": "messages_user_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_location_id_locations_id_fk": { + "name": "messages_location_id_locations_id_fk", + "tableFrom": "messages", + "tableTo": "locations", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.prompt_generation_jobs": { + "name": "prompt_generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result_prompt": { + "name": "result_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "prompt_generation_jobs_user_id_users_id_fk": { + "name": "prompt_generation_jobs_user_id_users_id_fk", + "tableFrom": "prompt_generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skyfi_oauth_tokens": { + "name": "skyfi_oauth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expiry": { + "name": "token_expiry", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "skyfi_oauth_tokens_user_id_users_id_fk": { + "name": "skyfi_oauth_tokens_user_id_users_id_fk", + "tableFrom": "skyfi_oauth_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skyfi_oauth_tokens_user_id_unique": { + "name": "skyfi_oauth_tokens_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_prompts": { + "name": "system_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "system_prompts_user_id_users_id_fk": { + "name": "system_prompts_user_id_users_id_fk", + "tableFrom": "system_prompts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'viewer'" + }, + "selected_model": { + "name": "selected_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "clerk_user_id": { + "name": "clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_clerk_user_id_unique": { + "name": "users_clerk_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "clerk_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.visualizations": { + "name": "visualizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'map_layer'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "geometry": { + "name": "geometry", + "type": "geometry(GEOMETRY, 4326)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "visualizations_user_id_users_id_fk": { + "name": "visualizations_user_id_users_id_fk", + "tableFrom": "visualizations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "visualizations_chat_id_chats_id_fk": { + "name": "visualizations_chat_id_chats_id_fk", + "tableFrom": "visualizations", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index ee11d5f4..d416fd22 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -43,6 +43,34 @@ "when": 1783699200000, "tag": "0005_add_documents_rag", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1784838292036, + "tag": "0006_add_user_id_indexes", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1784838292037, + "tag": "0007_create_prompt_generation_jobs", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1784838292038, + "tag": "0008_allow_data_message_role", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1784838292039, + "tag": "0009_add_skyfi_oauth_tokens", + "breakpoints": true } ] } diff --git a/lib/actions/skyfi.ts b/lib/actions/skyfi.ts new file mode 100644 index 00000000..a5c4a4d3 --- /dev/null +++ b/lib/actions/skyfi.ts @@ -0,0 +1,166 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { db } from '@/lib/db'; +import { skyfiOAuthTokens } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; +import { SkyfiOAuthProvider } from '@/lib/skyfi/provider'; +import crypto from 'crypto'; + +function generateCodeVerifier(): string { + return crypto.randomBytes(32).toString('base64url'); +} + +function generateCodeChallenge(verifier: string): string { + return crypto.createHash('sha256').update(verifier).digest('base64url'); +} + +function getRedirectUri(): string { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; + return `${baseUrl}/api/skyfi/callback`; +} + +/** + * Ensures the client is registered dynamically with the SkyFi MCP server. + */ +async function ensureClientRegistered(provider: SkyfiOAuthProvider): Promise { + const currentInfo = await provider.clientInformation(); + if (currentInfo?.client_id) { + return currentInfo.client_id; + } + + console.log('[SkyFiAction] Client not registered. Registering dynamically...'); + const res = await fetch('https://mcp.skyfi.com/oauth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'QCX Planetary Copilot', + redirect_uris: [provider.redirectUrl?.toString()], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + }), + }); + + if (!res.ok) { + throw new Error(`Dynamic Client Registration failed: ${await res.text()}`); + } + + const data = await res.json(); + await provider.saveClientInformation({ + client_id: data.client_id, + client_secret: data.client_secret || null, + } as any); + + return data.client_id; +} + +/** + * Starts the SkyFi OAuth connection flow. + * Performs DCR, generates PKCE, and returns the authorization URL. + */ +export async function startSkyfiConnection(): Promise<{ url?: string; error?: string }> { + try { + const userId = await getCurrentUserIdOnServer(); + if (!userId) { + return { error: 'Unauthorized: User not authenticated.' }; + } + + const redirectUri = getRedirectUri(); + const provider = new SkyfiOAuthProvider(userId, redirectUri); + + const clientId = await ensureClientRegistered(provider); + + const verifier = generateCodeVerifier(); + const challenge = generateCodeChallenge(verifier); + const state = crypto.randomBytes(16).toString('hex'); + + await provider.saveCodeVerifier(verifier); + await provider.saveState(state); + + const authorizeUrl = new URL('https://mcp.skyfi.com/oauth/authorize'); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('client_id', clientId); + authorizeUrl.searchParams.set('redirect_uri', redirectUri); + authorizeUrl.searchParams.set('code_challenge', challenge); + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); + authorizeUrl.searchParams.set('state', state); + authorizeUrl.searchParams.set('scope', 'skyfi:read skyfi:write skyfi:order'); + + return { url: authorizeUrl.toString() }; + } catch (error: any) { + console.error('[SkyFiAction: startSkyfiConnection] Error:', error.message); + return { error: error.message || 'Failed to start SkyFi connection.' }; + } +} + +/** + * Returns whether a valid SkyFi connection exists for the current user. + */ +export async function getSkyfiConnectionStatus(): Promise<{ connected: boolean; email?: string; budget?: string }> { + try { + const userId = await getCurrentUserIdOnServer(); + if (!userId) { + return { connected: false }; + } + + const redirectUri = getRedirectUri(); + const provider = new SkyfiOAuthProvider(userId, redirectUri); + const tokens = await provider.tokens(); + + if (!tokens || !tokens.access_token) { + return { connected: false }; + } + + // Try a simple whoami call to verify token validity and get email/budget + const res = await fetch('https://mcp.skyfi.com/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Skyfi-Api-Key': tokens.access_token, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'skyfi_whoami', + arguments: {}, + }, + }), + }); + + if (res.ok) { + const data = await res.json(); + const content = data?.result?.content?.[0]?.text || ''; + return { connected: true, budget: content }; + } + + return { connected: true }; + } catch (error: any) { + console.error('[SkyFiAction: getSkyfiConnectionStatus] Error:', error.message); + return { connected: false }; + } +} + +/** + * Disconnects the user's SkyFi account by removing token storage. + */ +export async function disconnectSkyfi(): Promise<{ success: boolean; error?: string }> { + try { + const userId = await getCurrentUserIdOnServer(); + if (!userId) { + return { success: false, error: 'Unauthorized: User not authenticated.' }; + } + + await db.delete(skyfiOAuthTokens) + .where(eq(skyfiOAuthTokens.userId, userId)); + + revalidatePath('/settings'); + return { success: true }; + } catch (error: any) { + console.error('[SkyFiAction: disconnectSkyfi] Error:', error.message); + return { success: false, error: error.message || 'Failed to disconnect SkyFi.' }; + } +} diff --git a/lib/actions/users.ts b/lib/actions/users.ts index 6fba7f8b..3147419e 100644 --- a/lib/actions/users.ts +++ b/lib/actions/users.ts @@ -209,3 +209,102 @@ export async function searchUsers(query: string) { return []; } } + +import { Client as MCPClientClass } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +/** + * Fetches the SkyFi config for the current user. + */ +export async function getSkyfiConfig(): Promise<{ apiKey?: string; initialized?: boolean } | null> { + const userId = await getCurrentUserIdOnServer(); + if (!userId) return null; + try { + const result = await db.select({ metadata: users.metadata }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + const metadata = result[0]?.metadata as any; + return metadata?.skyfi || null; + } catch (error) { + console.error('[Action: getSkyfiConfig] Error:', error); + return null; + } +} + +/** + * Saves the SkyFi config for the current user. + */ +export async function saveSkyfiConfig(config: { apiKey?: string; initialized?: boolean }): Promise<{ success: boolean; error?: string }> { + const userId = await getCurrentUserIdOnServer(); + if (!userId) return { success: false, error: 'Not authenticated' }; + try { + const result = await db.select({ metadata: users.metadata }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + const currentMetadata = (result[0]?.metadata as Record) || {}; + + await db.update(users) + .set({ + metadata: { + ...currentMetadata, + skyfi: config + } + }) + .where(eq(users.id, userId)); + + revalidatePath('/settings'); + return { success: true }; + } catch (error) { + console.error('[Action: saveSkyfiConfig] Error:', error); + return { success: false, error: 'Failed to save SkyFi configuration.' }; + } +} + +/** + * Tests connection to the SkyFi MCP server using the provided API key. + */ +export async function testSkyfiConnection(apiKey: string): Promise<{ success: boolean; data?: string; error?: string }> { + if (!apiKey || !apiKey.trim()) { + return { success: false, error: 'API Key is required.' }; + } + + const serverUrl = new URL('https://mcp.skyfi.com/mcp'); + let transport; + let client; + + try { + transport = new StreamableHTTPClientTransport(serverUrl, { + requestInit: { + headers: { + 'X-Skyfi-Api-Key': apiKey, + } + } + }); + client = new MCPClientClass({ name: 'SkyfiTestClient', version: '1.0.0' }); + + await Promise.race([ + client.connect(transport), + new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout after 10 seconds')), 10000)), + ]); + + const result = await Promise.race([ + client.callTool({ name: 'skyfi_whoami', arguments: {} }), + new Promise((_, reject) => setTimeout(() => reject(new Error('Tool call timeout after 10 seconds')), 10000)), + ]); + + await client.close(); + + const response = result as { content?: Array<{ text?: string }> }; + const textBlock = response?.content?.[0]?.text || ''; + + return { success: true, data: textBlock }; + } catch (err: any) { + console.error('[Action: testSkyfiConnection] Error:', err.message); + if (client) { + try { await client.close(); } catch {} + } + return { success: false, error: err.message || 'Failed to connect to SkyFi MCP.' }; + } +} diff --git a/lib/agents/researcher.tsx b/lib/agents/researcher.tsx index 81d00fa8..3b874b5f 100644 --- a/lib/agents/researcher.tsx +++ b/lib/agents/researcher.tsx @@ -13,11 +13,12 @@ import { getTools } from './tools' import { getModel } from '../utils' import { MapProvider } from '@/lib/store/settings' import { DrawnFeature } from './resolution-search' +import { getSelectedModel } from '@/lib/actions/users' // This magic tag lets us write raw multi-line strings with backticks, arrows, etc. const raw = String.raw -const getDefaultSystemPrompt = (date: string, drawnFeatures?: DrawnFeature[]) => raw` +const getDefaultSystemPrompt = (date: string, drawnFeatures?: DrawnFeature[], selectedModel?: string | null) => raw` As a comprehensive AI assistant, your primary directive is **Exploration Efficiency**. You must use the provided tools judiciously to gather information and formulate a response. **Product Context:** @@ -33,7 +34,7 @@ Use these user-drawn areas/lines as primary areas of interest for your analysis **Exploration Efficiency Directives:** 1. **Tool First:** Always check if a tool can directly or partially answer the user's query. Use the most specific tool available. 2. **Geospatial Priority:** For any query involving locations, places, addresses, geographical features, finding businesses, distances, or directions → you **MUST** use the 'geospatialQueryTool'. -3. **Search Specificity:** When using the 'search' tool, formulate queries that are as specific as possible. +${selectedModel === 'SkyFi' ? `3. **SkyFi Priority:** The user has selected **SkyFi** as their active planetary tool. For any query asking to query latest images, search past captures, check their SkyFi account or budget, or select Areas of Interest (AOIs) for SkyFi, you **MUST** use the 'skyfiQueryTool' instead of or in addition to general searches.` : `3. **Search Specificity:** When using the 'search' tool, formulate queries that are as specific as possible.`} 4. **Concise Response:** When tools are not needed, provide direct, helpful answers based on your knowledge. Match the user's language. 5. **Citations:** Always cite source URLs when using information from tools. @@ -51,11 +52,18 @@ Use these user-drawn areas/lines as primary areas of interest for your analysis ONLY when the user explicitly provides one or more URLs and asks you to read, summarize, or extract content from them. - **Never use** this tool proactively. -#### **3. Location, Geography, Navigation, and Mapping Queries** +${selectedModel === 'SkyFi' ? `#### **3. SkyFi Satellite Imagery and AOI** +- **Tool**: \`skyfiQueryTool\` +- **When to use**: + • Any request to check SkyFi account status or budget (use 'whoami') + • Any request to geocode or set/select an Area of Interest (AOI) on SkyFi (use 'geocode') + • Any request to search satellite archive catalog or query latest/latss images (use 'search') + • Any request to validate, price, or place an order (use 'validate_order' / 'place_order') + • Any request to list previous satellite image orders (use 'list_orders')` : `#### **3. Location, Geography, Navigation, and Mapping Queries** - **Tool**: \`geospatialQueryTool\` → **MUST be used (no exceptions)** for: • Finding places, businesses, "near me", distances, directions • Travel times, routes, traffic, map generation - • Isochrones, travel-time matrices, multi-stop optimization + • Isochrones, travel-time matrices, multi-stop optimization`} #### **4. Searching Uploaded Documents and Attachments** - **Tool**: \`documentRetrieve\` @@ -69,16 +77,19 @@ Use these user-drawn areas/lines as primary areas of interest for your analysis - “How long to walk from Central Park to Times Square?” - “Areas reachable in 30 minutes from downtown Portland” -**Behavior when using \`geospatialQueryTool\`:** +${selectedModel === 'SkyFi' ? `**Behavior when using \`skyfiQueryTool\`:** +- Issue the tool call immediately. +- Clearly present the search results, imagery options, or order prices returned by SkyFi in your final response. +- Always ask for the user's explicit approval before placing any paid/billable orders.` : `**Behavior when using \`geospatialQueryTool\`:** - Issue the tool call immediately - In your final response: provide concise text only - → NEVER say “the map will update” or “markers are being added” -- → Trust the system handles map rendering automatically +- → Trust the system handles map rendering automatically`} #### **Summary of Decision Flow** 1. User gave explicit URLs? → \`retrieve\` 2. Query relates to uploaded documents, attachments, or custom user knowledge files? → \`documentRetrieve\` (mandatory) -3. Location/distance/direction/maps? → \`geospatialQueryTool\` (mandatory) +${selectedModel === 'SkyFi' ? `3. SkyFi account, satellite imagery search, geocoding AOI, or ordering? → \`skyfiQueryTool\` (mandatory)` : `3. Location/distance/direction/maps? → \`geospatialQueryTool\` (mandatory)`} 4. Everything else needing external data? → \`search\` 5. Otherwise → answer from knowledge @@ -109,11 +120,12 @@ export async function researcher( ) const currentDate = new Date().toLocaleString() + const selectedModel = await getSelectedModel(); const systemPromptToUse = dynamicSystemPrompt?.trim() ? dynamicSystemPrompt - : getDefaultSystemPrompt(currentDate, drawnFeatures) + : getDefaultSystemPrompt(currentDate, drawnFeatures, selectedModel) // Check if any message contains an image const hasImage = messages.some(message => @@ -148,7 +160,7 @@ export async function researcher( maxTokens: 4096, system: systemPromptToUse, messages, - tools: getTools({ uiStream, fullResponse, mapProvider }), + tools: getTools({ uiStream, fullResponse, mapProvider, selectedModel, drawnFeatures }), }) uiStream.update(null) // remove spinner diff --git a/lib/agents/tools/index.tsx b/lib/agents/tools/index.tsx index c0f74360..0fd79650 100644 --- a/lib/agents/tools/index.tsx +++ b/lib/agents/tools/index.tsx @@ -4,6 +4,8 @@ import { searchTool } from './search' import { videoSearchTool } from './video-search' import { geospatialTool } from './geospatial' import { documentRetrieveTool } from './document-retrieve' +import { skyfiTool } from './skyfi' +import { DrawnFeature } from '@/lib/agents/resolution-search' import { MapProvider } from '@/lib/store/settings' @@ -11,9 +13,11 @@ export interface ToolProps { uiStream: ReturnType fullResponse: string mapProvider?: MapProvider + selectedModel?: string | null + drawnFeatures?: DrawnFeature[] } -export const getTools = ({ uiStream, fullResponse, mapProvider }: ToolProps) => { +export const getTools = ({ uiStream, fullResponse, mapProvider, selectedModel, drawnFeatures }: ToolProps) => { const tools: any = { retrieve: retrieveTool({ uiStream, @@ -26,6 +30,10 @@ export const getTools = ({ uiStream, fullResponse, mapProvider }: ToolProps) => documentRetrieve: documentRetrieveTool({ uiStream, fullResponse + }), + skyfiQueryTool: skyfiTool({ + uiStream, + drawnFeatures }) } diff --git a/lib/agents/tools/skyfi.tsx b/lib/agents/tools/skyfi.tsx new file mode 100644 index 00000000..840b807f --- /dev/null +++ b/lib/agents/tools/skyfi.tsx @@ -0,0 +1,293 @@ +/** + * SkyFi MCP integration tool + */ +import { createStreamableUI, createStreamableValue } from 'ai/rsc'; +import { BotMessage } from '@/components/message'; +import { Client as MCPClientClass } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; +import { SkyfiOAuthProvider } from '@/lib/skyfi/provider'; +import { skyfiQuerySchema } from '@/lib/schema/skyfi'; +import { DrawnFeature } from '@/lib/agents/resolution-search'; +import { z } from 'zod'; + +export type McpClient = MCPClientClass; + +function getRedirectUri(): string { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; + return `${baseUrl}/api/skyfi/callback`; +} + +function geoJsonToWkt(geometry: any): string | null { + if (!geometry) return null; + if (geometry.type === 'Polygon') { + const coords = geometry.coordinates[0]; + const ringStr = coords.map((c: any) => `${c[0]} ${c[1]}`).join(', '); + return `POLYGON((${ringStr}))`; + } else if (geometry.type === 'LineString') { + const coords = geometry.coordinates; + const lineStr = coords.map((c: any) => `${c[0]} ${c[1]}`).join(', '); + return `LINESTRING(${lineStr})`; + } else if (geometry.type === 'Point') { + const coords = geometry.coordinates; + return `POINT(${coords[0]} ${coords[1]})`; + } + return null; +} + +/** + * Establish connection to the SkyFi MCP server with the user's stored OAuth token. + */ +async function getConnectedSkyfiMcpClient(accessToken: string): Promise { + console.log('[SkyfiTool] Connecting to SkyFi MCP server via OAuth Bearer token...'); + + const serverUrl = new URL('https://mcp.skyfi.com/mcp'); + + // Create transport with headers + let transport; + try { + transport = new StreamableHTTPClientTransport(serverUrl, { + requestInit: { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'X-Skyfi-Api-Key': accessToken, + } + } + }); + console.log('[SkyfiTool] Transport created successfully'); + } catch (transportError: any) { + console.error('[SkyfiTool] Failed to create transport:', transportError.message); + return null; + } + + // Create client + let client; + try { + client = new MCPClientClass({ name: 'SkyfiToolClient', version: '1.0.0' }); + console.log('[SkyfiTool] MCP Client instance created'); + } catch (clientError: any) { + console.error('[SkyfiTool] Failed to create MCP client:', clientError.message); + return null; + } + + // Connect to server + try { + await Promise.race([ + client.connect(transport), + new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout after 15 seconds')), 15000)), + ]); + console.log('[SkyfiTool] Successfully connected to SkyFi MCP server'); + } catch (connectError: any) { + console.error('[SkyfiTool] SkyFi MCP connection failed:', connectError.message); + return null; + } + + return client; +} + +/** + * Safely close the MCP client. + */ +async function closeClient(client: McpClient | null) { + if (!client) return; + try { + await Promise.race([ + client.close(), + new Promise((_, reject) => setTimeout(() => reject(new Error('Close timeout after 5 seconds')), 5000)), + ]); + console.log('[SkyfiTool] MCP client closed successfully'); + } catch (error: any) { + console.error('[SkyfiTool] Error closing MCP client:', error.message); + } +} + +/** + * SkyFi tool executor. + */ +export const skyfiTool = ({ + uiStream, + drawnFeatures +}: { + uiStream: ReturnType; + drawnFeatures?: DrawnFeature[]; +}) => ({ + description: `Use this tool to connect directly to the user's SkyFi account via the SkyFi MCP server. + Capabilities include: + - Checking account status, remaining budget, and organizations ('whoami'). + - Geocoding/creating WKT Polygons for Areas of Interest ('geocode'). + - Searching satellite archive catalog for latest captures over an Area of Interest ('search'). + - Validating/pricing an archive order before purchase ('validate_order'). + - Placing actual billable orders for satellite images ('place_order') (requires explicit customer confirmation first!). + - Listing previous orders ('list_orders'). + + Always ask the user for permission before placing a billable order! Ensure you call 'validate_order' first to display the cost.`, + parameters: skyfiQuerySchema, + execute: async (params: z.infer) => { + const { queryType, location, aoi, params: extraParams } = params; + console.log('[SkyfiTool] Execute called with:', params); + + const uiFeedbackStream = createStreamableValue(); + uiStream.append(); + + // Resolve user ID + const userId = await getCurrentUserIdOnServer(); + if (!userId) { + const errorMsg = 'Please log in to use the SkyFi tool.'; + uiFeedbackStream.update(errorMsg); + uiFeedbackStream.done(); + uiStream.update(); + return { type: 'SKYFI_QUERY', success: false, error: 'Unauthorized', message: errorMsg }; + } + + // Resolve OAuth tokens + const redirectUri = getRedirectUri(); + const provider = new SkyfiOAuthProvider(userId, redirectUri); + const tokens = await provider.tokens(); + + if (!tokens || !tokens.access_token) { + const missingKeyMessage = `Your SkyFi account is not connected. Please connect your SkyFi account in Settings -> Tools tab first.`; + uiFeedbackStream.update(missingKeyMessage); + uiFeedbackStream.done(); + uiStream.update(); + return { type: 'SKYFI_QUERY', success: false, error: 'Unauthenticated', message: missingKeyMessage }; + } + + let feedbackMessage = `Connecting to SkyFi MCP server...`; + uiFeedbackStream.update(feedbackMessage); + + const mcpClient = await getConnectedSkyfiMcpClient(tokens.access_token); + if (!mcpClient) { + feedbackMessage = 'Failed to connect to the SkyFi MCP server. Your login may have expired, please try reconnecting in Settings.'; + uiFeedbackStream.update(feedbackMessage); + uiFeedbackStream.done(); + uiStream.update(); + return { type: 'SKYFI_QUERY', success: false, error: 'Connection failed' }; + } + + try { + feedbackMessage = `Successfully connected to SkyFi. Executing '${queryType}' action...`; + uiFeedbackStream.update(feedbackMessage); + + // Determine correct MCP tool name and format parameters + let mcpToolName = ''; + let mcpArgs: Record = {}; + + // Convert any drawn AOI geometry to WKT + let resolvedAoiWkt = aoi; + if (!resolvedAoiWkt && drawnFeatures && drawnFeatures.length > 0) { + const latestPolygon = drawnFeatures.find(f => f.type === 'Polygon'); + if (latestPolygon) { + resolvedAoiWkt = geoJsonToWkt(latestPolygon.geometry) || undefined; + } else { + resolvedAoiWkt = geoJsonToWkt(drawnFeatures[0].geometry) || undefined; + } + if (resolvedAoiWkt) { + console.log('[SkyfiTool] Resolved WKT from user drawn features on map:', resolvedAoiWkt); + } + } + + switch (queryType) { + case 'whoami': + mcpToolName = 'skyfi_whoami'; + mcpArgs = {}; + break; + case 'geocode': + mcpToolName = 'skyfi_geocode_aoi'; + mcpArgs = { place: location || extraParams?.place }; + break; + case 'search': + mcpToolName = 'skyfi_search_archive_with_map'; + mcpArgs = { + aoi: resolvedAoiWkt || extraParams?.aoi, + ...extraParams + }; + break; + case 'validate_order': + mcpToolName = 'skyfi_validate_archive_order'; + mcpArgs = { + aoi: resolvedAoiWkt || extraParams?.aoi, + ...extraParams + }; + break; + case 'place_order': + mcpToolName = 'skyfi_place_archive_order'; + mcpArgs = { + aoi: resolvedAoiWkt || extraParams?.aoi, + ...extraParams + }; + break; + case 'list_orders': + mcpToolName = 'skyfi_list_orders'; + mcpArgs = { + ...extraParams + }; + break; + default: + throw new Error(`Unsupported queryType: ${queryType}`); + } + + // If we need WKT but don't have it, and we have a place name/location, geocode it first! + if (!mcpArgs.aoi && (queryType === 'search' || queryType === 'validate_order' || queryType === 'place_order') && location) { + feedbackMessage = `Geocoding '${location}' using SkyFi to build Area of Interest...`; + uiFeedbackStream.update(feedbackMessage); + + const geocodeResult = await mcpClient.callTool({ + name: 'skyfi_geocode_aoi', + arguments: { place: location } + }) as any; + + const geocodeText = geocodeResult?.content?.[0]?.text || ''; + // Extract polygon WKT + const wktMatch = geocodeText.match(/POLYGON\s*\(\([\s\S]+?\)\)/i) || geocodeText.match(/MULTIPOLYGON\s*\([\s\S]+?\)/i); + if (wktMatch) { + mcpArgs.aoi = wktMatch[0]; + console.log('[SkyfiTool] Geocoded location to WKT:', mcpArgs.aoi); + } else { + throw new Error(`Could not resolve location '${location}' to a valid Area of Interest polygon.`); + } + } + + console.log('[SkyfiTool] Calling tool:', mcpToolName, 'with args:', mcpArgs); + + const mcpResult = await Promise.race([ + mcpClient.callTool({ name: mcpToolName, arguments: mcpArgs }), + new Promise((_, reject) => setTimeout(() => reject(new Error('SkyFi MCP Tool call timeout after 30 seconds')), 30000)), + ]); + + const serviceResponse = mcpResult as { content?: Array<{ text?: string | null } | { [k: string]: any }> }; + const blocks = serviceResponse?.content || []; + const textBlocks = blocks.map(b => (typeof b.text === 'string' ? b.text : null)).filter((t): t is string => !!t && t.trim().length > 0); + + const rawText = textBlocks[0] || 'Success'; + + feedbackMessage = `Action '${queryType}' executed successfully.`; + uiFeedbackStream.update(feedbackMessage); + + await closeClient(mcpClient); + uiFeedbackStream.done(); + uiStream.update(); + + return { + type: 'SKYFI_QUERY', + success: true, + queryType, + result: rawText, + error: null + }; + } catch (error: any) { + const toolError = `SkyFi execution failed: ${error.message}`; + console.error('[SkyfiTool] Execution failed:', error); + uiFeedbackStream.update(toolError); + await closeClient(mcpClient); + uiFeedbackStream.done(); + uiStream.update(); + return { + type: 'SKYFI_QUERY', + success: false, + queryType, + result: null, + error: toolError + }; + } + } +}); diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 7900d466..05506bdc 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -130,8 +130,22 @@ export const documentChunks = pgTable('document_chunks', { createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), }); +export const skyfiOAuthTokens = pgTable('skyfi_oauth_tokens', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }).unique(), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + tokenExpiry: timestamp('token_expiry', { withTimezone: true }), + clientId: text('client_id'), + clientSecret: text('client_secret'), + codeVerifier: text('code_verifier'), + state: text('state'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + // Relations -export const usersRelations = relations(users, ({ many }) => ({ +export const usersRelations = relations(users, ({ one, many }) => ({ chats: many(chats), messages: many(messages), calendarNotes: many(calendarNotes), @@ -141,6 +155,14 @@ export const usersRelations = relations(users, ({ many }) => ({ visualizations: many(visualizations), promptGenerationJobs: many(promptGenerationJobs), documents: many(documents), + skyfiOAuthToken: one(skyfiOAuthTokens), +})); + +export const skyfiOAuthTokensRelations = relations(skyfiOAuthTokens, ({ one }) => ({ + user: one(users, { + fields: [skyfiOAuthTokens.userId], + references: [users.id], + }), })); export const chatsRelations = relations(chats, ({ one, many }) => ({ diff --git a/lib/schema/skyfi.tsx b/lib/schema/skyfi.tsx new file mode 100644 index 00000000..c3c0f55a --- /dev/null +++ b/lib/schema/skyfi.tsx @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +// Flat schema for the unified SkyFi MCP tool. +// This allows the AI agent to easily invoke various SkyFi satellite image tools +// with structured parameters. +export const skyfiQuerySchema = z.object({ + queryType: z.enum(['whoami', 'geocode', 'search', 'validate_order', 'place_order', 'list_orders']) + .describe( + "Type of SkyFi query to perform: " + + "'whoami' → verify credentials and check budget; " + + "'geocode' → convert a place name/location into a WKT polygon Area of Interest (AOI); " + + "'search' → search the satellite archive catalog for latest images over an Area of Interest (AOI); " + + "'validate_order' → validate and price a satellite image archive order (dry run); " + + "'place_order' → place a billable satellite image order (only after user's explicit confirmation); " + + "'list_orders' → list and track previous orders." + ), + location: z.string() + .min(1) + .optional() + .describe("Location name or address to geocode (used by 'geocode' and optionally 'search')"), + aoi: z.string() + .min(1) + .optional() + .describe("WKT polygon representing the Area of Interest (AOI) (used by 'search', 'validate_order', 'place_order')"), + orderId: z.string() + .optional() + .describe("ID of the order (used for tracking or placing orders)"), + params: z.record(z.any()) + .optional() + .describe("Additional optional parameters to pass directly to the corresponding SkyFi MCP tool (e.g. { gsd: 100, sensorType: 'DAY' } etc.)"), + maxResults: z.number() + .int() + .positive() + .optional() + .default(5) + .describe("Maximum number of results to return"), +}); + +export type SkyfiQuery = z.infer; diff --git a/lib/skyfi/provider.ts b/lib/skyfi/provider.ts new file mode 100644 index 00000000..cf8fcff2 --- /dev/null +++ b/lib/skyfi/provider.ts @@ -0,0 +1,179 @@ +import { OAuthClientProvider, OAuthClientMetadata, OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/dist/esm/client/auth.js'; +import { db } from '@/lib/db'; +import { skyfiOAuthTokens } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; + +export class SkyfiOAuthProvider implements OAuthClientProvider { + private userId: string; + private redirectUrlStr: string; + + constructor(userId: string, redirectUrl: string) { + this.userId = userId; + this.redirectUrlStr = redirectUrl; + } + + get redirectUrl(): string | URL | undefined { + return this.redirectUrlStr; + } + + get clientMetadata(): OAuthClientMetadata { + return { + client_name: 'QCX Planetary Copilot', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', // Public client with PKCE + redirect_uris: [this.redirectUrlStr], + }; + } + + async clientInformation(): Promise { + const [row] = await db.select() + .from(skyfiOAuthTokens) + .where(eq(skyfiOAuthTokens.userId, this.userId)) + .limit(1); + + if (row && row.clientId) { + return { + client_id: row.clientId, + client_secret: row.clientSecret || undefined, + registration_client_uri: '', + registration_access_token: '', + } as any; + } + return undefined; + } + + async saveClientInformation(clientInformation: OAuthClientInformationMixed): Promise { + const [existing] = await db.select({ id: skyfiOAuthTokens.id }) + .from(skyfiOAuthTokens) + .where(eq(skyfiOAuthTokens.userId, this.userId)) + .limit(1); + + if (existing) { + await db.update(skyfiOAuthTokens) + .set({ + clientId: clientInformation.client_id, + clientSecret: clientInformation.client_secret || null, + updatedAt: new Date(), + }) + .where(eq(skyfiOAuthTokens.id, existing.id)); + } else { + await db.insert(skyfiOAuthTokens) + .values({ + userId: this.userId, + clientId: clientInformation.client_id, + clientSecret: clientInformation.client_secret || null, + }); + } + } + + async tokens(): Promise { + const [row] = await db.select() + .from(skyfiOAuthTokens) + .where(eq(skyfiOAuthTokens.userId, this.userId)) + .limit(1); + + if (row && row.accessToken) { + return { + access_token: row.accessToken, + refresh_token: row.refreshToken || undefined, + expires_at: row.tokenExpiry ? Math.floor(row.tokenExpiry.getTime() / 1000) : undefined, + }; + } + return undefined; + } + + async saveTokens(tokens: OAuthTokens): Promise { + const [existing] = await db.select({ id: skyfiOAuthTokens.id }) + .from(skyfiOAuthTokens) + .where(eq(skyfiOAuthTokens.userId, this.userId)) + .limit(1); + + const tokenExpiryDate = tokens.expires_at ? new Date(tokens.expires_at * 1000) : null; + + if (existing) { + await db.update(skyfiOAuthTokens) + .set({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || null, + tokenExpiry: tokenExpiryDate, + updatedAt: new Date(), + }) + .where(eq(skyfiOAuthTokens.id, existing.id)); + } else { + await db.insert(skyfiOAuthTokens) + .values({ + userId: this.userId, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || null, + tokenExpiry: tokenExpiryDate, + }); + } + } + + async saveCodeVerifier(codeVerifier: string): Promise { + const [existing] = await db.select({ id: skyfiOAuthTokens.id }) + .from(skyfiOAuthTokens) + .where(eq(skyfiOAuthTokens.userId, this.userId)) + .limit(1); + + if (existing) { + await db.update(skyfiOAuthTokens) + .set({ + codeVerifier, + updatedAt: new Date(), + }) + .where(eq(skyfiOAuthTokens.id, existing.id)); + } else { + await db.insert(skyfiOAuthTokens) + .values({ + userId: this.userId, + codeVerifier, + }); + } + } + + async codeVerifier(): Promise { + const [row] = await db.select({ codeVerifier: skyfiOAuthTokens.codeVerifier }) + .from(skyfiOAuthTokens) + .where(eq(skyfiOAuthTokens.userId, this.userId)) + .limit(1); + + return row?.codeVerifier || ''; + } + + async saveState(state: string): Promise { + const [existing] = await db.select({ id: skyfiOAuthTokens.id }) + .from(skyfiOAuthTokens) + .where(eq(skyfiOAuthTokens.userId, this.userId)) + .limit(1); + + if (existing) { + await db.update(skyfiOAuthTokens) + .set({ + state, + updatedAt: new Date(), + }) + .where(eq(skyfiOAuthTokens.id, existing.id)); + } else { + await db.insert(skyfiOAuthTokens) + .values({ + userId: this.userId, + state, + }); + } + } + + async state(): Promise { + const [row] = await db.select({ state: skyfiOAuthTokens.state }) + .from(skyfiOAuthTokens) + .where(eq(skyfiOAuthTokens.userId, this.userId)) + .limit(1); + + return row?.state || ''; + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + // This is called internally, handled transiently on connection. + } +} From be3bf0569c40a271b5ffb70e9f8b6a803a738f42 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 03:33:58 +0000 Subject: [PATCH 02/11] feat: Fix Type error in skyfi-section and integrate SkyFi MCP satellite imagery Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- components/skyfi-section.tsx | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/components/skyfi-section.tsx b/components/skyfi-section.tsx index 8f90e9ff..d60dc1d9 100644 --- a/components/skyfi-section.tsx +++ b/components/skyfi-section.tsx @@ -2,8 +2,10 @@ import { Section } from './section' import { ToolBadge } from './tool-badge' -import { BotMessage } from './message' import { SearchSkeleton } from './search-skeleton' +import { MemoizedReactMarkdown } from './ui/markdown' +import rehypeExternalLinks from 'rehype-external-links' +import remarkGfm from 'remark-gfm' import { StreamableValue, useStreamableValue } from 'ai/rsc' export type SkyfiSectionProps = { @@ -39,8 +41,17 @@ export function SkyfiSection({ result }: SkyfiSectionProps) { {`SkyFi MCP: ${queryType}`}
-
- +
+ + {resultText} +
@@ -52,3 +63,8 @@ export function SkyfiSection({ result }: SkyfiSectionProps) { ) } + +// Inline helper for classnames +function cn(...classes: string[]) { + return classes.filter(Boolean).join(' ') +} From 65afa91c1afad9138f3163fb4644410583286c82 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:02:21 +0000 Subject: [PATCH 03/11] feat: Implement secure SkyFi OAuth 2.1 connection and satellite imagery tools with token rotation and confirmation gates Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- lib/agents/tools/skyfi.tsx | 39 ++++++++++++++++++---------- lib/schema/skyfi.tsx | 4 +++ lib/skyfi/provider.ts | 52 +++++++++++++++++++++++++++++++++----- 3 files changed, 75 insertions(+), 20 deletions(-) diff --git a/lib/agents/tools/skyfi.tsx b/lib/agents/tools/skyfi.tsx index 840b807f..d7ca2961 100644 --- a/lib/agents/tools/skyfi.tsx +++ b/lib/agents/tools/skyfi.tsx @@ -24,13 +24,6 @@ function geoJsonToWkt(geometry: any): string | null { const coords = geometry.coordinates[0]; const ringStr = coords.map((c: any) => `${c[0]} ${c[1]}`).join(', '); return `POLYGON((${ringStr}))`; - } else if (geometry.type === 'LineString') { - const coords = geometry.coordinates; - const lineStr = coords.map((c: any) => `${c[0]} ${c[1]}`).join(', '); - return `LINESTRING(${lineStr})`; - } else if (geometry.type === 'Point') { - const coords = geometry.coordinates; - return `POINT(${coords[0]} ${coords[1]})`; } return null; } @@ -178,14 +171,23 @@ export const skyfiTool = ({ const latestPolygon = drawnFeatures.find(f => f.type === 'Polygon'); if (latestPolygon) { resolvedAoiWkt = geoJsonToWkt(latestPolygon.geometry) || undefined; - } else { - resolvedAoiWkt = geoJsonToWkt(drawnFeatures[0].geometry) || undefined; } + + if (!resolvedAoiWkt && (queryType === 'search' || queryType === 'validate_order' || queryType === 'place_order') && !location) { + const invalidAoiMessage = "Your drawn area is not a valid closed Polygon. SkyFi requires a closed Polygon to define your Area of Interest. Please use the map drawing tools to draw a closed Polygon."; + uiFeedbackStream.update(invalidAoiMessage); + uiFeedbackStream.done(); + uiStream.update(); + return { type: 'SKYFI_QUERY', success: false, error: 'Invalid AOI geometry', message: invalidAoiMessage }; + } + if (resolvedAoiWkt) { console.log('[SkyfiTool] Resolved WKT from user drawn features on map:', resolvedAoiWkt); } } + let dryRunValidateOnly = false; + switch (queryType) { case 'whoami': mcpToolName = 'skyfi_whoami'; @@ -210,7 +212,13 @@ export const skyfiTool = ({ }; break; case 'place_order': - mcpToolName = 'skyfi_place_archive_order'; + if (!params.confirm) { + console.log('[SkyfiTool] place_order requested without explicit confirm. Overriding to skyfi_validate_archive_order dry run...'); + mcpToolName = 'skyfi_validate_archive_order'; + dryRunValidateOnly = true; + } else { + mcpToolName = 'skyfi_place_archive_order'; + } mcpArgs = { aoi: resolvedAoiWkt || extraParams?.aoi, ...extraParams @@ -258,7 +266,11 @@ export const skyfiTool = ({ const blocks = serviceResponse?.content || []; const textBlocks = blocks.map(b => (typeof b.text === 'string' ? b.text : null)).filter((t): t is string => !!t && t.trim().length > 0); - const rawText = textBlocks[0] || 'Success'; + let rawText = textBlocks[0] || 'Success'; + + if (dryRunValidateOnly) { + rawText += `\n\n⚠️ **EXPLICIT CONFIRMATION REQUIRED** ⚠️\nThis is a dry-run estimate for a billable satellite imagery purchase. To proceed and complete this order, you must explicitly confirm. Please type "confirm" or reply that you would like to proceed with the purchase to authorize this charge.`; + } feedbackMessage = `Action '${queryType}' executed successfully.`; uiFeedbackStream.update(feedbackMessage); @@ -270,9 +282,10 @@ export const skyfiTool = ({ return { type: 'SKYFI_QUERY', success: true, - queryType, + queryType: dryRunValidateOnly ? 'validate_order' : queryType, result: rawText, - error: null + error: null, + confirmationRequired: dryRunValidateOnly }; } catch (error: any) { const toolError = `SkyFi execution failed: ${error.message}`; diff --git a/lib/schema/skyfi.tsx b/lib/schema/skyfi.tsx index c3c0f55a..e76ca333 100644 --- a/lib/schema/skyfi.tsx +++ b/lib/schema/skyfi.tsx @@ -34,6 +34,10 @@ export const skyfiQuerySchema = z.object({ .optional() .default(5) .describe("Maximum number of results to return"), + confirm: z.boolean() + .optional() + .default(false) + .describe("Must be set to true to authorize actual billable charges during 'place_order'. If false or missing, a validation dry-run will be run instead of placing the live order."), }); export type SkyfiQuery = z.infer; diff --git a/lib/skyfi/provider.ts b/lib/skyfi/provider.ts index cf8fcff2..430b2e16 100644 --- a/lib/skyfi/provider.ts +++ b/lib/skyfi/provider.ts @@ -73,14 +73,52 @@ export class SkyfiOAuthProvider implements OAuthClientProvider { .where(eq(skyfiOAuthTokens.userId, this.userId)) .limit(1); - if (row && row.accessToken) { - return { - access_token: row.accessToken, - refresh_token: row.refreshToken || undefined, - expires_at: row.tokenExpiry ? Math.floor(row.tokenExpiry.getTime() / 1000) : undefined, - }; + if (!row || !row.accessToken) { + return undefined; } - return undefined; + + const nowSeconds = Math.floor(Date.now() / 1000); + const expiresAt = row.tokenExpiry ? Math.floor(row.tokenExpiry.getTime() / 1000) : null; + + // Check if token is expired or expiring in less than 60 seconds + if (expiresAt && expiresAt < nowSeconds + 60 && row.refreshToken && row.clientId) { + console.log('[SkyfiProvider] Token expired or expiring soon. Refreshing token...'); + try { + const response = await fetch('https://mcp.skyfi.com/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: row.clientId, + refresh_token: row.refreshToken, + }), + }); + + if (response.ok) { + const data = await response.json(); + console.log('[SkyfiProvider] Token successfully refreshed.'); + const newTokens: OAuthTokens = { + access_token: data.access_token, + refresh_token: data.refresh_token || row.refreshToken, + expires_at: data.expires_in ? Math.floor(Date.now() / 1000) + data.expires_in : undefined, + }; + await this.saveTokens(newTokens); + return newTokens; + } else { + console.error('[SkyfiProvider] Failed to refresh token:', await response.text()); + } + } catch (err: any) { + console.error('[SkyfiProvider] Error refreshing token:', err.message); + } + } + + return { + access_token: row.accessToken, + refresh_token: row.refreshToken || undefined, + expires_at: expiresAt || undefined, + }; } async saveTokens(tokens: OAuthTokens): Promise { From 029c1ba62a3b3f354f54dfa1f755f9141bc1d1a8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 05:03:22 +0000 Subject: [PATCH 04/11] feat: Complete robust secure SkyFi OAuth 2.1 integration and tool refining Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- app/api/skyfi/callback/route.ts | 96 +++++---- components/settings/components/settings.tsx | 16 +- .../components/tool-selection-form.tsx | 15 +- components/skyfi-section.tsx | 22 +- .../0009_add_skyfi_oauth_tokens.sql | 2 + lib/actions/skyfi.ts | 133 +++++++----- lib/actions/users.ts | 99 --------- lib/agents/tools/index.tsx | 7 +- lib/agents/tools/skyfi.tsx | 69 +++++-- lib/db/schema.ts | 2 + lib/skyfi/provider.ts | 191 ++++++++++-------- lib/utils/encryption.ts | 32 +++ public/sw.js | 2 +- 13 files changed, 364 insertions(+), 322 deletions(-) create mode 100644 lib/utils/encryption.ts diff --git a/app/api/skyfi/callback/route.ts b/app/api/skyfi/callback/route.ts index 0e3c8d9f..f6847bcf 100644 --- a/app/api/skyfi/callback/route.ts +++ b/app/api/skyfi/callback/route.ts @@ -1,6 +1,7 @@ import { NextResponse, NextRequest } from 'next/server'; import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; import { SkyfiOAuthProvider } from '@/lib/skyfi/provider'; +import { getRedirectUri } from '@/lib/actions/skyfi'; import { db } from '@/lib/db'; import { skyfiOAuthTokens } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; @@ -20,8 +21,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Missing code or state parameters.' }, { status: 400 }); } - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; - const redirectUri = `${baseUrl}/api/skyfi/callback`; + const redirectUri = await getRedirectUri(); const provider = new SkyfiOAuthProvider(userId, redirectUri); const storedState = await provider.state(); @@ -36,48 +36,62 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Missing client registration or PKCE verifier.' }, { status: 400 }); } - console.log('[SkyFiCallback] Exchanging code for tokens...'); - // Exchange the authorization code for tokens - const response = await fetch('https://mcp.skyfi.com/oauth/token', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - client_id: clientInfo.client_id, - code, - redirect_uri: redirectUri, - code_verifier: verifier, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error('[SkyFiCallback] Token exchange failed:', errorText); - return NextResponse.json({ error: `Token exchange failed: ${errorText}` }, { status: 500 }); - } + console.log('[SkyFiCallback] Exchanging code for tokens with a 10s timeout...'); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch('https://mcp.skyfi.com/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientInfo.client_id, + code, + redirect_uri: redirectUri, + code_verifier: verifier, + }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[SkyFiCallback] Token exchange failed:', errorText); + return NextResponse.json({ error: `Token exchange failed: ${errorText}` }, { status: 500 }); + } - const data = await response.json(); - console.log('[SkyFiCallback] Code successfully exchanged for tokens.'); - - // Save tokens in database - await provider.saveTokens({ - access_token: data.access_token, - refresh_token: data.refresh_token || undefined, - expires_at: data.expires_in ? Math.floor(Date.now() / 1000) + data.expires_in : undefined, - }); - - // Clear temporary state and code verifier - await db.update(skyfiOAuthTokens) - .set({ - state: null, - codeVerifier: null, - updatedAt: new Date(), - }) - .where(eq(skyfiOAuthTokens.userId, userId)); + const data = await response.json(); + console.log('[SkyFiCallback] Code successfully exchanged for tokens.'); + + // Save tokens in database + await provider.saveTokens({ + access_token: data.access_token, + refresh_token: data.refresh_token || undefined, + expires_at: data.expires_in ? Math.floor(Date.now() / 1000) + data.expires_in : undefined, + }); + + // Clear temporary state and code verifier + await db.update(skyfiOAuthTokens) + .set({ + state: null, + codeVerifier: null, + updatedAt: new Date(), + }) + .where(eq(skyfiOAuthTokens.userId, userId)); + + } catch (fetchError: any) { + clearTimeout(timeoutId); + console.error('[SkyFiCallback] Fetch failed or timed out:', fetchError.message); + return NextResponse.json({ error: `Token exchange failed or timed out: ${fetchError.message}` }, { status: 500 }); + } // Redirect user back to the settings page + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; return NextResponse.redirect(`${baseUrl}/settings`); } catch (error: any) { console.error('[SkyFiCallback] Unexpected error:', error.message); diff --git a/components/settings/components/settings.tsx b/components/settings/components/settings.tsx index db83041f..1a3f7904 100644 --- a/components/settings/components/settings.tsx +++ b/components/settings/components/settings.tsx @@ -22,7 +22,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Label } from "@/components/ui/label"; import { useToast } from "@/components/ui/hooks/use-toast" import { getSystemPrompt, saveSystemPrompt } from "../../../lib/actions/chat" -import { getSelectedModel, saveSelectedModel, getSkyfiConfig, saveSkyfiConfig } from "../../../lib/actions/users" +import { getSelectedModel, saveSelectedModel } from "../../../lib/actions/users" import { useCurrentUser } from "@/lib/auth/use-current-user" import { SettingsSkeleton } from './settings-skeleton' import { useUser } from '@clerk/nextjs' @@ -40,7 +40,6 @@ const settingsFormSchema = z.object({ selectedModel: z.string().refine(value => value.trim() !== '', { message: "Please select a tool.", }), - skyfiApiKey: z.string().optional(), users: z.array( z.object({ id: z.string(), @@ -60,7 +59,6 @@ const defaultValues: Partial = { systemPrompt: "You are a planetary copilot, an AI assistant designed to help users with information about planets, space exploration, and astronomy. Provide accurate, educational, and engaging responses about our solar system and beyond.", selectedModel: "QCX-Terra", - skyfiApiKey: "", users: [], domain: "", } @@ -99,10 +97,9 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { async function fetchData() { if (!userId || authLoading) return; - const [existingPrompt, selectedModel, skyfiConfig] = await Promise.all([ + const [existingPrompt, selectedModel] = await Promise.all([ getSystemPrompt(userId), getSelectedModel(), - getSkyfiConfig(), ]); if (existingPrompt) { @@ -111,9 +108,6 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { if (selectedModel) { form.setValue("selectedModel", selectedModel, { shouldValidate: true, shouldDirty: false }); } - if (skyfiConfig?.apiKey) { - form.setValue("skyfiApiKey", skyfiConfig.apiKey, { shouldValidate: true, shouldDirty: false }); - } } fetchData(); }, [form, userId, authLoading]); @@ -136,10 +130,9 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { try { // Save the system prompt and selected model - const [promptSaveResult, modelSaveResult, skyfiSaveResult] = await Promise.all([ + const [promptSaveResult, modelSaveResult] = await Promise.all([ saveSystemPrompt(userId, data.systemPrompt), saveSelectedModel(data.selectedModel), - saveSkyfiConfig({ apiKey: data.skyfiApiKey, initialized: !!data.skyfiApiKey }), ]); if (promptSaveResult?.error) { @@ -148,9 +141,6 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { if (modelSaveResult?.error) { throw new Error(modelSaveResult.error); } - if (skyfiSaveResult?.error) { - throw new Error(skyfiSaveResult.error); - } console.log("Submitted data:", data) diff --git a/components/settings/components/tool-selection-form.tsx b/components/settings/components/tool-selection-form.tsx index 0e432456..3ac3905c 100644 --- a/components/settings/components/tool-selection-form.tsx +++ b/components/settings/components/tool-selection-form.tsx @@ -56,6 +56,7 @@ export function ToolSelectionForm({ form }: ToolSelectionFormProps) { const [skyfiBudget, setSkyfiBudget] = useState(null); const [loadingStatus, setLoadingStatus] = useState(true); const [isConnecting, setIsConnecting] = useState(false); + const [isDisconnecting, setIsDisconnecting] = useState(false); useEffect(() => { async function loadStatus() { @@ -184,8 +185,9 @@ export function ToolSelectionForm({ form }: ToolSelectionFormProps) { type="button" variant="destructive" size="sm" + disabled={isDisconnecting} onClick={async () => { - setLoadingStatus(true); + setIsDisconnecting(true); try { const res = await disconnectSkyfi(); if (res.success) { @@ -205,11 +207,18 @@ export function ToolSelectionForm({ form }: ToolSelectionFormProps) { variant: "destructive", }); } finally { - setLoadingStatus(false); + setIsDisconnecting(false); } }} > - Disconnect + {isDisconnecting ? ( + <> + + Disconnecting... + + ) : ( + "Disconnect" + )} diff --git a/components/skyfi-section.tsx b/components/skyfi-section.tsx index d60dc1d9..69b56ad4 100644 --- a/components/skyfi-section.tsx +++ b/components/skyfi-section.tsx @@ -7,6 +7,7 @@ import { MemoizedReactMarkdown } from './ui/markdown' import rehypeExternalLinks from 'rehype-external-links' import remarkGfm from 'remark-gfm' import { StreamableValue, useStreamableValue } from 'ai/rsc' +import { cn } from '@/lib/utils' export type SkyfiSectionProps = { result?: StreamableValue @@ -33,6 +34,22 @@ export function SkyfiSection({ result }: SkyfiSectionProps) { } } + // Handle stream error explicitly + if (error) { + return ( +
+
+ {`SkyFi MCP Error`} +
+
+
+ {(error as any).message || String(error)} +
+
+
+ ) + } + return (
{!pending && data ? ( @@ -63,8 +80,3 @@ export function SkyfiSection({ result }: SkyfiSectionProps) {
) } - -// Inline helper for classnames -function cn(...classes: string[]) { - return classes.filter(Boolean).join(' ') -} diff --git a/drizzle/migrations/0009_add_skyfi_oauth_tokens.sql b/drizzle/migrations/0009_add_skyfi_oauth_tokens.sql index faf355a4..c3d70384 100644 --- a/drizzle/migrations/0009_add_skyfi_oauth_tokens.sql +++ b/drizzle/migrations/0009_add_skyfi_oauth_tokens.sql @@ -8,6 +8,8 @@ CREATE TABLE IF NOT EXISTS "skyfi_oauth_tokens" ( "client_secret" text, "code_verifier" text, "state" text, + "registration_client_uri" text, + "registration_access_token" text, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT "skyfi_oauth_tokens_user_id_unique" UNIQUE("user_id") diff --git a/lib/actions/skyfi.ts b/lib/actions/skyfi.ts index a5c4a4d3..2cdf5595 100644 --- a/lib/actions/skyfi.ts +++ b/lib/actions/skyfi.ts @@ -8,21 +8,14 @@ import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; import { SkyfiOAuthProvider } from '@/lib/skyfi/provider'; import crypto from 'crypto'; -function generateCodeVerifier(): string { - return crypto.randomBytes(32).toString('base64url'); -} - -function generateCodeChallenge(verifier: string): string { - return crypto.createHash('sha256').update(verifier).digest('base64url'); -} - -function getRedirectUri(): string { +export async function getRedirectUri(): Promise { const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; return `${baseUrl}/api/skyfi/callback`; } /** * Ensures the client is registered dynamically with the SkyFi MCP server. + * Uses an AbortController with a 10s timeout to prevent hanging. */ async function ensureClientRegistered(provider: SkyfiOAuthProvider): Promise { const currentInfo = await provider.clientInformation(); @@ -31,29 +24,43 @@ async function ensureClientRegistered(provider: SkyfiOAuthProvider): Promise controller.abort(), 10000); + + try { + const res = await fetch('https://mcp.skyfi.com/oauth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'QCX Planetary Copilot', + redirect_uris: [provider.redirectUrl?.toString()], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!res.ok) { + throw new Error(`Dynamic Client Registration failed: ${await res.text()}`); + } - return data.client_id; + const data = await res.json(); + await provider.saveClientInformation({ + client_id: data.client_id, + client_secret: data.client_secret || null, + registration_client_uri: data.registration_client_uri || null, + registration_access_token: data.registration_access_token || null, + } as any); + + return data.client_id; + } catch (error: any) { + clearTimeout(timeoutId); + throw error; + } } /** @@ -67,7 +74,7 @@ export async function startSkyfiConnection(): Promise<{ url?: string; error?: st return { error: 'Unauthorized: User not authenticated.' }; } - const redirectUri = getRedirectUri(); + const redirectUri = await getRedirectUri(); const provider = new SkyfiOAuthProvider(userId, redirectUri); const clientId = await ensureClientRegistered(provider); @@ -97,6 +104,7 @@ export async function startSkyfiConnection(): Promise<{ url?: string; error?: st /** * Returns whether a valid SkyFi connection exists for the current user. + * Uses an AbortController with a 10s timeout to prevent hanging. */ export async function getSkyfiConnectionStatus(): Promise<{ connected: boolean; email?: string; budget?: string }> { try { @@ -105,7 +113,7 @@ export async function getSkyfiConnectionStatus(): Promise<{ connected: boolean; return { connected: false }; } - const redirectUri = getRedirectUri(); + const redirectUri = await getRedirectUri(); const provider = new SkyfiOAuthProvider(userId, redirectUri); const tokens = await provider.tokens(); @@ -113,28 +121,39 @@ export async function getSkyfiConnectionStatus(): Promise<{ connected: boolean; return { connected: false }; } - // Try a simple whoami call to verify token validity and get email/budget - const res = await fetch('https://mcp.skyfi.com/mcp', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Skyfi-Api-Key': tokens.access_token, - }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { - name: 'skyfi_whoami', - arguments: {}, - }, - }), - }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); - if (res.ok) { - const data = await res.json(); - const content = data?.result?.content?.[0]?.text || ''; - return { connected: true, budget: content }; + try { + // Try a simple whoami call to verify token validity and get email/budget + const res = await fetch('https://mcp.skyfi.com/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Skyfi-Api-Key': tokens.access_token, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'skyfi_whoami', + arguments: {}, + }, + }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (res.ok) { + const data = await res.json(); + const content = data?.result?.content?.[0]?.text || ''; + return { connected: true, budget: content }; + } + } catch (fetchError) { + clearTimeout(timeoutId); + console.warn('[SkyFiAction: getSkyfiConnectionStatus] Failed to query whoami:', fetchError); } return { connected: true }; @@ -164,3 +183,11 @@ export async function disconnectSkyfi(): Promise<{ success: boolean; error?: str return { success: false, error: error.message || 'Failed to disconnect SkyFi.' }; } } + +function generateCodeVerifier(): string { + return crypto.randomBytes(32).toString('base64url'); +} + +function generateCodeChallenge(verifier: string): string { + return crypto.createHash('sha256').update(verifier).digest('base64url'); +} diff --git a/lib/actions/users.ts b/lib/actions/users.ts index 3147419e..6fba7f8b 100644 --- a/lib/actions/users.ts +++ b/lib/actions/users.ts @@ -209,102 +209,3 @@ export async function searchUsers(query: string) { return []; } } - -import { Client as MCPClientClass } from '@modelcontextprotocol/sdk/client/index.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; - -/** - * Fetches the SkyFi config for the current user. - */ -export async function getSkyfiConfig(): Promise<{ apiKey?: string; initialized?: boolean } | null> { - const userId = await getCurrentUserIdOnServer(); - if (!userId) return null; - try { - const result = await db.select({ metadata: users.metadata }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - const metadata = result[0]?.metadata as any; - return metadata?.skyfi || null; - } catch (error) { - console.error('[Action: getSkyfiConfig] Error:', error); - return null; - } -} - -/** - * Saves the SkyFi config for the current user. - */ -export async function saveSkyfiConfig(config: { apiKey?: string; initialized?: boolean }): Promise<{ success: boolean; error?: string }> { - const userId = await getCurrentUserIdOnServer(); - if (!userId) return { success: false, error: 'Not authenticated' }; - try { - const result = await db.select({ metadata: users.metadata }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - const currentMetadata = (result[0]?.metadata as Record) || {}; - - await db.update(users) - .set({ - metadata: { - ...currentMetadata, - skyfi: config - } - }) - .where(eq(users.id, userId)); - - revalidatePath('/settings'); - return { success: true }; - } catch (error) { - console.error('[Action: saveSkyfiConfig] Error:', error); - return { success: false, error: 'Failed to save SkyFi configuration.' }; - } -} - -/** - * Tests connection to the SkyFi MCP server using the provided API key. - */ -export async function testSkyfiConnection(apiKey: string): Promise<{ success: boolean; data?: string; error?: string }> { - if (!apiKey || !apiKey.trim()) { - return { success: false, error: 'API Key is required.' }; - } - - const serverUrl = new URL('https://mcp.skyfi.com/mcp'); - let transport; - let client; - - try { - transport = new StreamableHTTPClientTransport(serverUrl, { - requestInit: { - headers: { - 'X-Skyfi-Api-Key': apiKey, - } - } - }); - client = new MCPClientClass({ name: 'SkyfiTestClient', version: '1.0.0' }); - - await Promise.race([ - client.connect(transport), - new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout after 10 seconds')), 10000)), - ]); - - const result = await Promise.race([ - client.callTool({ name: 'skyfi_whoami', arguments: {} }), - new Promise((_, reject) => setTimeout(() => reject(new Error('Tool call timeout after 10 seconds')), 10000)), - ]); - - await client.close(); - - const response = result as { content?: Array<{ text?: string }> }; - const textBlock = response?.content?.[0]?.text || ''; - - return { success: true, data: textBlock }; - } catch (err: any) { - console.error('[Action: testSkyfiConnection] Error:', err.message); - if (client) { - try { await client.close(); } catch {} - } - return { success: false, error: err.message || 'Failed to connect to SkyFi MCP.' }; - } -} diff --git a/lib/agents/tools/index.tsx b/lib/agents/tools/index.tsx index 0fd79650..90f2da7c 100644 --- a/lib/agents/tools/index.tsx +++ b/lib/agents/tools/index.tsx @@ -30,8 +30,11 @@ export const getTools = ({ uiStream, fullResponse, mapProvider, selectedModel, d documentRetrieve: documentRetrieveTool({ uiStream, fullResponse - }), - skyfiQueryTool: skyfiTool({ + }) + } + + if (selectedModel === 'SkyFi') { + tools.skyfiQueryTool = skyfiTool({ uiStream, drawnFeatures }) diff --git a/lib/agents/tools/skyfi.tsx b/lib/agents/tools/skyfi.tsx index d7ca2961..af274ba4 100644 --- a/lib/agents/tools/skyfi.tsx +++ b/lib/agents/tools/skyfi.tsx @@ -10,6 +10,7 @@ import { SkyfiOAuthProvider } from '@/lib/skyfi/provider'; import { skyfiQuerySchema } from '@/lib/schema/skyfi'; import { DrawnFeature } from '@/lib/agents/resolution-search'; import { z } from 'zod'; +import crypto from 'crypto'; export type McpClient = MCPClientClass; @@ -29,14 +30,14 @@ function geoJsonToWkt(geometry: any): string | null { } /** - * Establish connection to the SkyFi MCP server with the user's stored OAuth token. + * Establish connection to the SkyFi MCP server with the user's stored OAuth token and support AbortSignal. */ -async function getConnectedSkyfiMcpClient(accessToken: string): Promise { +async function getConnectedSkyfiMcpClient(accessToken: string, signal?: AbortSignal): Promise { console.log('[SkyfiTool] Connecting to SkyFi MCP server via OAuth Bearer token...'); const serverUrl = new URL('https://mcp.skyfi.com/mcp'); - // Create transport with headers + // Create transport with headers and abort signal let transport; try { transport = new StreamableHTTPClientTransport(serverUrl, { @@ -44,7 +45,8 @@ async function getConnectedSkyfiMcpClient(accessToken: string): Promise) => { - const { queryType, location, aoi, params: extraParams } = params; + const { queryType, location, aoi, params: extraParams, maxResults } = params; console.log('[SkyfiTool] Execute called with:', params); const uiFeedbackStream = createStreamableValue(); @@ -148,8 +150,16 @@ export const skyfiTool = ({ let feedbackMessage = `Connecting to SkyFi MCP server...`; uiFeedbackStream.update(feedbackMessage); - const mcpClient = await getConnectedSkyfiMcpClient(tokens.access_token); + // Setup abort controller for tool execution timeout (30 seconds) + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + console.log('[SkyfiTool] Timeout triggered. Aborting underlying requests.'); + controller.abort(); + }, 30000); + + const mcpClient = await getConnectedSkyfiMcpClient(tokens.access_token, controller.signal); if (!mcpClient) { + clearTimeout(timeoutId); feedbackMessage = 'Failed to connect to the SkyFi MCP server. Your login may have expired, please try reconnecting in Settings.'; uiFeedbackStream.update(feedbackMessage); uiFeedbackStream.done(); @@ -157,6 +167,9 @@ export const skyfiTool = ({ return { type: 'SKYFI_QUERY', success: false, error: 'Connection failed' }; } + let dryRunValidateOnly = false; + let stableIdempotencyKey = ''; + try { feedbackMessage = `Successfully connected to SkyFi. Executing '${queryType}' action...`; uiFeedbackStream.update(feedbackMessage); @@ -174,6 +187,7 @@ export const skyfiTool = ({ } if (!resolvedAoiWkt && (queryType === 'search' || queryType === 'validate_order' || queryType === 'place_order') && !location) { + clearTimeout(timeoutId); const invalidAoiMessage = "Your drawn area is not a valid closed Polygon. SkyFi requires a closed Polygon to define your Area of Interest. Please use the map drawing tools to draw a closed Polygon."; uiFeedbackStream.update(invalidAoiMessage); uiFeedbackStream.done(); @@ -186,7 +200,10 @@ export const skyfiTool = ({ } } - let dryRunValidateOnly = false; + // Stable idempotency key derivation to prevent duplicate order placements + stableIdempotencyKey = crypto.createHash('sha256') + .update(`${userId}_${resolvedAoiWkt || ''}_${queryType}`) + .digest('hex'); switch (queryType) { case 'whoami': @@ -200,15 +217,16 @@ export const skyfiTool = ({ case 'search': mcpToolName = 'skyfi_search_archive_with_map'; mcpArgs = { - aoi: resolvedAoiWkt || extraParams?.aoi, - ...extraParams + maxResults: maxResults || 5, + ...extraParams, + aoi: resolvedAoiWkt || extraParams?.aoi }; break; case 'validate_order': mcpToolName = 'skyfi_validate_archive_order'; mcpArgs = { - aoi: resolvedAoiWkt || extraParams?.aoi, - ...extraParams + ...extraParams, + aoi: resolvedAoiWkt || extraParams?.aoi }; break; case 'place_order': @@ -220,8 +238,9 @@ export const skyfiTool = ({ mcpToolName = 'skyfi_place_archive_order'; } mcpArgs = { - aoi: resolvedAoiWkt || extraParams?.aoi, - ...extraParams + idempotencyKey: stableIdempotencyKey, + ...extraParams, + aoi: resolvedAoiWkt || extraParams?.aoi }; break; case 'list_orders': @@ -242,9 +261,9 @@ export const skyfiTool = ({ const geocodeResult = await mcpClient.callTool({ name: 'skyfi_geocode_aoi', arguments: { place: location } - }) as any; + }); - const geocodeText = geocodeResult?.content?.[0]?.text || ''; + const geocodeText = (geocodeResult as any)?.content?.[0]?.text || ''; // Extract polygon WKT const wktMatch = geocodeText.match(/POLYGON\s*\(\([\s\S]+?\)\)/i) || geocodeText.match(/MULTIPOLYGON\s*\([\s\S]+?\)/i); if (wktMatch) { @@ -257,10 +276,9 @@ export const skyfiTool = ({ console.log('[SkyfiTool] Calling tool:', mcpToolName, 'with args:', mcpArgs); - const mcpResult = await Promise.race([ - mcpClient.callTool({ name: mcpToolName, arguments: mcpArgs }), - new Promise((_, reject) => setTimeout(() => reject(new Error('SkyFi MCP Tool call timeout after 30 seconds')), 30000)), - ]); + const mcpResult = await mcpClient.callTool({ name: mcpToolName, arguments: mcpArgs }); + + clearTimeout(timeoutId); const serviceResponse = mcpResult as { content?: Array<{ text?: string | null } | { [k: string]: any }> }; const blocks = serviceResponse?.content || []; @@ -288,7 +306,18 @@ export const skyfiTool = ({ confirmationRequired: dryRunValidateOnly }; } catch (error: any) { - const toolError = `SkyFi execution failed: ${error.message}`; + clearTimeout(timeoutId); + + const isAbortError = error.name === 'AbortError' || error.message?.includes('aborted') || error.message?.includes('timeout'); + let toolError = `SkyFi execution failed: ${error.message}`; + + if (isAbortError) { + toolError = `SkyFi execution failed: Request timed out after 30 seconds.`; + if (queryType === 'place_order' && !dryRunValidateOnly) { + toolError += ` The order request was aborted to prevent duplicate placements. Use the idempotency key: ${stableIdempotencyKey} if you attempt to retry.`; + } + } + console.error('[SkyfiTool] Execution failed:', error); uiFeedbackStream.update(toolError); await closeClient(mcpClient); diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 05506bdc..858888c4 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -140,6 +140,8 @@ export const skyfiOAuthTokens = pgTable('skyfi_oauth_tokens', { clientSecret: text('client_secret'), codeVerifier: text('code_verifier'), state: text('state'), + registrationClientUri: text('registration_client_uri'), + registrationAccessToken: text('registration_access_token'), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }); diff --git a/lib/skyfi/provider.ts b/lib/skyfi/provider.ts index 430b2e16..a728f696 100644 --- a/lib/skyfi/provider.ts +++ b/lib/skyfi/provider.ts @@ -1,7 +1,45 @@ -import { OAuthClientProvider, OAuthClientMetadata, OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/dist/esm/client/auth.js'; import { db } from '@/lib/db'; import { skyfiOAuthTokens } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; +import { encrypt, decrypt } from '@/lib/utils/encryption'; + +export interface OAuthClientMetadata { + client_name?: string; + grant_types?: string[]; + response_types?: string[]; + token_endpoint_auth_method?: string; + redirect_uris?: string[]; + [key: string]: any; +} + +export interface OAuthClientInformationMixed { + client_id: string; + client_secret?: string; + registration_client_uri?: string; + registration_access_token?: string; + [key: string]: any; +} + +export interface OAuthTokens { + access_token: string; + refresh_token?: string; + expires_at?: number; + [key: string]: any; +} + +export interface OAuthClientProvider { + redirectUrl: string | URL | undefined; + clientMetadataUrl?: string; + clientMetadata: OAuthClientMetadata; + state?(): string | Promise; + clientInformation(): OAuthClientInformationMixed | undefined | Promise; + saveClientInformation?(clientInformation: OAuthClientInformationMixed): void | Promise; + tokens(): OAuthTokens | undefined | Promise; + saveTokens(tokens: OAuthTokens): void | Promise; + redirectToAuthorization(authorizationUrl: URL): void | Promise; + saveCodeVerifier(codeVerifier: string): void | Promise; + codeVerifier(): string | Promise; +} export class SkyfiOAuthProvider implements OAuthClientProvider { private userId: string; @@ -35,36 +73,33 @@ export class SkyfiOAuthProvider implements OAuthClientProvider { if (row && row.clientId) { return { client_id: row.clientId, - client_secret: row.clientSecret || undefined, - registration_client_uri: '', - registration_access_token: '', - } as any; + client_secret: decrypt(row.clientSecret) || undefined, + registration_client_uri: row.registrationClientUri || undefined, + registration_access_token: decrypt(row.registrationAccessToken) || undefined, + }; } return undefined; } async saveClientInformation(clientInformation: OAuthClientInformationMixed): Promise { - const [existing] = await db.select({ id: skyfiOAuthTokens.id }) - .from(skyfiOAuthTokens) - .where(eq(skyfiOAuthTokens.userId, this.userId)) - .limit(1); - - if (existing) { - await db.update(skyfiOAuthTokens) - .set({ + await db.insert(skyfiOAuthTokens) + .values({ + userId: this.userId, + clientId: clientInformation.client_id, + clientSecret: encrypt(clientInformation.client_secret || null), + registrationClientUri: clientInformation.registration_client_uri || null, + registrationAccessToken: encrypt(clientInformation.registration_access_token || null), + }) + .onConflictDoUpdate({ + target: skyfiOAuthTokens.userId, + set: { clientId: clientInformation.client_id, - clientSecret: clientInformation.client_secret || null, + clientSecret: encrypt(clientInformation.client_secret || null), + registrationClientUri: clientInformation.registration_client_uri || null, + registrationAccessToken: encrypt(clientInformation.registration_access_token || null), updatedAt: new Date(), - }) - .where(eq(skyfiOAuthTokens.id, existing.id)); - } else { - await db.insert(skyfiOAuthTokens) - .values({ - userId: this.userId, - clientId: clientInformation.client_id, - clientSecret: clientInformation.client_secret || null, - }); - } + } + }); } async tokens(): Promise { @@ -77,11 +112,18 @@ export class SkyfiOAuthProvider implements OAuthClientProvider { return undefined; } + const decryptedAccessToken = decrypt(row.accessToken); + const decryptedRefreshToken = decrypt(row.refreshToken); + + if (!decryptedAccessToken) { + return undefined; + } + const nowSeconds = Math.floor(Date.now() / 1000); const expiresAt = row.tokenExpiry ? Math.floor(row.tokenExpiry.getTime() / 1000) : null; // Check if token is expired or expiring in less than 60 seconds - if (expiresAt && expiresAt < nowSeconds + 60 && row.refreshToken && row.clientId) { + if (expiresAt && expiresAt < nowSeconds + 60 && decryptedRefreshToken && row.clientId) { console.log('[SkyfiProvider] Token expired or expiring soon. Refreshing token...'); try { const response = await fetch('https://mcp.skyfi.com/oauth/token', { @@ -92,7 +134,7 @@ export class SkyfiOAuthProvider implements OAuthClientProvider { body: new URLSearchParams({ grant_type: 'refresh_token', client_id: row.clientId, - refresh_token: row.refreshToken, + refresh_token: decryptedRefreshToken, }), }); @@ -101,7 +143,7 @@ export class SkyfiOAuthProvider implements OAuthClientProvider { console.log('[SkyfiProvider] Token successfully refreshed.'); const newTokens: OAuthTokens = { access_token: data.access_token, - refresh_token: data.refresh_token || row.refreshToken, + refresh_token: data.refresh_token || decryptedRefreshToken, expires_at: data.expires_in ? Math.floor(Date.now() / 1000) + data.expires_in : undefined, }; await this.saveTokens(newTokens); @@ -115,60 +157,46 @@ export class SkyfiOAuthProvider implements OAuthClientProvider { } return { - access_token: row.accessToken, - refresh_token: row.refreshToken || undefined, + access_token: decryptedAccessToken, + refresh_token: decryptedRefreshToken || undefined, expires_at: expiresAt || undefined, }; } async saveTokens(tokens: OAuthTokens): Promise { - const [existing] = await db.select({ id: skyfiOAuthTokens.id }) - .from(skyfiOAuthTokens) - .where(eq(skyfiOAuthTokens.userId, this.userId)) - .limit(1); - const tokenExpiryDate = tokens.expires_at ? new Date(tokens.expires_at * 1000) : null; - if (existing) { - await db.update(skyfiOAuthTokens) - .set({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || null, + await db.insert(skyfiOAuthTokens) + .values({ + userId: this.userId, + accessToken: encrypt(tokens.access_token), + refreshToken: encrypt(tokens.refresh_token || null), + tokenExpiry: tokenExpiryDate, + }) + .onConflictDoUpdate({ + target: skyfiOAuthTokens.userId, + set: { + accessToken: encrypt(tokens.access_token), + refreshToken: encrypt(tokens.refresh_token || null), tokenExpiry: tokenExpiryDate, updatedAt: new Date(), - }) - .where(eq(skyfiOAuthTokens.id, existing.id)); - } else { - await db.insert(skyfiOAuthTokens) - .values({ - userId: this.userId, - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || null, - tokenExpiry: tokenExpiryDate, - }); - } + } + }); } async saveCodeVerifier(codeVerifier: string): Promise { - const [existing] = await db.select({ id: skyfiOAuthTokens.id }) - .from(skyfiOAuthTokens) - .where(eq(skyfiOAuthTokens.userId, this.userId)) - .limit(1); - - if (existing) { - await db.update(skyfiOAuthTokens) - .set({ + await db.insert(skyfiOAuthTokens) + .values({ + userId: this.userId, + codeVerifier, + }) + .onConflictDoUpdate({ + target: skyfiOAuthTokens.userId, + set: { codeVerifier, updatedAt: new Date(), - }) - .where(eq(skyfiOAuthTokens.id, existing.id)); - } else { - await db.insert(skyfiOAuthTokens) - .values({ - userId: this.userId, - codeVerifier, - }); - } + } + }); } async codeVerifier(): Promise { @@ -181,25 +209,18 @@ export class SkyfiOAuthProvider implements OAuthClientProvider { } async saveState(state: string): Promise { - const [existing] = await db.select({ id: skyfiOAuthTokens.id }) - .from(skyfiOAuthTokens) - .where(eq(skyfiOAuthTokens.userId, this.userId)) - .limit(1); - - if (existing) { - await db.update(skyfiOAuthTokens) - .set({ + await db.insert(skyfiOAuthTokens) + .values({ + userId: this.userId, + state, + }) + .onConflictDoUpdate({ + target: skyfiOAuthTokens.userId, + set: { state, updatedAt: new Date(), - }) - .where(eq(skyfiOAuthTokens.id, existing.id)); - } else { - await db.insert(skyfiOAuthTokens) - .values({ - userId: this.userId, - state, - }); - } + } + }); } async state(): Promise { @@ -212,6 +233,6 @@ export class SkyfiOAuthProvider implements OAuthClientProvider { } async redirectToAuthorization(authorizationUrl: URL): Promise { - // This is called internally, handled transiently on connection. + // Handled transiently } } diff --git a/lib/utils/encryption.ts b/lib/utils/encryption.ts new file mode 100644 index 00000000..7073ab50 --- /dev/null +++ b/lib/utils/encryption.ts @@ -0,0 +1,32 @@ +import crypto from 'crypto'; + +const ALGORITHM = 'aes-256-cbc'; +const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default_32_byte_secret_key_placeholder_for_local_dev'; // Must be 32 bytes + +export function encrypt(text: string | null | undefined): string | null { + if (!text) return null; + const iv = crypto.randomBytes(16); + // Ensure the key is exactly 32 bytes + const key = crypto.createHash('sha256').update(ENCRYPTION_KEY).digest(); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + return `${iv.toString('hex')}:${encrypted}`; +} + +export function decrypt(encryptedText: string | null | undefined): string | null { + if (!encryptedText) return null; + try { + const [ivHex, encrypted] = encryptedText.split(':'); + if (!ivHex || !encrypted) return null; + const iv = Buffer.from(ivHex, 'hex'); + const key = crypto.createHash('sha256').update(ENCRYPTION_KEY).digest(); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } catch (error) { + console.error('[Encryption] Decryption failed:', error); + return null; + } +} diff --git a/public/sw.js b/public/sw.js index 6a92dcb8..4ae3f92a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,2 +1,2 @@ (()=>{"use strict";let e,t,a,r={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"serwist",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},s=e=>[r.prefix,e,r.suffix].filter(e=>e&&e.length>0).join("-"),i=e=>{for(let t of Object.keys(r))e(t)},n={updateDetails:e=>{i(t=>{let a=e[t];"string"==typeof a&&(r[t]=a)})},getGoogleAnalyticsName:e=>e||s(r.googleAnalytics),getPrecacheName:e=>e||s(r.precache),getRuntimeName:e=>e||s(r.runtime)},c=(e,...t)=>{let a=e;return t.length>0&&(a+=` :: ${JSON.stringify(t)}`),a};var o=class extends Error{details;constructor(e,t){super(c(e,t)),this.name=e,this.details=t}};let l=e=>new URL(String(e),location.href).href.replace(RegExp(`^${location.origin}`),"");function h(e){return new Promise(t=>setTimeout(t,e))}let u=new Set;function d(e,t){let a=new URL(e);for(let e of t)a.searchParams.delete(e);return a.href}async function f(e,t,a,r){let s=d(t.url,a);if(t.url===s)return e.match(t,r);let i={...r,ignoreSearch:!0};for(let n of(await e.keys(t,i)))if(s===d(n.url,a))return e.match(n,r)}var w=class{promise;resolve;reject;constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}};let y=async()=>{for(let e of u)await e()},p="-precache-",g=async(e,t=p)=>{let a=(await self.caches.keys()).filter(a=>a.includes(t)&&a.includes(self.registration.scope)&&a!==e);return await Promise.all(a.map(e=>self.caches.delete(e))),a},m=e=>{self.addEventListener("activate",t=>{t.waitUntil(g(n.getPrecacheName(e)).then(e=>{}))})},_=()=>{self.addEventListener("activate",()=>self.clients.claim())},v=(e,t)=>{let a=t();return e.waitUntil(a),a},b=(e,t)=>t.some(t=>e instanceof t),R=new WeakMap,q=new WeakMap,E=new WeakMap,D={get(e,t,a){if(e instanceof IDBTransaction){if("done"===t)return R.get(e);if("store"===t)return a.objectStoreNames[1]?void 0:a.objectStore(a.objectStoreNames[0])}return S(e[t])},set:(e,t,a)=>(e[t]=a,!0),has:(e,t)=>e instanceof IDBTransaction&&("done"===t||"store"===t)||t in e};function S(e){if(e instanceof IDBRequest){let t=new Promise((t,a)=>{let r=()=>{e.removeEventListener("success",s),e.removeEventListener("error",i)},s=()=>{t(S(e.result)),r()},i=()=>{a(e.error),r()};e.addEventListener("success",s),e.addEventListener("error",i)});return E.set(t,e),t}if(q.has(e))return q.get(e);let r=function(e){if("function"==typeof e)return(a||(a=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(e)?function(...t){return e.apply(P(this),t),S(this.request)}:function(...t){return S(e.apply(P(this),t))};return(e instanceof IDBTransaction&&function(e){if(R.has(e))return;let t=new Promise((t,a)=>{let r=()=>{e.removeEventListener("complete",s),e.removeEventListener("error",i),e.removeEventListener("abort",i)},s=()=>{t(),r()},i=()=>{a(e.error||new DOMException("AbortError","AbortError")),r()};e.addEventListener("complete",s),e.addEventListener("error",i),e.addEventListener("abort",i)});R.set(e,t)}(e),b(e,t||(t=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])))?new Proxy(e,D):e}(e);return r!==e&&(q.set(e,r),E.set(r,e)),r}let P=e=>E.get(e),k=["get","getKey","getAll","getAllKeys","count"],C=["put","add","delete","clear"],T=new Map;function N(e,t){if(!(e instanceof IDBDatabase&&!(t in e)&&"string"==typeof t))return;if(T.get(t))return T.get(t);let a=t.replace(/FromIndex$/,""),r=t!==a,s=C.includes(a);if(!(a in(r?IDBIndex:IDBObjectStore).prototype)||!(s||k.includes(a)))return;let i=async function(e,...t){let i=this.transaction(e,s?"readwrite":"readonly"),n=i.store;return r&&(n=n.index(t.shift())),(await Promise.all([n[a](...t),s&&i.done]))[0]};return T.set(t,i),i}D=(e=>({...e,get:(t,a,r)=>N(t,a)||e.get(t,a,r),has:(t,a)=>!!N(t,a)||e.has(t,a)}))(D);let I=["continue","continuePrimaryKey","advance"],L={},x=new WeakMap,U=new WeakMap,B={get(e,t){if(!I.includes(t))return e[t];let a=L[t];return a||(a=L[t]=function(...e){x.set(this,U.get(this)[t](...e))}),a}};async function*O(...e){let t=this;if(t instanceof IDBCursor||(t=await t.openCursor(...e)),!t)return;let a=new Proxy(t,B);for(U.set(a,t),E.set(a,P(t));t;)yield a,t=await (x.get(a)||t.continue()),x.delete(a)}function K(e,t){return t===Symbol.asyncIterator&&b(e,[IDBIndex,IDBObjectStore,IDBCursor])||"iterate"===t&&b(e,[IDBIndex,IDBObjectStore])}D=(e=>({...e,get:(t,a,r)=>K(t,a)?O:e.get(t,a,r),has:(t,a)=>K(t,a)||e.has(t,a)}))(D);let A=async(t,a)=>{let r=null;if(t.url&&(r=new URL(t.url).origin),r!==self.location.origin)throw new o("cross-origin-copy-response",{origin:r});let s=t.clone(),i={headers:new Headers(s.headers),status:s.status,statusText:s.statusText},n=a?a(i):i,c=!function(){if(void 0===e){let t=new Response("");if("body"in t)try{new Response(t.body),e=!0}catch{e=!1}e=!1}return e}()?await s.blob():s.body;return new Response(c,n)},M=()=>{self.__WB_DISABLE_DEV_LOGS=!0},F="requests",W="queueName";var j=class{_db=null;async addEntry(e){let t=(await this.getDb()).transaction(F,"readwrite",{durability:"relaxed"});await t.store.add(e),await t.done}async getFirstEntryId(){return(await (await this.getDb()).transaction(F).store.openCursor())?.value.id}async getAllEntriesByQueueName(e){return await (await this.getDb()).getAllFromIndex(F,W,IDBKeyRange.only(e))||[]}async getEntryCountByQueueName(e){return(await this.getDb()).countFromIndex(F,W,IDBKeyRange.only(e))}async deleteEntry(e){await (await this.getDb()).delete(F,e)}async getFirstEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"next")}async getLastEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"prev")}async getEndEntryFromIndex(e,t){return(await (await this.getDb()).transaction(F).store.index(W).openCursor(e,t))?.value}async getDb(){return this._db||(this._db=await function(e,t,{blocked:a,upgrade:r,blocking:s,terminated:i}={}){let n=indexedDB.open(e,3),c=S(n);return r&&n.addEventListener("upgradeneeded",e=>{r(S(n.result),e.oldVersion,e.newVersion,S(n.transaction),e)}),a&&n.addEventListener("blocked",e=>a(e.oldVersion,e.newVersion,e)),c.then(e=>{i&&e.addEventListener("close",()=>i()),s&&e.addEventListener("versionchange",e=>s(e.oldVersion,e.newVersion,e))}).catch(()=>{}),c}("serwist-background-sync",0,{upgrade:this._upgradeDb})),this._db}_upgradeDb(e,t){t>0&&t<3&&e.objectStoreNames.contains(F)&&e.deleteObjectStore(F),e.createObjectStore(F,{autoIncrement:!0,keyPath:"id"}).createIndex(W,W,{unique:!1})}},H=class{_queueName;_queueDb;constructor(e){this._queueName=e,this._queueDb=new j}async pushEntry(e){delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async unshiftEntry(e){let t=await this._queueDb.getFirstEntryId();t?e.id=t-1:delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async popEntry(){return this._removeEntry(await this._queueDb.getLastEntryByQueueName(this._queueName))}async shiftEntry(){return this._removeEntry(await this._queueDb.getFirstEntryByQueueName(this._queueName))}async getAll(){return await this._queueDb.getAllEntriesByQueueName(this._queueName)}async size(){return await this._queueDb.getEntryCountByQueueName(this._queueName)}async deleteEntry(e){await this._queueDb.deleteEntry(e)}async _removeEntry(e){return e&&await this.deleteEntry(e.id),e}};let $=["method","referrer","referrerPolicy","mode","credentials","cache","redirect","integrity","keepalive"];var G=class e{_requestData;static async fromRequest(t){let a={url:t.url,headers:{}};for(let e of("GET"!==t.method&&(a.body=await t.clone().arrayBuffer()),t.headers.forEach((e,t)=>{a.headers[t]=e}),$))void 0!==t[e]&&(a[e]=t[e]);return new e(a)}constructor(e){"navigate"===e.mode&&(e.mode="same-origin"),this._requestData=e}toObject(){let e=Object.assign({},this._requestData);return e.headers=Object.assign({},this._requestData.headers),e.body&&(e.body=e.body.slice(0)),e}toRequest(){return new Request(this._requestData.url,this._requestData)}clone(){return new e(this.toObject())}};let V="serwist-background-sync",Q=new Set,z=e=>{let t={request:new G(e.requestData).toRequest(),timestamp:e.timestamp};return e.metadata&&(t.metadata=e.metadata),t};var J=class{_name;_onSync;_maxRetentionTime;_queueStore;_forceSyncFallback;_syncInProgress=!1;_requestsAddedDuringSync=!1;constructor(e,{forceSyncFallback:t,onSync:a,maxRetentionTime:r}={}){if(Q.has(e))throw new o("duplicate-queue-name",{name:e});Q.add(e),this._name=e,this._onSync=a||this.replayRequests,this._maxRetentionTime=r||10080,this._forceSyncFallback=!!t,this._queueStore=new H(this._name),this._addSyncListener()}get name(){return this._name}async pushRequest(e){await this._addRequest(e,"push")}async unshiftRequest(e){await this._addRequest(e,"unshift")}async popRequest(){return this._removeRequest("pop")}async shiftRequest(){return this._removeRequest("shift")}async getAll(){let e=await this._queueStore.getAll(),t=Date.now(),a=[];for(let r of e){let e=60*this._maxRetentionTime*1e3;t-r.timestamp>e?await this._queueStore.deleteEntry(r.id):a.push(z(r))}return a}async size(){return await this._queueStore.size()}async _addRequest({request:e,metadata:t,timestamp:a=Date.now()},r){let s={requestData:(await G.fromRequest(e.clone())).toObject(),timestamp:a};switch(t&&(s.metadata=t),r){case"push":await this._queueStore.pushEntry(s);break;case"unshift":await this._queueStore.unshiftEntry(s)}this._syncInProgress?this._requestsAddedDuringSync=!0:await this.registerSync()}async _removeRequest(e){let t,a=Date.now();switch(e){case"pop":t=await this._queueStore.popEntry();break;case"shift":t=await this._queueStore.shiftEntry()}if(t){let r=60*this._maxRetentionTime*1e3;return a-t.timestamp>r?this._removeRequest(e):z(t)}}async replayRequests(){let e;for(;e=await this.shiftRequest();)try{await fetch(e.request.clone())}catch{throw await this.unshiftRequest(e),new o("queue-replay-failed",{name:this._name})}}async registerSync(){if("sync"in self.registration&&!this._forceSyncFallback)try{await self.registration.sync.register(`${V}:${this._name}`)}catch(e){}}_addSyncListener(){"sync"in self.registration&&!this._forceSyncFallback?self.addEventListener("sync",e=>{if(e.tag===`${V}:${this._name}`){let t=async()=>{let t;this._syncInProgress=!0;try{await this._onSync({queue:this})}catch(e){if(e instanceof Error)throw e}finally{this._requestsAddedDuringSync&&!(t&&!e.lastChance)&&await this.registerSync(),this._syncInProgress=!1,this._requestsAddedDuringSync=!1}};e.waitUntil(t())}}):this._onSync({queue:this})}static get _queueNames(){return Q}},X=class{_queue;constructor(e,t){this._queue=new J(e,t)}async fetchDidFail({request:e}){await this._queue.pushRequest({request:e})}};let Y={cacheWillUpdate:async({response:e})=>200===e.status||0===e.status?e:null};function Z(e){return"string"==typeof e?new Request(e):e}var ee=class{event;request;url;params;_cacheKeys={};_strategy;_handlerDeferred;_extendLifetimePromises;_plugins;_pluginStateMap;constructor(e,t){for(let a of(this.event=t.event,this.request=t.request,t.url&&(this.url=t.url,this.params=t.params),this._strategy=e,this._handlerDeferred=new w,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map,this._plugins))this._pluginStateMap.set(a,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){let{event:t}=this,a=Z(e),r=await this.getPreloadResponse();if(r)return r;let s=this.hasCallback("fetchDidFail")?a.clone():null;try{for(let e of this.iterateCallbacks("requestWillFetch"))a=await e({request:a.clone(),event:t})}catch(e){if(e instanceof Error)throw new o("plugin-error-request-will-fetch",{thrownErrorMessage:e.message})}let i=a.clone();try{let e;for(let r of(e=await fetch(a,"navigate"===a.mode?void 0:this._strategy.fetchOptions),this.iterateCallbacks("fetchDidSucceed")))e=await r({event:t,request:i,response:e});return e}catch(e){throw s&&await this.runCallbacks("fetchDidFail",{error:e,event:t,originalRequest:s.clone(),request:i.clone()}),e}}async fetchAndCachePut(e){let t=await this.fetch(e),a=t.clone();return this.waitUntil(this.cachePut(e,a)),t}async cacheMatch(e){let t,a=Z(e),{cacheName:r,matchOptions:s}=this._strategy,i=await this.getCacheKey(a,"read"),n={...s,cacheName:r};for(let e of(t=await caches.match(i,n),this.iterateCallbacks("cachedResponseWillBeUsed")))t=await e({cacheName:r,matchOptions:s,cachedResponse:t,request:i,event:this.event})||void 0;return t}async cachePut(e,t){let a=Z(e);await h(0);let r=await this.getCacheKey(a,"write");if(!t)throw new o("cache-put-with-no-response",{url:l(r.url)});let s=await this._ensureResponseSafeToCache(t);if(!s)return!1;let{cacheName:i,matchOptions:n}=this._strategy,c=await self.caches.open(i),u=this.hasCallback("cacheDidUpdate"),d=u?await f(c,r.clone(),["__WB_REVISION__"],n):null;try{await c.put(r,u?s.clone():s)}catch(e){if(e instanceof Error)throw"QuotaExceededError"===e.name&&await y(),e}for(let e of this.iterateCallbacks("cacheDidUpdate"))await e({cacheName:i,oldResponse:d,newResponse:s.clone(),request:r,event:this.event});return!0}async getCacheKey(e,t){let a=`${e.url} | ${t}`;if(!this._cacheKeys[a]){let r=e;for(let e of this.iterateCallbacks("cacheKeyWillBeUsed"))r=Z(await e({mode:t,request:r,event:this.event,params:this.params}));this._cacheKeys[a]=r}return this._cacheKeys[a]}hasCallback(e){for(let t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(let a of this.iterateCallbacks(e))await a(t)}*iterateCallbacks(e){for(let t of this._strategy.plugins)if("function"==typeof t[e]){let a=this._pluginStateMap.get(t),r=r=>{let s={...r,state:a};return t[e](s)};yield r}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){let e;for(;e=this._extendLifetimePromises.shift();)await e}destroy(){this._handlerDeferred.resolve(null)}async getPreloadResponse(){if(this.event instanceof FetchEvent&&"navigate"===this.event.request.mode&&"preloadResponse"in this.event)try{let e=await this.event.preloadResponse;if(e)return e}catch(e){return}}async _ensureResponseSafeToCache(e){let t=e,a=!1;for(let e of this.iterateCallbacks("cacheWillUpdate"))if(t=await e({request:this.request,response:t,event:this.event})||void 0,a=!0,!t)break;return!a&&t&&200!==t.status&&(t=void 0),t}},et=class{cacheName;plugins;fetchOptions;matchOptions;constructor(e={}){this.cacheName=n.getRuntimeName(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){let[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});let t=e.event,a="string"==typeof e.request?new Request(e.request):e.request,r=new ee(this,e.url?{event:t,request:a,url:e.url,params:e.params}:{event:t,request:a}),s=this._getResponse(r,a,t);return[s,this._awaitComplete(s,r,a,t)]}async _getResponse(e,t,a){let r;await e.runCallbacks("handlerWillStart",{event:a,request:t});try{if(r=await this._handle(t,e),void 0===r||"error"===r.type)throw new o("no-response",{url:t.url})}catch(s){if(s instanceof Error){for(let i of e.iterateCallbacks("handlerDidError"))if(void 0!==(r=await i({error:s,event:a,request:t})))break}if(!r)throw s}for(let s of e.iterateCallbacks("handlerWillRespond"))r=await s({event:a,request:t,response:r});return r}async _awaitComplete(e,t,a,r){let s,i;try{s=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:r,request:a,response:s}),await t.doneWaiting()}catch(e){e instanceof Error&&(i=e)}if(await t.runCallbacks("handlerDidComplete",{event:r,request:a,response:s,error:i}),t.destroy(),i)throw i}},ea=class extends et{_networkTimeoutSeconds;constructor(e={}){super(e),this.plugins.some(e=>"cacheWillUpdate"in e)||this.plugins.unshift(Y),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,r=[],s=[];if(this._networkTimeoutSeconds){let{id:i,promise:n}=this._getTimeoutPromise({request:e,logs:r,handler:t});a=i,s.push(n)}let i=this._getNetworkPromise({timeoutId:a,request:e,logs:r,handler:t});s.push(i);let n=await t.waitUntil((async()=>await t.waitUntil(Promise.race(s))||await i)());if(!n)throw new o("no-response",{url:e.url});return n}_getTimeoutPromise({request:e,logs:t,handler:a}){let r;return{promise:new Promise(t=>{r=setTimeout(async()=>{t(await a.cacheMatch(e))},1e3*this._networkTimeoutSeconds)}),id:r}}async _getNetworkPromise({timeoutId:e,request:t,logs:a,handler:r}){let s,i;try{i=await r.fetchAndCachePut(t)}catch(e){e instanceof Error&&(s=e)}return e&&clearTimeout(e),(s||!i)&&(i=await r.cacheMatch(t)),i}},er=class extends et{_networkTimeoutSeconds;constructor(e={}){super(e),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,r;try{let a=[t.fetch(e)];if(this._networkTimeoutSeconds){let e=h(1e3*this._networkTimeoutSeconds);a.push(e)}if(!(r=await Promise.race(a)))throw Error(`Timed out the network response after ${this._networkTimeoutSeconds} seconds.`)}catch(e){e instanceof Error&&(a=e)}if(!r)throw new o("no-response",{url:e.url,error:a});return r}};let es=e=>e&&"object"==typeof e?e:{handle:e};var ei=class{handler;match;method;catchHandler;constructor(e,t,a="GET"){this.handler=es(t),this.match=e,this.method=a}setCatchHandler(e){this.catchHandler=es(e)}},en=class e extends et{_fallbackToNetwork;static defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:e})=>!e||e.status>=400?null:e};static copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:e})=>e.redirected?await A(e):e};constructor(t={}){t.cacheName=n.getPrecacheName(t.cacheName),super(t),this._fallbackToNetwork=!1!==t.fallbackToNetwork,this.plugins.push(e.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){let a=await t.getPreloadResponse();if(a)return a;let r=await t.cacheMatch(e);return r||(t.event&&"install"===t.event.type?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,t){let a,r=t.params||{};if(this._fallbackToNetwork){let s=r.integrity,i=e.integrity,n=!i||i===s;a=await t.fetch(new Request(e,{integrity:"no-cors"!==e.mode?i||s:void 0})),s&&n&&"no-cors"!==e.mode&&(this._useDefaultCacheabilityPluginIfNeeded(),await t.cachePut(e,a.clone()))}else throw new o("missing-precache-entry",{cacheName:this.cacheName,url:e.url});return a}async _handleInstall(e,t){this._useDefaultCacheabilityPluginIfNeeded();let a=await t.fetch(e);if(!await t.cachePut(e,a.clone()))throw new o("bad-precaching-response",{url:e.url,status:a.status});return a}_useDefaultCacheabilityPluginIfNeeded(){let t=null,a=0;for(let[r,s]of this.plugins.entries())s!==e.copyRedirectedCacheableResponsesPlugin&&(s===e.defaultPrecacheCacheabilityPlugin&&(t=r),s.cacheWillUpdate&&a++);0===a?this.plugins.push(e.defaultPrecacheCacheabilityPlugin):a>1&&null!==t&&this.plugins.splice(t,1)}},ec=class extends ei{_allowlist;_denylist;constructor(e,{allowlist:t=[/./],denylist:a=[]}={}){super(e=>this._match(e),e),this._allowlist=t,this._denylist=a}_match({url:e,request:t}){if(t&&"navigate"!==t.mode)return!1;let a=e.pathname+e.search;for(let e of this._denylist)if(e.test(a))return!1;return!!this._allowlist.some(e=>e.test(a))}};let eo=()=>!!self.registration?.navigationPreload,el=e=>{eo()&&self.addEventListener("activate",t=>{t.waitUntil(self.registration.navigationPreload.enable().then(()=>{e&&self.registration.navigationPreload.setHeaderValue(e)}))})},eh=(e,t=[])=>{for(let a of[...e.searchParams.keys()])t.some(e=>e.test(a))&&e.searchParams.delete(a);return e};var eu=class extends ei{constructor(e,t,a){super(({url:t})=>{let a=e.exec(t.href);if(a)return t.origin!==location.origin&&0!==a.index?void 0:a.slice(1)},t,a)}};let ed=e=>{n.updateDetails(e)},ef=e=>{if(!e)throw new o("add-to-cache-list-unexpected-type",{entry:e});if("string"==typeof e){let t=new URL(e,location.href);return{cacheKey:t.href,url:t.href}}let{revision:t,url:a}=e;if(!a)throw new o("add-to-cache-list-unexpected-type",{entry:e});if(!t){let e=new URL(a,location.href);return{cacheKey:e.href,url:e.href}}let r=new URL(a,location.href),s=new URL(a,location.href);return r.searchParams.set("__WB_REVISION__",t),{cacheKey:r.href,url:s.href}};var ew=class{updatedURLs=[];notUpdatedURLs=[];handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)};cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:a})=>{if("install"===e.type&&t?.originalRequest&&t.originalRequest instanceof Request){let e=t.originalRequest.url;a?this.notUpdatedURLs.push(e):this.updatedURLs.push(e)}return a}};let ey=(e,t,a)=>{if("string"==typeof e){let r=new URL(e,location.href);return new ei(({url:e})=>e.href===r.href,t,a)}if(e instanceof RegExp)return new eu(e,t,a);if("function"==typeof e)return new ei(e,t,a);if(e instanceof ei)return e;throw new o("unsupported-route-type",{moduleName:"serwist",funcName:"parseRoute",paramName:"capture"})},ep=async(e,t,a)=>{let r=t.map((e,t)=>({index:t,item:e})),s=async e=>{let t=[];for(;;){let s=r.pop();if(!s)return e(t);let i=await a(s.item);t.push({result:i,index:s.index})}},i=Array.from({length:e},()=>new Promise(s));return(await Promise.all(i)).flat().sort((e,t)=>e.indexe.result)},eg=(e,t,a)=>!a.some(a=>e.headers.has(a)&&t.headers.has(a))||a.every(a=>{let r=e.headers.has(a)===t.headers.has(a),s=e.headers.get(a)===t.headers.get(a);return r&&s}),em="undefined"!=typeof navigator&&/^((?!chrome|android).)*safari/i.test(navigator.userAgent),e_=e=>({cacheName:e.cacheName,updatedURL:e.request.url}),ev="cache-entries",eb=e=>{let t=new URL(e,location.href);return t.hash="",t.href};var eR=class{_cacheName;_db=null;constructor(e){this._cacheName=e}_getId(e){return`${this._cacheName}|${eb(e)}`}_upgradeDb(e){let t=e.createObjectStore(ev,{keyPath:"id"});t.createIndex("cacheName","cacheName",{unique:!1}),t.createIndex("timestamp","timestamp",{unique:!1})}_upgradeDbAndDeleteOldDbs(e){this._upgradeDb(e),this._cacheName&&deleteDB(this._cacheName)}async setTimestamp(e,t){e=eb(e);let a={id:this._getId(e),cacheName:this._cacheName,url:e,timestamp:t},r=(await this.getDb()).transaction(ev,"readwrite",{durability:"relaxed"});await r.store.put(a),await r.done}async getTimestamp(e){return(await (await this.getDb()).get(ev,this._getId(e)))?.timestamp}async expireEntries(e,t){let a=await (await this.getDb()).transaction(ev,"readwrite").store.index("timestamp").openCursor(null,"prev"),r=[],s=0;for(;a;){let i=a.value;i.cacheName===this._cacheName&&(e&&i.timestamp=t?(a.delete(),r.push(i.url)):s++),a=await a.continue()}return r}async getDb(){return this._db||(this._db=await openDB("serwist-expiration",1,{upgrade:this._upgradeDbAndDeleteOldDbs.bind(this)})),this._db}};let eq=/^\/(\w+\/)?collect/,eE=e=>async({queue:t})=>{let a;for(;a=await t.shiftRequest();){let{request:r,timestamp:s}=a,i=new URL(r.url);try{let t="POST"===r.method?new URLSearchParams(await r.clone().text()):i.searchParams,a=s-(Number(t.get("qt"))||0),n=Date.now()-a;if(t.set("qt",String(n)),e.parameterOverrides)for(let a of Object.keys(e.parameterOverrides)){let r=e.parameterOverrides[a];t.set(a,r)}"function"==typeof e.hitFilter&&e.hitFilter.call(null,t),await fetch(new Request(i.origin+i.pathname,{body:t.toString(),method:"POST",mode:"cors",credentials:"omit",headers:{"Content-Type":"text/plain"}}))}catch(e){throw await t.unshiftRequest(a),e}}},eD=e=>{let t=({url:e})=>"www.google-analytics.com"===e.hostname&&eq.test(e.pathname),a=new er({plugins:[e]});return[new ei(t,a,"GET"),new ei(t,a,"POST")]},eS=e=>new ei(({url:e})=>"www.google-analytics.com"===e.hostname&&"/analytics.js"===e.pathname,new ea({cacheName:e}),"GET"),eP=e=>new ei(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtag/js"===e.pathname,new ea({cacheName:e}),"GET"),ek=e=>new ei(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtm.js"===e.pathname,new ea({cacheName:e}),"GET"),eC=({serwist:e,cacheName:t,...a})=>{let r=n.getGoogleAnalyticsName(t),s=new X("serwist-google-analytics",{maxRetentionTime:2880,onSync:eE(a)});for(let t of[ek(r),eS(r),eP(r),...eD(s)])e.registerRoute(t)};var eT=class{_fallbackUrls;_serwist;constructor({fallbackUrls:e,serwist:t}){this._fallbackUrls=e,this._serwist=t}async handlerDidError(e){for(let t of this._fallbackUrls)if("string"==typeof t){let e=await this._serwist.matchPrecache(t);if(void 0!==e)return e}else if(t.matcher(e)){let e=await this._serwist.matchPrecache(t.url);if(void 0!==e)return e}}};let eN=(e,t,a)=>{let r,s,i=e.size;if(a&&a>i||t&&t<0)throw new SerwistError("range-not-satisfiable",{size:i,end:a,start:t});return void 0!==t&&void 0!==a?(r=t,s=a+1):void 0!==t&&void 0===a?(r=t,s=i):void 0!==a&&void 0===t&&(r=i-a,s=i),{start:r,end:s}},eI=e=>{let t=e.trim().toLowerCase();if(!t.startsWith("bytes="))throw new SerwistError("unit-must-be-bytes",{normalizedRangeHeader:t});if(t.includes(","))throw new SerwistError("single-range-only",{normalizedRangeHeader:t});let a=/(\d*)-(\d*)/.exec(t);if(!a||!(a[1]||a[2]))throw new SerwistError("invalid-range-values",{normalizedRangeHeader:t});return{start:""===a[1]?void 0:Number(a[1]),end:""===a[2]?void 0:Number(a[2])}};var eL=class extends et{async _handle(e,t){let a,r=await t.cacheMatch(e);if(r);else try{r=await t.fetchAndCachePut(e)}catch(e){e instanceof Error&&(a=e)}if(!r)throw new o("no-response",{url:e.url,error:a});return r}},ex=class extends ei{constructor(e,t){super(({request:a})=>{let r=e.getUrlsToPrecacheKeys();for(let s of function*(e,{directoryIndex:t="index.html",ignoreURLParametersMatching:a=[/^utm_/,/^fbclid$/],cleanURLs:r=!0,urlManipulation:s}={}){let i=new URL(e,location.href);i.hash="",yield i.href;let n=eh(i,a);if(yield n.href,t&&n.pathname.endsWith("/")){let e=new URL(n.href);e.pathname+=t,yield e.href}if(r){let e=new URL(n.href);e.pathname+=".html",yield e.href}if(s)for(let e of s({url:i}))yield e.href}(a.url,t)){let t=r.get(s);if(t)return{cacheKey:t,integrity:e.getIntegrityForPrecacheKey(t)}}},e.precacheStrategy)}},eU=class{_precacheController;constructor({precacheController:e}){this._precacheController=e}cacheKeyWillBeUsed=async({request:e,params:t})=>{let a=t?.cacheKey||this._precacheController.getPrecacheKeyForUrl(e.url);return a?new Request(a,{headers:e.headers}):e}};let eB=(e,t={})=>{let{cacheName:a,plugins:r=[],fetchOptions:s,matchOptions:i,fallbackToNetwork:c,directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u,cleanupOutdatedCaches:d,concurrency:f=10,navigateFallback:w,navigateFallbackAllowlist:y,navigateFallbackDenylist:p}=t??{};return{precacheStrategyOptions:{cacheName:n.getPrecacheName(a),plugins:[...r,new eU({precacheController:e})],fetchOptions:s,matchOptions:i,fallbackToNetwork:c},precacheRouteOptions:{directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u},precacheMiscOptions:{cleanupOutdatedCaches:d,concurrency:f,navigateFallback:w,navigateFallbackAllowlist:y,navigateFallbackDenylist:p}}};new class{_urlsToCacheKeys=new Map;_urlsToCacheModes=new Map;_cacheKeysToIntegrities=new Map;_concurrentPrecaching;_precacheStrategy;_routes;_defaultHandlerMap;_catchHandler;_requestRules;constructor({precacheEntries:e,precacheOptions:t,skipWaiting:a=!1,importScripts:r,navigationPreload:s=!1,cacheId:i,clientsClaim:n=!1,runtimeCaching:c,offlineAnalyticsConfig:o,disableDevLogs:l=!1,fallbacks:h,requestRules:u}={}){let{precacheStrategyOptions:d,precacheRouteOptions:f,precacheMiscOptions:w}=eB(this,t);if(this._concurrentPrecaching=w.concurrency,this._precacheStrategy=new en(d),this._routes=new Map,this._defaultHandlerMap=new Map,this._requestRules=u,this.handleInstall=this.handleInstall.bind(this),this.handleActivate=this.handleActivate.bind(this),this.handleFetch=this.handleFetch.bind(this),this.handleCache=this.handleCache.bind(this),r&&r.length>0&&self.importScripts(...r),s&&el(),void 0!==i&&ed({prefix:i}),a?self.skipWaiting():self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),n&&_(),e&&e.length>0&&this.addToPrecacheList(e),w.cleanupOutdatedCaches&&m(d.cacheName),this.registerRoute(new ex(this,f)),w.navigateFallback&&this.registerRoute(new ec(this.createHandlerBoundToUrl(w.navigateFallback),{allowlist:w.navigateFallbackAllowlist,denylist:w.navigateFallbackDenylist})),void 0!==o&&("boolean"==typeof o?o&&eC({serwist:this}):eC({...o,serwist:this})),void 0!==c){if(void 0!==h){let e=new eT({fallbackUrls:h.entries,serwist:this});c.forEach(t=>{t.handler instanceof et&&!t.handler.plugins.some(e=>"handlerDidError"in e)&&t.handler.plugins.push(e)})}for(let e of c)this.registerCapture(e.matcher,e.handler,e.method)}l&&M()}get precacheStrategy(){return this._precacheStrategy}get routes(){return this._routes}addEventListeners(){self.addEventListener("install",this.handleInstall),self.addEventListener("activate",this.handleActivate),self.addEventListener("fetch",this.handleFetch),self.addEventListener("message",this.handleCache)}addToPrecacheList(e){let t=[];for(let a of e){"string"==typeof a?t.push(a):a&&!a.integrity&&void 0===a.revision&&t.push(a.url);let{cacheKey:e,url:r}=ef(a),s="string"!=typeof a&&a.revision?"reload":"default";if(this._urlsToCacheKeys.has(r)&&this._urlsToCacheKeys.get(r)!==e)throw new o("add-to-cache-list-conflicting-entries",{firstEntry:this._urlsToCacheKeys.get(r),secondEntry:e});if("string"!=typeof a&&a.integrity){if(this._cacheKeysToIntegrities.has(e)&&this._cacheKeysToIntegrities.get(e)!==a.integrity)throw new o("add-to-cache-list-conflicting-integrities",{url:r});this._cacheKeysToIntegrities.set(e,a.integrity)}this._urlsToCacheKeys.set(r,e),this._urlsToCacheModes.set(r,s)}t.length>0&&console.warn(`Serwist is precaching URLs without revision info: ${t.join(", ")} -This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),v(e,async()=>{let t=new ew;this.precacheStrategy.plugins.push(t),await ep(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let r=this._cacheKeysToIntegrities.get(a),s=this._urlsToCacheModes.get(t),i=new Request(t,{integrity:r,cache:s,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:i,url:new URL(i.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:r}=t;return{updatedURLs:a,notUpdatedURLs:r}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return v(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),r=[];for(let s of t)a.has(s.url)||(await e.delete(s),r.push(s.url));return{deletedCacheRequests:r}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,es(e))}setCatchHandler(e){this._catchHandler=es(e)}registerCapture(e,t,a){let r=ey(e,t,a);return this.registerRoute(r),r}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new o("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new o("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new o("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,r=new URL(e.url,location.href);if(!r.protocol.startsWith("http"))return;let s=r.origin===location.origin,{params:i,route:n}=this.findMatchingRoute({event:t,request:e,sameOrigin:s,url:r}),c=n?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:r,request:e,event:t,params:i})}catch(e){a=Promise.reject(e)}let l=n?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:r,request:e,event:t,params:i})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:r,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:r}){for(let s of this._routes.get(a.method)||[]){let i,n=s.match({url:e,sameOrigin:t,request:a,event:r});if(n)return Array.isArray(i=n)&&0===i.length||n.constructor===Object&&0===Object.keys(n).length?i=void 0:"boolean"==typeof n&&(i=void 0),{route:s,params:i}}return{}}}({precacheEntries:[{'revision':'af2b6e592c9167a3e6669085aae379db','url':'/_next/static/_UZhIFq9bUorWxxdu2nOx/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/_UZhIFq9bUorWxxdu2nOx/_ssgManifest.js'},{'revision':null,'url':'/_next/static/chunks/121.991963838bfc92a5.js'},{'revision':null,'url':'/_next/static/chunks/164f4fb6.f3faa5d5aba5cf04.js'},{'revision':null,'url':'/_next/static/chunks/191-c601373cc6131fd7.js'},{'revision':null,'url':'/_next/static/chunks/211-8278580b778b3a39.js'},{'revision':null,'url':'/_next/static/chunks/221.8cea1bbd8d7a75d0.js'},{'revision':null,'url':'/_next/static/chunks/245.71c45fa6b1e823c4.js'},{'revision':null,'url':'/_next/static/chunks/255-d7d1e27411e8ea37.js'},{'revision':null,'url':'/_next/static/chunks/2f0b94e8.d674f0a035f460ca.js'},{'revision':null,'url':'/_next/static/chunks/309.45a3262ac4bcca2e.js'},{'revision':null,'url':'/_next/static/chunks/34.eca2ad36e2065dcc.js'},{'revision':null,'url':'/_next/static/chunks/399.31ee657513a3dde2.js'},{'revision':null,'url':'/_next/static/chunks/44530001-09199bfb889c2833.js'},{'revision':null,'url':'/_next/static/chunks/4bd1b696-b5c413a68ae9f40b.js'},{'revision':null,'url':'/_next/static/chunks/642.b0bb6c17effd6d1a.js'},{'revision':null,'url':'/_next/static/chunks/745-5cbe0b834fa13c50.js'},{'revision':null,'url':'/_next/static/chunks/746-c335b885cf0977cc.js'},{'revision':null,'url':'/_next/static/chunks/751.b87576b52bb9f4d1.js'},{'revision':null,'url':'/_next/static/chunks/780.6de6cb96b57fe026.js'},{'revision':null,'url':'/_next/static/chunks/797-0c10a3f91f00b70b.js'},{'revision':null,'url':'/_next/static/chunks/822.e5a25ff0c78b4017.js'},{'revision':null,'url':'/_next/static/chunks/883ee446.8f8323ee7b61fbb5.js'},{'revision':null,'url':'/_next/static/chunks/ad2866b8.6b876e1655c11f64.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-53439e48b6308ac6.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chat/route-b4f3924ac733b034.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chats/%5Bid%5D/participants/route-b8c2aa3fc4ba663d.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chats/all/route-8057f7cd2376b981.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chats/route-22ca0d4364958556.js'},{'revision':null,'url':'/_next/static/chunks/app/api/clerk/webhook/route-d3bd95716cd4028b.js'},{'revision':null,'url':'/_next/static/chunks/app/api/embeddings/route-8fa56ba6811ae879.js'},{'revision':null,'url':'/_next/static/chunks/app/api/health/route-0ef5d711349c1b67.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-29e2f87ef55d0c37.js'},{'revision':null,'url':'/_next/static/chunks/app/manifest.webmanifest/route-3596db319c2d2b56.js'},{'revision':null,'url':'/_next/static/chunks/app/offline/page-f761a3156b61eb05.js'},{'revision':null,'url':'/_next/static/chunks/app/page-17ae59c2740e343f.js'},{'revision':null,'url':'/_next/static/chunks/app/search/%5Bid%5D/page-7e810aaa5bf4e9c0.js'},{'revision':null,'url':'/_next/static/chunks/bc98253f.a20b3a3cf1b114d6.js'},{'revision':null,'url':'/_next/static/chunks/c36f3faa.abb912aa7b92c8b5.js'},{'revision':null,'url':'/_next/static/chunks/d3ac728e-2af04ca4a833bf30.js'},{'revision':null,'url':'/_next/static/chunks/dc112a36-9a670afd7d3140fd.js'},{'revision':null,'url':'/_next/static/chunks/framework-6e3d659ca9a17c7d.js'},{'revision':null,'url':'/_next/static/chunks/main-app-103a28f346eee8b8.js'},{'revision':null,'url':'/_next/static/chunks/main-bccdb076287131f3.js'},{'revision':null,'url':'/_next/static/chunks/pages/_app-8e94039938385921.js'},{'revision':null,'url':'/_next/static/chunks/pages/_error-7b2d139042a6a5ab.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-5e08260e240519cc.js'},{'revision':null,'url':'/_next/static/css/1f6e4f9283d1476a.css'},{'revision':null,'url':'/_next/static/css/2f46e0db0d4a3aed.css'},{'revision':null,'url':'/_next/static/css/4183e00ad0e88413.css'},{'revision':null,'url':'/_next/static/css/a67336182a0a18f5.css'},{'revision':null,'url':'/_next/static/css/c35610c794fcbaaa.css'},{'revision':'be7c930fceb794521be0a68e113a71d8','url':'/_next/static/media/034d78ad42e9620c-s.woff2'},{'revision':'b550bca8934bd86812d1f5e28c9cc1de','url':'/_next/static/media/0484562807a97172-s.p.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'69d9d2cdadeab7225297d50fc8e48e8b','url':'/_next/static/media/29a4aea02fdee119-s.woff2'},{'revision':'9e3ecbe4bb4c6f0b71adc1cd481c2bdc','url':'/_next/static/media/29e7bbdce9332268-s.woff2'},{'revision':'792477d09826b11d1e5a611162c9797a','url':'/_next/static/media/8888a3826f4a3af4-s.p.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_AMS-Regular.1608a09b.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_AMS-Regular.4aafdb68.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_AMS-Regular.a79f1c31.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Bold.b6770918.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Bold.cce5b8ec.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Bold.ec17d132.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Regular.07ef19e7.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Regular.55fac258.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Regular.dad44a7f.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Bold.9f256b85.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Bold.b18f59e1.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Bold.d42a5579.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Regular.7c187121.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Regular.d3c882a6.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Regular.ed38e79f.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Bold.b74a1a8b.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Bold.c3fb5ac2.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Bold.d181c465.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-BoldItalic.6f2bb1df.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-BoldItalic.70d8b0a5.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-BoldItalic.e3f82f9d.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Italic.47373d1e.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Italic.8916142b.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Italic.9024d815.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Regular.0462f03b.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Regular.7f51fe03.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Regular.b7f8fe9b.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-BoldItalic.572d331f.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-BoldItalic.a879cf83.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-BoldItalic.f1035d8d.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-Italic.5295ba48.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-Italic.939bc644.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-Italic.f28c23ac.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Bold.8c5b5494.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Bold.94e1e8dc.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Bold.bf59d231.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Italic.3b1e59b3.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Italic.7c9bc82b.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Italic.b4c20c84.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Regular.74048478.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Regular.ba21ed5f.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Regular.d4d7ba48.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Script-Regular.03e9641d.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Script-Regular.07505710.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Script-Regular.fe9cbbe1.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Size1-Regular.e1e279cb.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Size1-Regular.eae34984.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Size1-Regular.fabc004a.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Size2-Regular.57727022.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Size2-Regular.5916a24f.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Size2-Regular.d6b476ec.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Size3-Regular.9acaf01c.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Size3-Regular.a144ef58.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Size3-Regular.b4230e7e.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Size4-Regular.10d95fd3.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Size4-Regular.7a996c9d.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Size4-Regular.fbccdabe.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Typewriter-Regular.6258592b.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Typewriter-Regular.a8709e36.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Typewriter-Regular.d97aaf4a.ttf'},{'revision':'d3aa06d13d3cf9c0558927051f3cb948','url':'/_next/static/media/a1386beebedccca4-s.woff2'},{'revision':'0bd523f6049956faaf43c254a719d06a','url':'/_next/static/media/b957ea75a84b6ea7-s.p.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'5a1b7c983a9dc0a87a2ff138e07ae822','url':'/_next/static/media/c3bc380753a8436c-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'9516f567cd80b0f418bba2f1299ed6d1','url':'/_next/static/media/db911767852bc875-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'43751174b6b810eb169101a20d8c26f8','url':'/_next/static/media/eafabf029ad39a43-s.p.woff2'},{'revision':'63af7d5e18e585fad8d0220e5d551da1','url':'/_next/static/media/f10b8e9d91f3edcb-s.woff2'},{'revision':'f2a04185547c36abfa589651236a9849','url':'/_next/static/media/fe0777f1195381cb-s.woff2'},{'revision':'460f55cf1456f5a201f259a4eb607989','url':'/icons/apple-touch-icon.png'},{'revision':'69784622e4b55d2666c5180fb954d781','url':'/icons/icon-192x192.png'},{'revision':'ab07c93bfbd862c758c4d6d31085a6db','url':'/icons/icon-512x512-maskable.png'},{'revision':'f1b0e1527b676413a8b8e0154f671984','url':'/icons/icon-512x512.png'},{'revision':'2ed79e1d8607156994a2a6462563d7f0','url':'/images/Q zoom.json'},{'revision':'d088f99e3a3f1b7eaec384f36b0866d2','url':'/images/Q.json'},{'revision':'b14dbca44bb366d8499ae9a9b24a104f','url':'/images/eva-logo.png'},{'revision':'8f6cf4c9ec412fe201f80e2d23445aa9','url':'/images/logo.svg'},{'revision':'ab57101a81d25f409febeed6eb7e32bf','url':'/images/opengraph-image.png'}],skipWaiting:!1,clientsClaim:!1,navigationPreload:!1,runtimeCaching:[{matcher:/\.(?:js|css|woff2?|png|jpg|jpeg|svg|gif|ico)$/,handler:new eL({cacheName:"static-assets"})},{matcher:e=>{let{url:t,request:a}=e,r=t.pathname.startsWith("/api/"),s="/api/chat"===t.pathname&&"POST"===a.method||"/api/chats/all"===t.pathname&&"DELETE"===a.method;return r&&!s},handler:new ea({cacheName:"api-cache",networkTimeoutSeconds:5})},{matcher:e=>{let{request:t}=e;return"navigate"===t.mode},handler:new ea({cacheName:"pages-cache",networkTimeoutSeconds:5,plugins:[{handlerDidError:async()=>caches.match("/offline")}]})}]}).addEventListeners(),self.addEventListener("push",e=>{console.log("[Service Worker] Push Received.",e)}),self.addEventListener("sync",e=>{console.log("[Service Worker] Background Sync.",e)})})(); \ No newline at end of file +This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),v(e,async()=>{let t=new ew;this.precacheStrategy.plugins.push(t),await ep(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let r=this._cacheKeysToIntegrities.get(a),s=this._urlsToCacheModes.get(t),i=new Request(t,{integrity:r,cache:s,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:i,url:new URL(i.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:r}=t;return{updatedURLs:a,notUpdatedURLs:r}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return v(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),r=[];for(let s of t)a.has(s.url)||(await e.delete(s),r.push(s.url));return{deletedCacheRequests:r}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,es(e))}setCatchHandler(e){this._catchHandler=es(e)}registerCapture(e,t,a){let r=ey(e,t,a);return this.registerRoute(r),r}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new o("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new o("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new o("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,r=new URL(e.url,location.href);if(!r.protocol.startsWith("http"))return;let s=r.origin===location.origin,{params:i,route:n}=this.findMatchingRoute({event:t,request:e,sameOrigin:s,url:r}),c=n?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:r,request:e,event:t,params:i})}catch(e){a=Promise.reject(e)}let l=n?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:r,request:e,event:t,params:i})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:r,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:r}){for(let s of this._routes.get(a.method)||[]){let i,n=s.match({url:e,sameOrigin:t,request:a,event:r});if(n)return Array.isArray(i=n)&&0===i.length||n.constructor===Object&&0===Object.keys(n).length?i=void 0:"boolean"==typeof n&&(i=void 0),{route:s,params:i}}return{}}}({precacheEntries:[{'revision':null,'url':'/_next/static/chunks/1463-9fbf44fc07aec709.js'},{'revision':null,'url':'/_next/static/chunks/164f4fb6.ce777fe0c2f313d4.js'},{'revision':null,'url':'/_next/static/chunks/1751-a7421f933b645f53.js'},{'revision':null,'url':'/_next/static/chunks/2121.224732b1794a9682.js'},{'revision':null,'url':'/_next/static/chunks/245.71c45fa6b1e823c4.js'},{'revision':null,'url':'/_next/static/chunks/2642.b93aca14ed10255c.js'},{'revision':null,'url':'/_next/static/chunks/2f0b94e8.9fae400113b2869a.js'},{'revision':null,'url':'/_next/static/chunks/4221.e8dc382e3f6203c5.js'},{'revision':null,'url':'/_next/static/chunks/4309.5568f859d1e3347b.js'},{'revision':null,'url':'/_next/static/chunks/44530001-2dcabf4063e0dc0d.js'},{'revision':null,'url':'/_next/static/chunks/47-f1f887d9590ad0ae.js'},{'revision':null,'url':'/_next/static/chunks/4745-3a6bac70cc179378.js'},{'revision':null,'url':'/_next/static/chunks/4751.e4c42f792ab47d1a.js'},{'revision':null,'url':'/_next/static/chunks/4bd1b696-f242fc90bae6ac3d.js'},{'revision':null,'url':'/_next/static/chunks/5714-fd4a0a03b48efe5e.js'},{'revision':null,'url':'/_next/static/chunks/5860-c8dc3b658eadb632.js'},{'revision':null,'url':'/_next/static/chunks/6399.9dbce694e39ce768.js'},{'revision':null,'url':'/_next/static/chunks/7034.85f74aed4274c767.js'},{'revision':null,'url':'/_next/static/chunks/7797-93c0d2f54719fac4.js'},{'revision':null,'url':'/_next/static/chunks/822.e5a25ff0c78b4017.js'},{'revision':null,'url':'/_next/static/chunks/8335.71e63cf2b4e96600.js'},{'revision':null,'url':'/_next/static/chunks/883ee446.e78f619846db8225.js'},{'revision':null,'url':'/_next/static/chunks/9191-6a943a71fbab5fbf.js'},{'revision':null,'url':'/_next/static/chunks/ad2866b8.1fc071285e350c45.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-72fcca513fff4e0b.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chat/route-ad2e70fc255c1261.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chats/%5Bid%5D/participants/route-6bedacf495dea8ce.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chats/all/route-4968a1d56998528c.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chats/route-af8a65c4e4ae4f5b.js'},{'revision':null,'url':'/_next/static/chunks/app/api/clerk/webhook/route-65f379ea9ac4c6d4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/embeddings/route-2ea076c3f5b3454c.js'},{'revision':null,'url':'/_next/static/chunks/app/api/health/route-dfdeb492290f9b9a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/skyfi/callback/route-248f26a809ad3df4.js'},{'revision':null,'url':'/_next/static/chunks/app/auth/discord/page-7d8d675b37f835f0.js'},{'revision':null,'url':'/_next/static/chunks/app/discord-auth/page-627cc9c32197466c.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-7b2312a6731bc6dc.js'},{'revision':null,'url':'/_next/static/chunks/app/manifest.webmanifest/route-5d4b636a9b2516ab.js'},{'revision':null,'url':'/_next/static/chunks/app/offline/page-cc51ffd178cab1d1.js'},{'revision':null,'url':'/_next/static/chunks/app/page-055728cc08de1e64.js'},{'revision':null,'url':'/_next/static/chunks/app/search/%5Bid%5D/page-0f78b0deabbf0ed3.js'},{'revision':null,'url':'/_next/static/chunks/app/sign-in/discord/page-1c84793792fe7d77.js'},{'revision':null,'url':'/_next/static/chunks/app/sign-in/page-bff6eb5de63840bb.js'},{'revision':null,'url':'/_next/static/chunks/app/sso-callback/page-00f41728b5cbfb00.js'},{'revision':null,'url':'/_next/static/chunks/bc98253f.a20b3a3cf1b114d6.js'},{'revision':null,'url':'/_next/static/chunks/c36f3faa.abb912aa7b92c8b5.js'},{'revision':null,'url':'/_next/static/chunks/d3ac728e-90b4132adc93dda2.js'},{'revision':null,'url':'/_next/static/chunks/dc112a36-18909a95e93ec969.js'},{'revision':null,'url':'/_next/static/chunks/fc2f6fa8-f85eea3e43dc0331.js'},{'revision':null,'url':'/_next/static/chunks/framework-1139d96b397cc8d6.js'},{'revision':null,'url':'/_next/static/chunks/main-794f106834521f59.js'},{'revision':null,'url':'/_next/static/chunks/main-app-a21f74533f7d6029.js'},{'revision':null,'url':'/_next/static/chunks/pages/_app-5d1abe03d322390c.js'},{'revision':null,'url':'/_next/static/chunks/pages/_error-3b2a1d523de49635.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-1548a9b545466b3e.js'},{'revision':null,'url':'/_next/static/css/1f4d2367ed33502e.css'},{'revision':null,'url':'/_next/static/css/2f46e0db0d4a3aed.css'},{'revision':null,'url':'/_next/static/css/6aa45f89e1641e77.css'},{'revision':null,'url':'/_next/static/css/a67336182a0a18f5.css'},{'revision':null,'url':'/_next/static/css/c35610c794fcbaaa.css'},{'revision':'372da9634d54bcad774a15d5874b29d0','url':'/_next/static/fHSycEt6XHkB2lIg0AxFl/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/fHSycEt6XHkB2lIg0AxFl/_ssgManifest.js'},{'revision':'be7c930fceb794521be0a68e113a71d8','url':'/_next/static/media/034d78ad42e9620c-s.woff2'},{'revision':'b550bca8934bd86812d1f5e28c9cc1de','url':'/_next/static/media/0484562807a97172-s.p.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'69d9d2cdadeab7225297d50fc8e48e8b','url':'/_next/static/media/29a4aea02fdee119-s.woff2'},{'revision':'9e3ecbe4bb4c6f0b71adc1cd481c2bdc','url':'/_next/static/media/29e7bbdce9332268-s.woff2'},{'revision':'792477d09826b11d1e5a611162c9797a','url':'/_next/static/media/8888a3826f4a3af4-s.p.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_AMS-Regular.1608a09b.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_AMS-Regular.4aafdb68.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_AMS-Regular.a79f1c31.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Bold.b6770918.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Bold.cce5b8ec.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Bold.ec17d132.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Regular.07ef19e7.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Regular.55fac258.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Regular.dad44a7f.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Bold.9f256b85.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Bold.b18f59e1.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Bold.d42a5579.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Regular.7c187121.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Regular.d3c882a6.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Regular.ed38e79f.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Bold.b74a1a8b.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Bold.c3fb5ac2.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Bold.d181c465.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-BoldItalic.6f2bb1df.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-BoldItalic.70d8b0a5.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-BoldItalic.e3f82f9d.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Italic.47373d1e.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Italic.8916142b.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Italic.9024d815.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Regular.0462f03b.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Regular.7f51fe03.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Regular.b7f8fe9b.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-BoldItalic.572d331f.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-BoldItalic.a879cf83.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-BoldItalic.f1035d8d.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-Italic.5295ba48.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-Italic.939bc644.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-Italic.f28c23ac.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Bold.8c5b5494.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Bold.94e1e8dc.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Bold.bf59d231.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Italic.3b1e59b3.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Italic.7c9bc82b.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Italic.b4c20c84.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Regular.74048478.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Regular.ba21ed5f.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Regular.d4d7ba48.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Script-Regular.03e9641d.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Script-Regular.07505710.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Script-Regular.fe9cbbe1.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Size1-Regular.e1e279cb.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Size1-Regular.eae34984.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Size1-Regular.fabc004a.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Size2-Regular.57727022.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Size2-Regular.5916a24f.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Size2-Regular.d6b476ec.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Size3-Regular.9acaf01c.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Size3-Regular.a144ef58.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Size3-Regular.b4230e7e.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Size4-Regular.10d95fd3.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Size4-Regular.7a996c9d.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Size4-Regular.fbccdabe.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Typewriter-Regular.6258592b.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Typewriter-Regular.a8709e36.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Typewriter-Regular.d97aaf4a.ttf'},{'revision':'d3aa06d13d3cf9c0558927051f3cb948','url':'/_next/static/media/a1386beebedccca4-s.woff2'},{'revision':'0bd523f6049956faaf43c254a719d06a','url':'/_next/static/media/b957ea75a84b6ea7-s.p.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'5a1b7c983a9dc0a87a2ff138e07ae822','url':'/_next/static/media/c3bc380753a8436c-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'9516f567cd80b0f418bba2f1299ed6d1','url':'/_next/static/media/db911767852bc875-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'43751174b6b810eb169101a20d8c26f8','url':'/_next/static/media/eafabf029ad39a43-s.p.woff2'},{'revision':'63af7d5e18e585fad8d0220e5d551da1','url':'/_next/static/media/f10b8e9d91f3edcb-s.woff2'},{'revision':'f2a04185547c36abfa589651236a9849','url':'/_next/static/media/fe0777f1195381cb-s.woff2'},{'revision':'460f55cf1456f5a201f259a4eb607989','url':'/icons/apple-touch-icon.png'},{'revision':'69784622e4b55d2666c5180fb954d781','url':'/icons/icon-192x192.png'},{'revision':'ab07c93bfbd862c758c4d6d31085a6db','url':'/icons/icon-512x512-maskable.png'},{'revision':'f1b0e1527b676413a8b8e0154f671984','url':'/icons/icon-512x512.png'},{'revision':'2ed79e1d8607156994a2a6462563d7f0','url':'/images/Q zoom.json'},{'revision':'d088f99e3a3f1b7eaec384f36b0866d2','url':'/images/Q.json'},{'revision':'b14dbca44bb366d8499ae9a9b24a104f','url':'/images/eva-logo.png'},{'revision':'8f6cf4c9ec412fe201f80e2d23445aa9','url':'/images/logo.svg'},{'revision':'ab57101a81d25f409febeed6eb7e32bf','url':'/images/opengraph-image.png'}],skipWaiting:!1,clientsClaim:!1,navigationPreload:!1,runtimeCaching:[{matcher:/\.(?:js|css|woff2?|png|jpg|jpeg|svg|gif|ico)$/,handler:new eL({cacheName:"static-assets"})},{matcher:e=>{let{url:t,request:a}=e,r=t.pathname.startsWith("/api/"),s="/api/chat"===t.pathname&&"POST"===a.method||"/api/chats/all"===t.pathname&&"DELETE"===a.method;return r&&!s},handler:new ea({cacheName:"api-cache",networkTimeoutSeconds:5})},{matcher:e=>{let{request:t}=e;return"navigate"===t.mode},handler:new ea({cacheName:"pages-cache",networkTimeoutSeconds:5,plugins:[{handlerDidError:async()=>caches.match("/offline")}]})}]}).addEventListeners(),self.addEventListener("push",e=>{console.log("[Service Worker] Push Received.",e)}),self.addEventListener("sync",e=>{console.log("[Service Worker] Background Sync.",e)})})(); \ No newline at end of file From 2e1aceb6725341cd38d77663cfe2531d1a0de982 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:19:43 +0000 Subject: [PATCH 05/11] Implement AES-256-GCM encryption, secure initialization, and improve SkyFi tool robustness - Migrate encryption/decryption from AES-256-CBC to AES-256-GCM with integrity tag verification and strict ENCRYPTION_KEY environment variable initialization checks. - Add deterministic serialize helper for extraParams and orderId when deriving stableIdempotencyKey in skyfiTool. - Correctly apply idempotencyKey after spreading extraParams to prevent user override. - Destructure orderId and include in mcpArgs for list_orders, validate_order, and place_order. - Support AbortSignal passing in both skyfi MCP client tool calls. - Sanitize tool logging by omitting location and aoi values. - Call closeClient on invalid-AOI early-return path. - Redirect unauthenticated users to /sign-in in startSkyfiConnection. - Add unit tests under tests-unit/encryption.test.ts. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- lib/actions/skyfi.ts | 11 +++---- lib/agents/tools/skyfi.tsx | 33 ++++++++++++++++----- lib/utils/encryption.ts | 21 +++++++++---- public/sw.js | 2 +- tests-unit/encryption.test.ts | 55 +++++++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 20 deletions(-) create mode 100644 tests-unit/encryption.test.ts diff --git a/lib/actions/skyfi.ts b/lib/actions/skyfi.ts index 2cdf5595..476c9f48 100644 --- a/lib/actions/skyfi.ts +++ b/lib/actions/skyfi.ts @@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm'; import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; import { SkyfiOAuthProvider } from '@/lib/skyfi/provider'; import crypto from 'crypto'; +import { redirect } from 'next/navigation'; export async function getRedirectUri(): Promise { const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; @@ -68,12 +69,12 @@ async function ensureClientRegistered(provider: SkyfiOAuthProvider): Promise { - try { - const userId = await getCurrentUserIdOnServer(); - if (!userId) { - return { error: 'Unauthorized: User not authenticated.' }; - } + const userId = await getCurrentUserIdOnServer(); + if (!userId) { + redirect('/sign-in'); + } + try { const redirectUri = await getRedirectUri(); const provider = new SkyfiOAuthProvider(userId, redirectUri); diff --git a/lib/agents/tools/skyfi.tsx b/lib/agents/tools/skyfi.tsx index af274ba4..183010e2 100644 --- a/lib/agents/tools/skyfi.tsx +++ b/lib/agents/tools/skyfi.tsx @@ -29,6 +29,14 @@ function geoJsonToWkt(geometry: any): string | null { return null; } +function deterministicSerialize(val: any): string { + if (val === null || val === undefined) return ''; + if (typeof val !== 'object') return String(val); + const keys = Object.keys(val).sort(); + const parts = keys.map(k => `${k}:${deterministicSerialize(val[k])}`); + return `{${parts.join(',')}}`; +} + /** * Establish connection to the SkyFi MCP server with the user's stored OAuth token and support AbortSignal. */ @@ -118,8 +126,8 @@ export const skyfiTool = ({ Always ask the user for permission before placing a billable order! Ensure you call 'validate_order' first to display the cost.`, parameters: skyfiQuerySchema, execute: async (params: z.infer) => { - const { queryType, location, aoi, params: extraParams, maxResults } = params; - console.log('[SkyfiTool] Execute called with:', params); + const { queryType, location, aoi, orderId, params: extraParams, maxResults } = params; + console.log('[SkyfiTool] Execute called for queryType:', queryType, { maxResults, confirm: params.confirm }); const uiFeedbackStream = createStreamableValue(); uiStream.append(); @@ -190,6 +198,7 @@ export const skyfiTool = ({ clearTimeout(timeoutId); const invalidAoiMessage = "Your drawn area is not a valid closed Polygon. SkyFi requires a closed Polygon to define your Area of Interest. Please use the map drawing tools to draw a closed Polygon."; uiFeedbackStream.update(invalidAoiMessage); + await closeClient(mcpClient); uiFeedbackStream.done(); uiStream.update(); return { type: 'SKYFI_QUERY', success: false, error: 'Invalid AOI geometry', message: invalidAoiMessage }; @@ -201,8 +210,10 @@ export const skyfiTool = ({ } // Stable idempotency key derivation to prevent duplicate order placements + const serializedExtraParams = deterministicSerialize(extraParams); + const hashInput = `${userId}_${resolvedAoiWkt || ''}_${queryType}_${orderId || ''}_${serializedExtraParams}`; stableIdempotencyKey = crypto.createHash('sha256') - .update(`${userId}_${resolvedAoiWkt || ''}_${queryType}`) + .update(hashInput) .digest('hex'); switch (queryType) { @@ -226,6 +237,7 @@ export const skyfiTool = ({ mcpToolName = 'skyfi_validate_archive_order'; mcpArgs = { ...extraParams, + ...(orderId ? { orderId } : {}), aoi: resolvedAoiWkt || extraParams?.aoi }; break; @@ -238,15 +250,17 @@ export const skyfiTool = ({ mcpToolName = 'skyfi_place_archive_order'; } mcpArgs = { - idempotencyKey: stableIdempotencyKey, ...extraParams, + ...(orderId ? { orderId } : {}), + idempotencyKey: stableIdempotencyKey, aoi: resolvedAoiWkt || extraParams?.aoi }; break; case 'list_orders': mcpToolName = 'skyfi_list_orders'; mcpArgs = { - ...extraParams + ...extraParams, + ...(orderId ? { orderId } : {}) }; break; default: @@ -261,7 +275,7 @@ export const skyfiTool = ({ const geocodeResult = await mcpClient.callTool({ name: 'skyfi_geocode_aoi', arguments: { place: location } - }); + }, undefined, { signal: controller.signal }); const geocodeText = (geocodeResult as any)?.content?.[0]?.text || ''; // Extract polygon WKT @@ -274,9 +288,12 @@ export const skyfiTool = ({ } } - console.log('[SkyfiTool] Calling tool:', mcpToolName, 'with args:', mcpArgs); + console.log('[SkyfiTool] Calling tool:', mcpToolName); - const mcpResult = await mcpClient.callTool({ name: mcpToolName, arguments: mcpArgs }); + const mcpResult = await mcpClient.callTool({ + name: mcpToolName, + arguments: mcpArgs + }, undefined, { signal: controller.signal }); clearTimeout(timeoutId); diff --git a/lib/utils/encryption.ts b/lib/utils/encryption.ts index 7073ab50..4e17f9f9 100644 --- a/lib/utils/encryption.ts +++ b/lib/utils/encryption.ts @@ -1,27 +1,36 @@ import crypto from 'crypto'; -const ALGORITHM = 'aes-256-cbc'; -const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default_32_byte_secret_key_placeholder_for_local_dev'; // Must be 32 bytes +const ALGORITHM = 'aes-256-gcm'; + +if (!process.env.ENCRYPTION_KEY) { + throw new Error('ENCRYPTION_KEY environment variable is not set'); +} +const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY; export function encrypt(text: string | null | undefined): string | null { if (!text) return null; - const iv = crypto.randomBytes(16); + const iv = crypto.randomBytes(12); // Standard IV size for GCM is 12 bytes // Ensure the key is exactly 32 bytes const key = crypto.createHash('sha256').update(ENCRYPTION_KEY).digest(); const cipher = crypto.createCipheriv(ALGORITHM, key, iv); let encrypted = cipher.update(text, 'utf8', 'hex'); encrypted += cipher.final('hex'); - return `${iv.toString('hex')}:${encrypted}`; + const tag = cipher.getAuthTag().toString('hex'); + return `${iv.toString('hex')}:${encrypted}:${tag}`; } export function decrypt(encryptedText: string | null | undefined): string | null { if (!encryptedText) return null; try { - const [ivHex, encrypted] = encryptedText.split(':'); - if (!ivHex || !encrypted) return null; + const parts = encryptedText.split(':'); + if (parts.length !== 3) return null; + const [ivHex, encrypted, tagHex] = parts; + if (!ivHex || !encrypted || !tagHex) return null; const iv = Buffer.from(ivHex, 'hex'); + const tag = Buffer.from(tagHex, 'hex'); const key = crypto.createHash('sha256').update(ENCRYPTION_KEY).digest(); const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); let decrypted = decipher.update(encrypted, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; diff --git a/public/sw.js b/public/sw.js index 4ae3f92a..32db4c43 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,2 +1,2 @@ (()=>{"use strict";let e,t,a,r={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"serwist",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},s=e=>[r.prefix,e,r.suffix].filter(e=>e&&e.length>0).join("-"),i=e=>{for(let t of Object.keys(r))e(t)},n={updateDetails:e=>{i(t=>{let a=e[t];"string"==typeof a&&(r[t]=a)})},getGoogleAnalyticsName:e=>e||s(r.googleAnalytics),getPrecacheName:e=>e||s(r.precache),getRuntimeName:e=>e||s(r.runtime)},c=(e,...t)=>{let a=e;return t.length>0&&(a+=` :: ${JSON.stringify(t)}`),a};var o=class extends Error{details;constructor(e,t){super(c(e,t)),this.name=e,this.details=t}};let l=e=>new URL(String(e),location.href).href.replace(RegExp(`^${location.origin}`),"");function h(e){return new Promise(t=>setTimeout(t,e))}let u=new Set;function d(e,t){let a=new URL(e);for(let e of t)a.searchParams.delete(e);return a.href}async function f(e,t,a,r){let s=d(t.url,a);if(t.url===s)return e.match(t,r);let i={...r,ignoreSearch:!0};for(let n of(await e.keys(t,i)))if(s===d(n.url,a))return e.match(n,r)}var w=class{promise;resolve;reject;constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}};let y=async()=>{for(let e of u)await e()},p="-precache-",g=async(e,t=p)=>{let a=(await self.caches.keys()).filter(a=>a.includes(t)&&a.includes(self.registration.scope)&&a!==e);return await Promise.all(a.map(e=>self.caches.delete(e))),a},m=e=>{self.addEventListener("activate",t=>{t.waitUntil(g(n.getPrecacheName(e)).then(e=>{}))})},_=()=>{self.addEventListener("activate",()=>self.clients.claim())},v=(e,t)=>{let a=t();return e.waitUntil(a),a},b=(e,t)=>t.some(t=>e instanceof t),R=new WeakMap,q=new WeakMap,E=new WeakMap,D={get(e,t,a){if(e instanceof IDBTransaction){if("done"===t)return R.get(e);if("store"===t)return a.objectStoreNames[1]?void 0:a.objectStore(a.objectStoreNames[0])}return S(e[t])},set:(e,t,a)=>(e[t]=a,!0),has:(e,t)=>e instanceof IDBTransaction&&("done"===t||"store"===t)||t in e};function S(e){if(e instanceof IDBRequest){let t=new Promise((t,a)=>{let r=()=>{e.removeEventListener("success",s),e.removeEventListener("error",i)},s=()=>{t(S(e.result)),r()},i=()=>{a(e.error),r()};e.addEventListener("success",s),e.addEventListener("error",i)});return E.set(t,e),t}if(q.has(e))return q.get(e);let r=function(e){if("function"==typeof e)return(a||(a=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(e)?function(...t){return e.apply(P(this),t),S(this.request)}:function(...t){return S(e.apply(P(this),t))};return(e instanceof IDBTransaction&&function(e){if(R.has(e))return;let t=new Promise((t,a)=>{let r=()=>{e.removeEventListener("complete",s),e.removeEventListener("error",i),e.removeEventListener("abort",i)},s=()=>{t(),r()},i=()=>{a(e.error||new DOMException("AbortError","AbortError")),r()};e.addEventListener("complete",s),e.addEventListener("error",i),e.addEventListener("abort",i)});R.set(e,t)}(e),b(e,t||(t=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])))?new Proxy(e,D):e}(e);return r!==e&&(q.set(e,r),E.set(r,e)),r}let P=e=>E.get(e),k=["get","getKey","getAll","getAllKeys","count"],C=["put","add","delete","clear"],T=new Map;function N(e,t){if(!(e instanceof IDBDatabase&&!(t in e)&&"string"==typeof t))return;if(T.get(t))return T.get(t);let a=t.replace(/FromIndex$/,""),r=t!==a,s=C.includes(a);if(!(a in(r?IDBIndex:IDBObjectStore).prototype)||!(s||k.includes(a)))return;let i=async function(e,...t){let i=this.transaction(e,s?"readwrite":"readonly"),n=i.store;return r&&(n=n.index(t.shift())),(await Promise.all([n[a](...t),s&&i.done]))[0]};return T.set(t,i),i}D=(e=>({...e,get:(t,a,r)=>N(t,a)||e.get(t,a,r),has:(t,a)=>!!N(t,a)||e.has(t,a)}))(D);let I=["continue","continuePrimaryKey","advance"],L={},x=new WeakMap,U=new WeakMap,B={get(e,t){if(!I.includes(t))return e[t];let a=L[t];return a||(a=L[t]=function(...e){x.set(this,U.get(this)[t](...e))}),a}};async function*O(...e){let t=this;if(t instanceof IDBCursor||(t=await t.openCursor(...e)),!t)return;let a=new Proxy(t,B);for(U.set(a,t),E.set(a,P(t));t;)yield a,t=await (x.get(a)||t.continue()),x.delete(a)}function K(e,t){return t===Symbol.asyncIterator&&b(e,[IDBIndex,IDBObjectStore,IDBCursor])||"iterate"===t&&b(e,[IDBIndex,IDBObjectStore])}D=(e=>({...e,get:(t,a,r)=>K(t,a)?O:e.get(t,a,r),has:(t,a)=>K(t,a)||e.has(t,a)}))(D);let A=async(t,a)=>{let r=null;if(t.url&&(r=new URL(t.url).origin),r!==self.location.origin)throw new o("cross-origin-copy-response",{origin:r});let s=t.clone(),i={headers:new Headers(s.headers),status:s.status,statusText:s.statusText},n=a?a(i):i,c=!function(){if(void 0===e){let t=new Response("");if("body"in t)try{new Response(t.body),e=!0}catch{e=!1}e=!1}return e}()?await s.blob():s.body;return new Response(c,n)},M=()=>{self.__WB_DISABLE_DEV_LOGS=!0},F="requests",W="queueName";var j=class{_db=null;async addEntry(e){let t=(await this.getDb()).transaction(F,"readwrite",{durability:"relaxed"});await t.store.add(e),await t.done}async getFirstEntryId(){return(await (await this.getDb()).transaction(F).store.openCursor())?.value.id}async getAllEntriesByQueueName(e){return await (await this.getDb()).getAllFromIndex(F,W,IDBKeyRange.only(e))||[]}async getEntryCountByQueueName(e){return(await this.getDb()).countFromIndex(F,W,IDBKeyRange.only(e))}async deleteEntry(e){await (await this.getDb()).delete(F,e)}async getFirstEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"next")}async getLastEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"prev")}async getEndEntryFromIndex(e,t){return(await (await this.getDb()).transaction(F).store.index(W).openCursor(e,t))?.value}async getDb(){return this._db||(this._db=await function(e,t,{blocked:a,upgrade:r,blocking:s,terminated:i}={}){let n=indexedDB.open(e,3),c=S(n);return r&&n.addEventListener("upgradeneeded",e=>{r(S(n.result),e.oldVersion,e.newVersion,S(n.transaction),e)}),a&&n.addEventListener("blocked",e=>a(e.oldVersion,e.newVersion,e)),c.then(e=>{i&&e.addEventListener("close",()=>i()),s&&e.addEventListener("versionchange",e=>s(e.oldVersion,e.newVersion,e))}).catch(()=>{}),c}("serwist-background-sync",0,{upgrade:this._upgradeDb})),this._db}_upgradeDb(e,t){t>0&&t<3&&e.objectStoreNames.contains(F)&&e.deleteObjectStore(F),e.createObjectStore(F,{autoIncrement:!0,keyPath:"id"}).createIndex(W,W,{unique:!1})}},H=class{_queueName;_queueDb;constructor(e){this._queueName=e,this._queueDb=new j}async pushEntry(e){delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async unshiftEntry(e){let t=await this._queueDb.getFirstEntryId();t?e.id=t-1:delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async popEntry(){return this._removeEntry(await this._queueDb.getLastEntryByQueueName(this._queueName))}async shiftEntry(){return this._removeEntry(await this._queueDb.getFirstEntryByQueueName(this._queueName))}async getAll(){return await this._queueDb.getAllEntriesByQueueName(this._queueName)}async size(){return await this._queueDb.getEntryCountByQueueName(this._queueName)}async deleteEntry(e){await this._queueDb.deleteEntry(e)}async _removeEntry(e){return e&&await this.deleteEntry(e.id),e}};let $=["method","referrer","referrerPolicy","mode","credentials","cache","redirect","integrity","keepalive"];var G=class e{_requestData;static async fromRequest(t){let a={url:t.url,headers:{}};for(let e of("GET"!==t.method&&(a.body=await t.clone().arrayBuffer()),t.headers.forEach((e,t)=>{a.headers[t]=e}),$))void 0!==t[e]&&(a[e]=t[e]);return new e(a)}constructor(e){"navigate"===e.mode&&(e.mode="same-origin"),this._requestData=e}toObject(){let e=Object.assign({},this._requestData);return e.headers=Object.assign({},this._requestData.headers),e.body&&(e.body=e.body.slice(0)),e}toRequest(){return new Request(this._requestData.url,this._requestData)}clone(){return new e(this.toObject())}};let V="serwist-background-sync",Q=new Set,z=e=>{let t={request:new G(e.requestData).toRequest(),timestamp:e.timestamp};return e.metadata&&(t.metadata=e.metadata),t};var J=class{_name;_onSync;_maxRetentionTime;_queueStore;_forceSyncFallback;_syncInProgress=!1;_requestsAddedDuringSync=!1;constructor(e,{forceSyncFallback:t,onSync:a,maxRetentionTime:r}={}){if(Q.has(e))throw new o("duplicate-queue-name",{name:e});Q.add(e),this._name=e,this._onSync=a||this.replayRequests,this._maxRetentionTime=r||10080,this._forceSyncFallback=!!t,this._queueStore=new H(this._name),this._addSyncListener()}get name(){return this._name}async pushRequest(e){await this._addRequest(e,"push")}async unshiftRequest(e){await this._addRequest(e,"unshift")}async popRequest(){return this._removeRequest("pop")}async shiftRequest(){return this._removeRequest("shift")}async getAll(){let e=await this._queueStore.getAll(),t=Date.now(),a=[];for(let r of e){let e=60*this._maxRetentionTime*1e3;t-r.timestamp>e?await this._queueStore.deleteEntry(r.id):a.push(z(r))}return a}async size(){return await this._queueStore.size()}async _addRequest({request:e,metadata:t,timestamp:a=Date.now()},r){let s={requestData:(await G.fromRequest(e.clone())).toObject(),timestamp:a};switch(t&&(s.metadata=t),r){case"push":await this._queueStore.pushEntry(s);break;case"unshift":await this._queueStore.unshiftEntry(s)}this._syncInProgress?this._requestsAddedDuringSync=!0:await this.registerSync()}async _removeRequest(e){let t,a=Date.now();switch(e){case"pop":t=await this._queueStore.popEntry();break;case"shift":t=await this._queueStore.shiftEntry()}if(t){let r=60*this._maxRetentionTime*1e3;return a-t.timestamp>r?this._removeRequest(e):z(t)}}async replayRequests(){let e;for(;e=await this.shiftRequest();)try{await fetch(e.request.clone())}catch{throw await this.unshiftRequest(e),new o("queue-replay-failed",{name:this._name})}}async registerSync(){if("sync"in self.registration&&!this._forceSyncFallback)try{await self.registration.sync.register(`${V}:${this._name}`)}catch(e){}}_addSyncListener(){"sync"in self.registration&&!this._forceSyncFallback?self.addEventListener("sync",e=>{if(e.tag===`${V}:${this._name}`){let t=async()=>{let t;this._syncInProgress=!0;try{await this._onSync({queue:this})}catch(e){if(e instanceof Error)throw e}finally{this._requestsAddedDuringSync&&!(t&&!e.lastChance)&&await this.registerSync(),this._syncInProgress=!1,this._requestsAddedDuringSync=!1}};e.waitUntil(t())}}):this._onSync({queue:this})}static get _queueNames(){return Q}},X=class{_queue;constructor(e,t){this._queue=new J(e,t)}async fetchDidFail({request:e}){await this._queue.pushRequest({request:e})}};let Y={cacheWillUpdate:async({response:e})=>200===e.status||0===e.status?e:null};function Z(e){return"string"==typeof e?new Request(e):e}var ee=class{event;request;url;params;_cacheKeys={};_strategy;_handlerDeferred;_extendLifetimePromises;_plugins;_pluginStateMap;constructor(e,t){for(let a of(this.event=t.event,this.request=t.request,t.url&&(this.url=t.url,this.params=t.params),this._strategy=e,this._handlerDeferred=new w,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map,this._plugins))this._pluginStateMap.set(a,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){let{event:t}=this,a=Z(e),r=await this.getPreloadResponse();if(r)return r;let s=this.hasCallback("fetchDidFail")?a.clone():null;try{for(let e of this.iterateCallbacks("requestWillFetch"))a=await e({request:a.clone(),event:t})}catch(e){if(e instanceof Error)throw new o("plugin-error-request-will-fetch",{thrownErrorMessage:e.message})}let i=a.clone();try{let e;for(let r of(e=await fetch(a,"navigate"===a.mode?void 0:this._strategy.fetchOptions),this.iterateCallbacks("fetchDidSucceed")))e=await r({event:t,request:i,response:e});return e}catch(e){throw s&&await this.runCallbacks("fetchDidFail",{error:e,event:t,originalRequest:s.clone(),request:i.clone()}),e}}async fetchAndCachePut(e){let t=await this.fetch(e),a=t.clone();return this.waitUntil(this.cachePut(e,a)),t}async cacheMatch(e){let t,a=Z(e),{cacheName:r,matchOptions:s}=this._strategy,i=await this.getCacheKey(a,"read"),n={...s,cacheName:r};for(let e of(t=await caches.match(i,n),this.iterateCallbacks("cachedResponseWillBeUsed")))t=await e({cacheName:r,matchOptions:s,cachedResponse:t,request:i,event:this.event})||void 0;return t}async cachePut(e,t){let a=Z(e);await h(0);let r=await this.getCacheKey(a,"write");if(!t)throw new o("cache-put-with-no-response",{url:l(r.url)});let s=await this._ensureResponseSafeToCache(t);if(!s)return!1;let{cacheName:i,matchOptions:n}=this._strategy,c=await self.caches.open(i),u=this.hasCallback("cacheDidUpdate"),d=u?await f(c,r.clone(),["__WB_REVISION__"],n):null;try{await c.put(r,u?s.clone():s)}catch(e){if(e instanceof Error)throw"QuotaExceededError"===e.name&&await y(),e}for(let e of this.iterateCallbacks("cacheDidUpdate"))await e({cacheName:i,oldResponse:d,newResponse:s.clone(),request:r,event:this.event});return!0}async getCacheKey(e,t){let a=`${e.url} | ${t}`;if(!this._cacheKeys[a]){let r=e;for(let e of this.iterateCallbacks("cacheKeyWillBeUsed"))r=Z(await e({mode:t,request:r,event:this.event,params:this.params}));this._cacheKeys[a]=r}return this._cacheKeys[a]}hasCallback(e){for(let t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(let a of this.iterateCallbacks(e))await a(t)}*iterateCallbacks(e){for(let t of this._strategy.plugins)if("function"==typeof t[e]){let a=this._pluginStateMap.get(t),r=r=>{let s={...r,state:a};return t[e](s)};yield r}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){let e;for(;e=this._extendLifetimePromises.shift();)await e}destroy(){this._handlerDeferred.resolve(null)}async getPreloadResponse(){if(this.event instanceof FetchEvent&&"navigate"===this.event.request.mode&&"preloadResponse"in this.event)try{let e=await this.event.preloadResponse;if(e)return e}catch(e){return}}async _ensureResponseSafeToCache(e){let t=e,a=!1;for(let e of this.iterateCallbacks("cacheWillUpdate"))if(t=await e({request:this.request,response:t,event:this.event})||void 0,a=!0,!t)break;return!a&&t&&200!==t.status&&(t=void 0),t}},et=class{cacheName;plugins;fetchOptions;matchOptions;constructor(e={}){this.cacheName=n.getRuntimeName(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){let[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});let t=e.event,a="string"==typeof e.request?new Request(e.request):e.request,r=new ee(this,e.url?{event:t,request:a,url:e.url,params:e.params}:{event:t,request:a}),s=this._getResponse(r,a,t);return[s,this._awaitComplete(s,r,a,t)]}async _getResponse(e,t,a){let r;await e.runCallbacks("handlerWillStart",{event:a,request:t});try{if(r=await this._handle(t,e),void 0===r||"error"===r.type)throw new o("no-response",{url:t.url})}catch(s){if(s instanceof Error){for(let i of e.iterateCallbacks("handlerDidError"))if(void 0!==(r=await i({error:s,event:a,request:t})))break}if(!r)throw s}for(let s of e.iterateCallbacks("handlerWillRespond"))r=await s({event:a,request:t,response:r});return r}async _awaitComplete(e,t,a,r){let s,i;try{s=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:r,request:a,response:s}),await t.doneWaiting()}catch(e){e instanceof Error&&(i=e)}if(await t.runCallbacks("handlerDidComplete",{event:r,request:a,response:s,error:i}),t.destroy(),i)throw i}},ea=class extends et{_networkTimeoutSeconds;constructor(e={}){super(e),this.plugins.some(e=>"cacheWillUpdate"in e)||this.plugins.unshift(Y),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,r=[],s=[];if(this._networkTimeoutSeconds){let{id:i,promise:n}=this._getTimeoutPromise({request:e,logs:r,handler:t});a=i,s.push(n)}let i=this._getNetworkPromise({timeoutId:a,request:e,logs:r,handler:t});s.push(i);let n=await t.waitUntil((async()=>await t.waitUntil(Promise.race(s))||await i)());if(!n)throw new o("no-response",{url:e.url});return n}_getTimeoutPromise({request:e,logs:t,handler:a}){let r;return{promise:new Promise(t=>{r=setTimeout(async()=>{t(await a.cacheMatch(e))},1e3*this._networkTimeoutSeconds)}),id:r}}async _getNetworkPromise({timeoutId:e,request:t,logs:a,handler:r}){let s,i;try{i=await r.fetchAndCachePut(t)}catch(e){e instanceof Error&&(s=e)}return e&&clearTimeout(e),(s||!i)&&(i=await r.cacheMatch(t)),i}},er=class extends et{_networkTimeoutSeconds;constructor(e={}){super(e),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,r;try{let a=[t.fetch(e)];if(this._networkTimeoutSeconds){let e=h(1e3*this._networkTimeoutSeconds);a.push(e)}if(!(r=await Promise.race(a)))throw Error(`Timed out the network response after ${this._networkTimeoutSeconds} seconds.`)}catch(e){e instanceof Error&&(a=e)}if(!r)throw new o("no-response",{url:e.url,error:a});return r}};let es=e=>e&&"object"==typeof e?e:{handle:e};var ei=class{handler;match;method;catchHandler;constructor(e,t,a="GET"){this.handler=es(t),this.match=e,this.method=a}setCatchHandler(e){this.catchHandler=es(e)}},en=class e extends et{_fallbackToNetwork;static defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:e})=>!e||e.status>=400?null:e};static copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:e})=>e.redirected?await A(e):e};constructor(t={}){t.cacheName=n.getPrecacheName(t.cacheName),super(t),this._fallbackToNetwork=!1!==t.fallbackToNetwork,this.plugins.push(e.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){let a=await t.getPreloadResponse();if(a)return a;let r=await t.cacheMatch(e);return r||(t.event&&"install"===t.event.type?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,t){let a,r=t.params||{};if(this._fallbackToNetwork){let s=r.integrity,i=e.integrity,n=!i||i===s;a=await t.fetch(new Request(e,{integrity:"no-cors"!==e.mode?i||s:void 0})),s&&n&&"no-cors"!==e.mode&&(this._useDefaultCacheabilityPluginIfNeeded(),await t.cachePut(e,a.clone()))}else throw new o("missing-precache-entry",{cacheName:this.cacheName,url:e.url});return a}async _handleInstall(e,t){this._useDefaultCacheabilityPluginIfNeeded();let a=await t.fetch(e);if(!await t.cachePut(e,a.clone()))throw new o("bad-precaching-response",{url:e.url,status:a.status});return a}_useDefaultCacheabilityPluginIfNeeded(){let t=null,a=0;for(let[r,s]of this.plugins.entries())s!==e.copyRedirectedCacheableResponsesPlugin&&(s===e.defaultPrecacheCacheabilityPlugin&&(t=r),s.cacheWillUpdate&&a++);0===a?this.plugins.push(e.defaultPrecacheCacheabilityPlugin):a>1&&null!==t&&this.plugins.splice(t,1)}},ec=class extends ei{_allowlist;_denylist;constructor(e,{allowlist:t=[/./],denylist:a=[]}={}){super(e=>this._match(e),e),this._allowlist=t,this._denylist=a}_match({url:e,request:t}){if(t&&"navigate"!==t.mode)return!1;let a=e.pathname+e.search;for(let e of this._denylist)if(e.test(a))return!1;return!!this._allowlist.some(e=>e.test(a))}};let eo=()=>!!self.registration?.navigationPreload,el=e=>{eo()&&self.addEventListener("activate",t=>{t.waitUntil(self.registration.navigationPreload.enable().then(()=>{e&&self.registration.navigationPreload.setHeaderValue(e)}))})},eh=(e,t=[])=>{for(let a of[...e.searchParams.keys()])t.some(e=>e.test(a))&&e.searchParams.delete(a);return e};var eu=class extends ei{constructor(e,t,a){super(({url:t})=>{let a=e.exec(t.href);if(a)return t.origin!==location.origin&&0!==a.index?void 0:a.slice(1)},t,a)}};let ed=e=>{n.updateDetails(e)},ef=e=>{if(!e)throw new o("add-to-cache-list-unexpected-type",{entry:e});if("string"==typeof e){let t=new URL(e,location.href);return{cacheKey:t.href,url:t.href}}let{revision:t,url:a}=e;if(!a)throw new o("add-to-cache-list-unexpected-type",{entry:e});if(!t){let e=new URL(a,location.href);return{cacheKey:e.href,url:e.href}}let r=new URL(a,location.href),s=new URL(a,location.href);return r.searchParams.set("__WB_REVISION__",t),{cacheKey:r.href,url:s.href}};var ew=class{updatedURLs=[];notUpdatedURLs=[];handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)};cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:a})=>{if("install"===e.type&&t?.originalRequest&&t.originalRequest instanceof Request){let e=t.originalRequest.url;a?this.notUpdatedURLs.push(e):this.updatedURLs.push(e)}return a}};let ey=(e,t,a)=>{if("string"==typeof e){let r=new URL(e,location.href);return new ei(({url:e})=>e.href===r.href,t,a)}if(e instanceof RegExp)return new eu(e,t,a);if("function"==typeof e)return new ei(e,t,a);if(e instanceof ei)return e;throw new o("unsupported-route-type",{moduleName:"serwist",funcName:"parseRoute",paramName:"capture"})},ep=async(e,t,a)=>{let r=t.map((e,t)=>({index:t,item:e})),s=async e=>{let t=[];for(;;){let s=r.pop();if(!s)return e(t);let i=await a(s.item);t.push({result:i,index:s.index})}},i=Array.from({length:e},()=>new Promise(s));return(await Promise.all(i)).flat().sort((e,t)=>e.indexe.result)},eg=(e,t,a)=>!a.some(a=>e.headers.has(a)&&t.headers.has(a))||a.every(a=>{let r=e.headers.has(a)===t.headers.has(a),s=e.headers.get(a)===t.headers.get(a);return r&&s}),em="undefined"!=typeof navigator&&/^((?!chrome|android).)*safari/i.test(navigator.userAgent),e_=e=>({cacheName:e.cacheName,updatedURL:e.request.url}),ev="cache-entries",eb=e=>{let t=new URL(e,location.href);return t.hash="",t.href};var eR=class{_cacheName;_db=null;constructor(e){this._cacheName=e}_getId(e){return`${this._cacheName}|${eb(e)}`}_upgradeDb(e){let t=e.createObjectStore(ev,{keyPath:"id"});t.createIndex("cacheName","cacheName",{unique:!1}),t.createIndex("timestamp","timestamp",{unique:!1})}_upgradeDbAndDeleteOldDbs(e){this._upgradeDb(e),this._cacheName&&deleteDB(this._cacheName)}async setTimestamp(e,t){e=eb(e);let a={id:this._getId(e),cacheName:this._cacheName,url:e,timestamp:t},r=(await this.getDb()).transaction(ev,"readwrite",{durability:"relaxed"});await r.store.put(a),await r.done}async getTimestamp(e){return(await (await this.getDb()).get(ev,this._getId(e)))?.timestamp}async expireEntries(e,t){let a=await (await this.getDb()).transaction(ev,"readwrite").store.index("timestamp").openCursor(null,"prev"),r=[],s=0;for(;a;){let i=a.value;i.cacheName===this._cacheName&&(e&&i.timestamp=t?(a.delete(),r.push(i.url)):s++),a=await a.continue()}return r}async getDb(){return this._db||(this._db=await openDB("serwist-expiration",1,{upgrade:this._upgradeDbAndDeleteOldDbs.bind(this)})),this._db}};let eq=/^\/(\w+\/)?collect/,eE=e=>async({queue:t})=>{let a;for(;a=await t.shiftRequest();){let{request:r,timestamp:s}=a,i=new URL(r.url);try{let t="POST"===r.method?new URLSearchParams(await r.clone().text()):i.searchParams,a=s-(Number(t.get("qt"))||0),n=Date.now()-a;if(t.set("qt",String(n)),e.parameterOverrides)for(let a of Object.keys(e.parameterOverrides)){let r=e.parameterOverrides[a];t.set(a,r)}"function"==typeof e.hitFilter&&e.hitFilter.call(null,t),await fetch(new Request(i.origin+i.pathname,{body:t.toString(),method:"POST",mode:"cors",credentials:"omit",headers:{"Content-Type":"text/plain"}}))}catch(e){throw await t.unshiftRequest(a),e}}},eD=e=>{let t=({url:e})=>"www.google-analytics.com"===e.hostname&&eq.test(e.pathname),a=new er({plugins:[e]});return[new ei(t,a,"GET"),new ei(t,a,"POST")]},eS=e=>new ei(({url:e})=>"www.google-analytics.com"===e.hostname&&"/analytics.js"===e.pathname,new ea({cacheName:e}),"GET"),eP=e=>new ei(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtag/js"===e.pathname,new ea({cacheName:e}),"GET"),ek=e=>new ei(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtm.js"===e.pathname,new ea({cacheName:e}),"GET"),eC=({serwist:e,cacheName:t,...a})=>{let r=n.getGoogleAnalyticsName(t),s=new X("serwist-google-analytics",{maxRetentionTime:2880,onSync:eE(a)});for(let t of[ek(r),eS(r),eP(r),...eD(s)])e.registerRoute(t)};var eT=class{_fallbackUrls;_serwist;constructor({fallbackUrls:e,serwist:t}){this._fallbackUrls=e,this._serwist=t}async handlerDidError(e){for(let t of this._fallbackUrls)if("string"==typeof t){let e=await this._serwist.matchPrecache(t);if(void 0!==e)return e}else if(t.matcher(e)){let e=await this._serwist.matchPrecache(t.url);if(void 0!==e)return e}}};let eN=(e,t,a)=>{let r,s,i=e.size;if(a&&a>i||t&&t<0)throw new SerwistError("range-not-satisfiable",{size:i,end:a,start:t});return void 0!==t&&void 0!==a?(r=t,s=a+1):void 0!==t&&void 0===a?(r=t,s=i):void 0!==a&&void 0===t&&(r=i-a,s=i),{start:r,end:s}},eI=e=>{let t=e.trim().toLowerCase();if(!t.startsWith("bytes="))throw new SerwistError("unit-must-be-bytes",{normalizedRangeHeader:t});if(t.includes(","))throw new SerwistError("single-range-only",{normalizedRangeHeader:t});let a=/(\d*)-(\d*)/.exec(t);if(!a||!(a[1]||a[2]))throw new SerwistError("invalid-range-values",{normalizedRangeHeader:t});return{start:""===a[1]?void 0:Number(a[1]),end:""===a[2]?void 0:Number(a[2])}};var eL=class extends et{async _handle(e,t){let a,r=await t.cacheMatch(e);if(r);else try{r=await t.fetchAndCachePut(e)}catch(e){e instanceof Error&&(a=e)}if(!r)throw new o("no-response",{url:e.url,error:a});return r}},ex=class extends ei{constructor(e,t){super(({request:a})=>{let r=e.getUrlsToPrecacheKeys();for(let s of function*(e,{directoryIndex:t="index.html",ignoreURLParametersMatching:a=[/^utm_/,/^fbclid$/],cleanURLs:r=!0,urlManipulation:s}={}){let i=new URL(e,location.href);i.hash="",yield i.href;let n=eh(i,a);if(yield n.href,t&&n.pathname.endsWith("/")){let e=new URL(n.href);e.pathname+=t,yield e.href}if(r){let e=new URL(n.href);e.pathname+=".html",yield e.href}if(s)for(let e of s({url:i}))yield e.href}(a.url,t)){let t=r.get(s);if(t)return{cacheKey:t,integrity:e.getIntegrityForPrecacheKey(t)}}},e.precacheStrategy)}},eU=class{_precacheController;constructor({precacheController:e}){this._precacheController=e}cacheKeyWillBeUsed=async({request:e,params:t})=>{let a=t?.cacheKey||this._precacheController.getPrecacheKeyForUrl(e.url);return a?new Request(a,{headers:e.headers}):e}};let eB=(e,t={})=>{let{cacheName:a,plugins:r=[],fetchOptions:s,matchOptions:i,fallbackToNetwork:c,directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u,cleanupOutdatedCaches:d,concurrency:f=10,navigateFallback:w,navigateFallbackAllowlist:y,navigateFallbackDenylist:p}=t??{};return{precacheStrategyOptions:{cacheName:n.getPrecacheName(a),plugins:[...r,new eU({precacheController:e})],fetchOptions:s,matchOptions:i,fallbackToNetwork:c},precacheRouteOptions:{directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u},precacheMiscOptions:{cleanupOutdatedCaches:d,concurrency:f,navigateFallback:w,navigateFallbackAllowlist:y,navigateFallbackDenylist:p}}};new class{_urlsToCacheKeys=new Map;_urlsToCacheModes=new Map;_cacheKeysToIntegrities=new Map;_concurrentPrecaching;_precacheStrategy;_routes;_defaultHandlerMap;_catchHandler;_requestRules;constructor({precacheEntries:e,precacheOptions:t,skipWaiting:a=!1,importScripts:r,navigationPreload:s=!1,cacheId:i,clientsClaim:n=!1,runtimeCaching:c,offlineAnalyticsConfig:o,disableDevLogs:l=!1,fallbacks:h,requestRules:u}={}){let{precacheStrategyOptions:d,precacheRouteOptions:f,precacheMiscOptions:w}=eB(this,t);if(this._concurrentPrecaching=w.concurrency,this._precacheStrategy=new en(d),this._routes=new Map,this._defaultHandlerMap=new Map,this._requestRules=u,this.handleInstall=this.handleInstall.bind(this),this.handleActivate=this.handleActivate.bind(this),this.handleFetch=this.handleFetch.bind(this),this.handleCache=this.handleCache.bind(this),r&&r.length>0&&self.importScripts(...r),s&&el(),void 0!==i&&ed({prefix:i}),a?self.skipWaiting():self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),n&&_(),e&&e.length>0&&this.addToPrecacheList(e),w.cleanupOutdatedCaches&&m(d.cacheName),this.registerRoute(new ex(this,f)),w.navigateFallback&&this.registerRoute(new ec(this.createHandlerBoundToUrl(w.navigateFallback),{allowlist:w.navigateFallbackAllowlist,denylist:w.navigateFallbackDenylist})),void 0!==o&&("boolean"==typeof o?o&&eC({serwist:this}):eC({...o,serwist:this})),void 0!==c){if(void 0!==h){let e=new eT({fallbackUrls:h.entries,serwist:this});c.forEach(t=>{t.handler instanceof et&&!t.handler.plugins.some(e=>"handlerDidError"in e)&&t.handler.plugins.push(e)})}for(let e of c)this.registerCapture(e.matcher,e.handler,e.method)}l&&M()}get precacheStrategy(){return this._precacheStrategy}get routes(){return this._routes}addEventListeners(){self.addEventListener("install",this.handleInstall),self.addEventListener("activate",this.handleActivate),self.addEventListener("fetch",this.handleFetch),self.addEventListener("message",this.handleCache)}addToPrecacheList(e){let t=[];for(let a of e){"string"==typeof a?t.push(a):a&&!a.integrity&&void 0===a.revision&&t.push(a.url);let{cacheKey:e,url:r}=ef(a),s="string"!=typeof a&&a.revision?"reload":"default";if(this._urlsToCacheKeys.has(r)&&this._urlsToCacheKeys.get(r)!==e)throw new o("add-to-cache-list-conflicting-entries",{firstEntry:this._urlsToCacheKeys.get(r),secondEntry:e});if("string"!=typeof a&&a.integrity){if(this._cacheKeysToIntegrities.has(e)&&this._cacheKeysToIntegrities.get(e)!==a.integrity)throw new o("add-to-cache-list-conflicting-integrities",{url:r});this._cacheKeysToIntegrities.set(e,a.integrity)}this._urlsToCacheKeys.set(r,e),this._urlsToCacheModes.set(r,s)}t.length>0&&console.warn(`Serwist is precaching URLs without revision info: ${t.join(", ")} -This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),v(e,async()=>{let t=new ew;this.precacheStrategy.plugins.push(t),await ep(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let r=this._cacheKeysToIntegrities.get(a),s=this._urlsToCacheModes.get(t),i=new Request(t,{integrity:r,cache:s,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:i,url:new URL(i.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:r}=t;return{updatedURLs:a,notUpdatedURLs:r}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return v(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),r=[];for(let s of t)a.has(s.url)||(await e.delete(s),r.push(s.url));return{deletedCacheRequests:r}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,es(e))}setCatchHandler(e){this._catchHandler=es(e)}registerCapture(e,t,a){let r=ey(e,t,a);return this.registerRoute(r),r}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new o("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new o("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new o("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,r=new URL(e.url,location.href);if(!r.protocol.startsWith("http"))return;let s=r.origin===location.origin,{params:i,route:n}=this.findMatchingRoute({event:t,request:e,sameOrigin:s,url:r}),c=n?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:r,request:e,event:t,params:i})}catch(e){a=Promise.reject(e)}let l=n?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:r,request:e,event:t,params:i})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:r,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:r}){for(let s of this._routes.get(a.method)||[]){let i,n=s.match({url:e,sameOrigin:t,request:a,event:r});if(n)return Array.isArray(i=n)&&0===i.length||n.constructor===Object&&0===Object.keys(n).length?i=void 0:"boolean"==typeof n&&(i=void 0),{route:s,params:i}}return{}}}({precacheEntries:[{'revision':null,'url':'/_next/static/chunks/1463-9fbf44fc07aec709.js'},{'revision':null,'url':'/_next/static/chunks/164f4fb6.ce777fe0c2f313d4.js'},{'revision':null,'url':'/_next/static/chunks/1751-a7421f933b645f53.js'},{'revision':null,'url':'/_next/static/chunks/2121.224732b1794a9682.js'},{'revision':null,'url':'/_next/static/chunks/245.71c45fa6b1e823c4.js'},{'revision':null,'url':'/_next/static/chunks/2642.b93aca14ed10255c.js'},{'revision':null,'url':'/_next/static/chunks/2f0b94e8.9fae400113b2869a.js'},{'revision':null,'url':'/_next/static/chunks/4221.e8dc382e3f6203c5.js'},{'revision':null,'url':'/_next/static/chunks/4309.5568f859d1e3347b.js'},{'revision':null,'url':'/_next/static/chunks/44530001-2dcabf4063e0dc0d.js'},{'revision':null,'url':'/_next/static/chunks/47-f1f887d9590ad0ae.js'},{'revision':null,'url':'/_next/static/chunks/4745-3a6bac70cc179378.js'},{'revision':null,'url':'/_next/static/chunks/4751.e4c42f792ab47d1a.js'},{'revision':null,'url':'/_next/static/chunks/4bd1b696-f242fc90bae6ac3d.js'},{'revision':null,'url':'/_next/static/chunks/5714-fd4a0a03b48efe5e.js'},{'revision':null,'url':'/_next/static/chunks/5860-c8dc3b658eadb632.js'},{'revision':null,'url':'/_next/static/chunks/6399.9dbce694e39ce768.js'},{'revision':null,'url':'/_next/static/chunks/7034.85f74aed4274c767.js'},{'revision':null,'url':'/_next/static/chunks/7797-93c0d2f54719fac4.js'},{'revision':null,'url':'/_next/static/chunks/822.e5a25ff0c78b4017.js'},{'revision':null,'url':'/_next/static/chunks/8335.71e63cf2b4e96600.js'},{'revision':null,'url':'/_next/static/chunks/883ee446.e78f619846db8225.js'},{'revision':null,'url':'/_next/static/chunks/9191-6a943a71fbab5fbf.js'},{'revision':null,'url':'/_next/static/chunks/ad2866b8.1fc071285e350c45.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-72fcca513fff4e0b.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chat/route-ad2e70fc255c1261.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chats/%5Bid%5D/participants/route-6bedacf495dea8ce.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chats/all/route-4968a1d56998528c.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chats/route-af8a65c4e4ae4f5b.js'},{'revision':null,'url':'/_next/static/chunks/app/api/clerk/webhook/route-65f379ea9ac4c6d4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/embeddings/route-2ea076c3f5b3454c.js'},{'revision':null,'url':'/_next/static/chunks/app/api/health/route-dfdeb492290f9b9a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/skyfi/callback/route-248f26a809ad3df4.js'},{'revision':null,'url':'/_next/static/chunks/app/auth/discord/page-7d8d675b37f835f0.js'},{'revision':null,'url':'/_next/static/chunks/app/discord-auth/page-627cc9c32197466c.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-7b2312a6731bc6dc.js'},{'revision':null,'url':'/_next/static/chunks/app/manifest.webmanifest/route-5d4b636a9b2516ab.js'},{'revision':null,'url':'/_next/static/chunks/app/offline/page-cc51ffd178cab1d1.js'},{'revision':null,'url':'/_next/static/chunks/app/page-055728cc08de1e64.js'},{'revision':null,'url':'/_next/static/chunks/app/search/%5Bid%5D/page-0f78b0deabbf0ed3.js'},{'revision':null,'url':'/_next/static/chunks/app/sign-in/discord/page-1c84793792fe7d77.js'},{'revision':null,'url':'/_next/static/chunks/app/sign-in/page-bff6eb5de63840bb.js'},{'revision':null,'url':'/_next/static/chunks/app/sso-callback/page-00f41728b5cbfb00.js'},{'revision':null,'url':'/_next/static/chunks/bc98253f.a20b3a3cf1b114d6.js'},{'revision':null,'url':'/_next/static/chunks/c36f3faa.abb912aa7b92c8b5.js'},{'revision':null,'url':'/_next/static/chunks/d3ac728e-90b4132adc93dda2.js'},{'revision':null,'url':'/_next/static/chunks/dc112a36-18909a95e93ec969.js'},{'revision':null,'url':'/_next/static/chunks/fc2f6fa8-f85eea3e43dc0331.js'},{'revision':null,'url':'/_next/static/chunks/framework-1139d96b397cc8d6.js'},{'revision':null,'url':'/_next/static/chunks/main-794f106834521f59.js'},{'revision':null,'url':'/_next/static/chunks/main-app-a21f74533f7d6029.js'},{'revision':null,'url':'/_next/static/chunks/pages/_app-5d1abe03d322390c.js'},{'revision':null,'url':'/_next/static/chunks/pages/_error-3b2a1d523de49635.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-1548a9b545466b3e.js'},{'revision':null,'url':'/_next/static/css/1f4d2367ed33502e.css'},{'revision':null,'url':'/_next/static/css/2f46e0db0d4a3aed.css'},{'revision':null,'url':'/_next/static/css/6aa45f89e1641e77.css'},{'revision':null,'url':'/_next/static/css/a67336182a0a18f5.css'},{'revision':null,'url':'/_next/static/css/c35610c794fcbaaa.css'},{'revision':'372da9634d54bcad774a15d5874b29d0','url':'/_next/static/fHSycEt6XHkB2lIg0AxFl/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/fHSycEt6XHkB2lIg0AxFl/_ssgManifest.js'},{'revision':'be7c930fceb794521be0a68e113a71d8','url':'/_next/static/media/034d78ad42e9620c-s.woff2'},{'revision':'b550bca8934bd86812d1f5e28c9cc1de','url':'/_next/static/media/0484562807a97172-s.p.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'69d9d2cdadeab7225297d50fc8e48e8b','url':'/_next/static/media/29a4aea02fdee119-s.woff2'},{'revision':'9e3ecbe4bb4c6f0b71adc1cd481c2bdc','url':'/_next/static/media/29e7bbdce9332268-s.woff2'},{'revision':'792477d09826b11d1e5a611162c9797a','url':'/_next/static/media/8888a3826f4a3af4-s.p.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_AMS-Regular.1608a09b.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_AMS-Regular.4aafdb68.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_AMS-Regular.a79f1c31.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Bold.b6770918.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Bold.cce5b8ec.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Bold.ec17d132.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Regular.07ef19e7.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Regular.55fac258.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Regular.dad44a7f.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Bold.9f256b85.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Bold.b18f59e1.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Bold.d42a5579.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Regular.7c187121.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Regular.d3c882a6.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Regular.ed38e79f.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Bold.b74a1a8b.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Bold.c3fb5ac2.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Bold.d181c465.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-BoldItalic.6f2bb1df.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-BoldItalic.70d8b0a5.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-BoldItalic.e3f82f9d.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Italic.47373d1e.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Italic.8916142b.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Italic.9024d815.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Regular.0462f03b.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Regular.7f51fe03.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Regular.b7f8fe9b.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-BoldItalic.572d331f.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-BoldItalic.a879cf83.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-BoldItalic.f1035d8d.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-Italic.5295ba48.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-Italic.939bc644.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-Italic.f28c23ac.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Bold.8c5b5494.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Bold.94e1e8dc.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Bold.bf59d231.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Italic.3b1e59b3.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Italic.7c9bc82b.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Italic.b4c20c84.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Regular.74048478.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Regular.ba21ed5f.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Regular.d4d7ba48.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Script-Regular.03e9641d.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Script-Regular.07505710.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Script-Regular.fe9cbbe1.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Size1-Regular.e1e279cb.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Size1-Regular.eae34984.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Size1-Regular.fabc004a.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Size2-Regular.57727022.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Size2-Regular.5916a24f.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Size2-Regular.d6b476ec.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Size3-Regular.9acaf01c.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Size3-Regular.a144ef58.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Size3-Regular.b4230e7e.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Size4-Regular.10d95fd3.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Size4-Regular.7a996c9d.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Size4-Regular.fbccdabe.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Typewriter-Regular.6258592b.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Typewriter-Regular.a8709e36.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Typewriter-Regular.d97aaf4a.ttf'},{'revision':'d3aa06d13d3cf9c0558927051f3cb948','url':'/_next/static/media/a1386beebedccca4-s.woff2'},{'revision':'0bd523f6049956faaf43c254a719d06a','url':'/_next/static/media/b957ea75a84b6ea7-s.p.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'5a1b7c983a9dc0a87a2ff138e07ae822','url':'/_next/static/media/c3bc380753a8436c-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'9516f567cd80b0f418bba2f1299ed6d1','url':'/_next/static/media/db911767852bc875-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'43751174b6b810eb169101a20d8c26f8','url':'/_next/static/media/eafabf029ad39a43-s.p.woff2'},{'revision':'63af7d5e18e585fad8d0220e5d551da1','url':'/_next/static/media/f10b8e9d91f3edcb-s.woff2'},{'revision':'f2a04185547c36abfa589651236a9849','url':'/_next/static/media/fe0777f1195381cb-s.woff2'},{'revision':'460f55cf1456f5a201f259a4eb607989','url':'/icons/apple-touch-icon.png'},{'revision':'69784622e4b55d2666c5180fb954d781','url':'/icons/icon-192x192.png'},{'revision':'ab07c93bfbd862c758c4d6d31085a6db','url':'/icons/icon-512x512-maskable.png'},{'revision':'f1b0e1527b676413a8b8e0154f671984','url':'/icons/icon-512x512.png'},{'revision':'2ed79e1d8607156994a2a6462563d7f0','url':'/images/Q zoom.json'},{'revision':'d088f99e3a3f1b7eaec384f36b0866d2','url':'/images/Q.json'},{'revision':'b14dbca44bb366d8499ae9a9b24a104f','url':'/images/eva-logo.png'},{'revision':'8f6cf4c9ec412fe201f80e2d23445aa9','url':'/images/logo.svg'},{'revision':'ab57101a81d25f409febeed6eb7e32bf','url':'/images/opengraph-image.png'}],skipWaiting:!1,clientsClaim:!1,navigationPreload:!1,runtimeCaching:[{matcher:/\.(?:js|css|woff2?|png|jpg|jpeg|svg|gif|ico)$/,handler:new eL({cacheName:"static-assets"})},{matcher:e=>{let{url:t,request:a}=e,r=t.pathname.startsWith("/api/"),s="/api/chat"===t.pathname&&"POST"===a.method||"/api/chats/all"===t.pathname&&"DELETE"===a.method;return r&&!s},handler:new ea({cacheName:"api-cache",networkTimeoutSeconds:5})},{matcher:e=>{let{request:t}=e;return"navigate"===t.mode},handler:new ea({cacheName:"pages-cache",networkTimeoutSeconds:5,plugins:[{handlerDidError:async()=>caches.match("/offline")}]})}]}).addEventListeners(),self.addEventListener("push",e=>{console.log("[Service Worker] Push Received.",e)}),self.addEventListener("sync",e=>{console.log("[Service Worker] Background Sync.",e)})})(); \ No newline at end of file +This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),v(e,async()=>{let t=new ew;this.precacheStrategy.plugins.push(t),await ep(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let r=this._cacheKeysToIntegrities.get(a),s=this._urlsToCacheModes.get(t),i=new Request(t,{integrity:r,cache:s,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:i,url:new URL(i.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:r}=t;return{updatedURLs:a,notUpdatedURLs:r}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return v(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),r=[];for(let s of t)a.has(s.url)||(await e.delete(s),r.push(s.url));return{deletedCacheRequests:r}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,es(e))}setCatchHandler(e){this._catchHandler=es(e)}registerCapture(e,t,a){let r=ey(e,t,a);return this.registerRoute(r),r}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new o("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new o("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new o("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,r=new URL(e.url,location.href);if(!r.protocol.startsWith("http"))return;let s=r.origin===location.origin,{params:i,route:n}=this.findMatchingRoute({event:t,request:e,sameOrigin:s,url:r}),c=n?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:r,request:e,event:t,params:i})}catch(e){a=Promise.reject(e)}let l=n?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:r,request:e,event:t,params:i})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:r,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:r}){for(let s of this._routes.get(a.method)||[]){let i,n=s.match({url:e,sameOrigin:t,request:a,event:r});if(n)return Array.isArray(i=n)&&0===i.length||n.constructor===Object&&0===Object.keys(n).length?i=void 0:"boolean"==typeof n&&(i=void 0),{route:s,params:i}}return{}}}({precacheEntries:[{'revision':'372da9634d54bcad774a15d5874b29d0','url':'/_next/static/2ABIqGAISHIrGAvA8nu_9/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/2ABIqGAISHIrGAvA8nu_9/_ssgManifest.js'},{'revision':null,'url':'/_next/static/chunks/1463-9fbf44fc07aec709.js'},{'revision':null,'url':'/_next/static/chunks/164f4fb6.ce777fe0c2f313d4.js'},{'revision':null,'url':'/_next/static/chunks/1751-a7421f933b645f53.js'},{'revision':null,'url':'/_next/static/chunks/2121.224732b1794a9682.js'},{'revision':null,'url':'/_next/static/chunks/245.71c45fa6b1e823c4.js'},{'revision':null,'url':'/_next/static/chunks/2642.6b0417a305f28724.js'},{'revision':null,'url':'/_next/static/chunks/2f0b94e8.9fae400113b2869a.js'},{'revision':null,'url':'/_next/static/chunks/4221.e8dc382e3f6203c5.js'},{'revision':null,'url':'/_next/static/chunks/4309.5568f859d1e3347b.js'},{'revision':null,'url':'/_next/static/chunks/44530001-2dcabf4063e0dc0d.js'},{'revision':null,'url':'/_next/static/chunks/47-f1f887d9590ad0ae.js'},{'revision':null,'url':'/_next/static/chunks/4745-3a6bac70cc179378.js'},{'revision':null,'url':'/_next/static/chunks/4751.e4c42f792ab47d1a.js'},{'revision':null,'url':'/_next/static/chunks/4bd1b696-f242fc90bae6ac3d.js'},{'revision':null,'url':'/_next/static/chunks/5714-b7b19c58cfd2d06f.js'},{'revision':null,'url':'/_next/static/chunks/5860-c8dc3b658eadb632.js'},{'revision':null,'url':'/_next/static/chunks/6399.9dbce694e39ce768.js'},{'revision':null,'url':'/_next/static/chunks/7034.85f74aed4274c767.js'},{'revision':null,'url':'/_next/static/chunks/7615.c5f726541bab53bb.js'},{'revision':null,'url':'/_next/static/chunks/7797-93c0d2f54719fac4.js'},{'revision':null,'url':'/_next/static/chunks/822.e5a25ff0c78b4017.js'},{'revision':null,'url':'/_next/static/chunks/883ee446.e78f619846db8225.js'},{'revision':null,'url':'/_next/static/chunks/9191-1b93c10039e8250a.js'},{'revision':null,'url':'/_next/static/chunks/ad2866b8.1fc071285e350c45.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-72fcca513fff4e0b.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chat/route-ad2e70fc255c1261.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chats/%5Bid%5D/participants/route-6bedacf495dea8ce.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chats/all/route-4968a1d56998528c.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chats/route-af8a65c4e4ae4f5b.js'},{'revision':null,'url':'/_next/static/chunks/app/api/clerk/webhook/route-65f379ea9ac4c6d4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/embeddings/route-2ea076c3f5b3454c.js'},{'revision':null,'url':'/_next/static/chunks/app/api/health/route-dfdeb492290f9b9a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/skyfi/callback/route-248f26a809ad3df4.js'},{'revision':null,'url':'/_next/static/chunks/app/auth/discord/page-7d8d675b37f835f0.js'},{'revision':null,'url':'/_next/static/chunks/app/discord-auth/page-627cc9c32197466c.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-7513fee09bd4b940.js'},{'revision':null,'url':'/_next/static/chunks/app/manifest.webmanifest/route-5d4b636a9b2516ab.js'},{'revision':null,'url':'/_next/static/chunks/app/offline/page-cc51ffd178cab1d1.js'},{'revision':null,'url':'/_next/static/chunks/app/page-055728cc08de1e64.js'},{'revision':null,'url':'/_next/static/chunks/app/search/%5Bid%5D/page-0f78b0deabbf0ed3.js'},{'revision':null,'url':'/_next/static/chunks/app/sign-in/discord/page-1c84793792fe7d77.js'},{'revision':null,'url':'/_next/static/chunks/app/sign-in/page-bff6eb5de63840bb.js'},{'revision':null,'url':'/_next/static/chunks/app/sso-callback/page-00f41728b5cbfb00.js'},{'revision':null,'url':'/_next/static/chunks/bc98253f.a20b3a3cf1b114d6.js'},{'revision':null,'url':'/_next/static/chunks/c36f3faa.abb912aa7b92c8b5.js'},{'revision':null,'url':'/_next/static/chunks/d3ac728e-90b4132adc93dda2.js'},{'revision':null,'url':'/_next/static/chunks/dc112a36-18909a95e93ec969.js'},{'revision':null,'url':'/_next/static/chunks/fc2f6fa8-f85eea3e43dc0331.js'},{'revision':null,'url':'/_next/static/chunks/framework-1139d96b397cc8d6.js'},{'revision':null,'url':'/_next/static/chunks/main-794f106834521f59.js'},{'revision':null,'url':'/_next/static/chunks/main-app-a21f74533f7d6029.js'},{'revision':null,'url':'/_next/static/chunks/pages/_app-5d1abe03d322390c.js'},{'revision':null,'url':'/_next/static/chunks/pages/_error-3b2a1d523de49635.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-ce7cee005944d8a9.js'},{'revision':null,'url':'/_next/static/css/1f4d2367ed33502e.css'},{'revision':null,'url':'/_next/static/css/2f46e0db0d4a3aed.css'},{'revision':null,'url':'/_next/static/css/6aa45f89e1641e77.css'},{'revision':null,'url':'/_next/static/css/a67336182a0a18f5.css'},{'revision':null,'url':'/_next/static/css/c35610c794fcbaaa.css'},{'revision':'be7c930fceb794521be0a68e113a71d8','url':'/_next/static/media/034d78ad42e9620c-s.woff2'},{'revision':'b550bca8934bd86812d1f5e28c9cc1de','url':'/_next/static/media/0484562807a97172-s.p.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'69d9d2cdadeab7225297d50fc8e48e8b','url':'/_next/static/media/29a4aea02fdee119-s.woff2'},{'revision':'9e3ecbe4bb4c6f0b71adc1cd481c2bdc','url':'/_next/static/media/29e7bbdce9332268-s.woff2'},{'revision':'792477d09826b11d1e5a611162c9797a','url':'/_next/static/media/8888a3826f4a3af4-s.p.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_AMS-Regular.1608a09b.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_AMS-Regular.4aafdb68.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_AMS-Regular.a79f1c31.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Bold.b6770918.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Bold.cce5b8ec.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Bold.ec17d132.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Regular.07ef19e7.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Regular.55fac258.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Caligraphic-Regular.dad44a7f.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Bold.9f256b85.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Bold.b18f59e1.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Bold.d42a5579.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Regular.7c187121.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Regular.d3c882a6.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Fraktur-Regular.ed38e79f.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Bold.b74a1a8b.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Bold.c3fb5ac2.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Bold.d181c465.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-BoldItalic.6f2bb1df.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-BoldItalic.70d8b0a5.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-BoldItalic.e3f82f9d.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Italic.47373d1e.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Italic.8916142b.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Italic.9024d815.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Regular.0462f03b.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Regular.7f51fe03.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Main-Regular.b7f8fe9b.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-BoldItalic.572d331f.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-BoldItalic.a879cf83.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-BoldItalic.f1035d8d.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-Italic.5295ba48.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-Italic.939bc644.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Math-Italic.f28c23ac.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Bold.8c5b5494.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Bold.94e1e8dc.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Bold.bf59d231.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Italic.3b1e59b3.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Italic.7c9bc82b.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Italic.b4c20c84.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Regular.74048478.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Regular.ba21ed5f.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_SansSerif-Regular.d4d7ba48.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Script-Regular.03e9641d.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Script-Regular.07505710.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Script-Regular.fe9cbbe1.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Size1-Regular.e1e279cb.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Size1-Regular.eae34984.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Size1-Regular.fabc004a.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Size2-Regular.57727022.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Size2-Regular.5916a24f.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Size2-Regular.d6b476ec.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Size3-Regular.9acaf01c.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Size3-Regular.a144ef58.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Size3-Regular.b4230e7e.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Size4-Regular.10d95fd3.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Size4-Regular.7a996c9d.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Size4-Regular.fbccdabe.ttf'},{'revision':null,'url':'/_next/static/media/KaTeX_Typewriter-Regular.6258592b.woff'},{'revision':null,'url':'/_next/static/media/KaTeX_Typewriter-Regular.a8709e36.woff2'},{'revision':null,'url':'/_next/static/media/KaTeX_Typewriter-Regular.d97aaf4a.ttf'},{'revision':'d3aa06d13d3cf9c0558927051f3cb948','url':'/_next/static/media/a1386beebedccca4-s.woff2'},{'revision':'0bd523f6049956faaf43c254a719d06a','url':'/_next/static/media/b957ea75a84b6ea7-s.p.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'5a1b7c983a9dc0a87a2ff138e07ae822','url':'/_next/static/media/c3bc380753a8436c-s.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'9516f567cd80b0f418bba2f1299ed6d1','url':'/_next/static/media/db911767852bc875-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'43751174b6b810eb169101a20d8c26f8','url':'/_next/static/media/eafabf029ad39a43-s.p.woff2'},{'revision':'63af7d5e18e585fad8d0220e5d551da1','url':'/_next/static/media/f10b8e9d91f3edcb-s.woff2'},{'revision':'f2a04185547c36abfa589651236a9849','url':'/_next/static/media/fe0777f1195381cb-s.woff2'},{'revision':'460f55cf1456f5a201f259a4eb607989','url':'/icons/apple-touch-icon.png'},{'revision':'69784622e4b55d2666c5180fb954d781','url':'/icons/icon-192x192.png'},{'revision':'ab07c93bfbd862c758c4d6d31085a6db','url':'/icons/icon-512x512-maskable.png'},{'revision':'f1b0e1527b676413a8b8e0154f671984','url':'/icons/icon-512x512.png'},{'revision':'2ed79e1d8607156994a2a6462563d7f0','url':'/images/Q zoom.json'},{'revision':'d088f99e3a3f1b7eaec384f36b0866d2','url':'/images/Q.json'},{'revision':'b14dbca44bb366d8499ae9a9b24a104f','url':'/images/eva-logo.png'},{'revision':'8f6cf4c9ec412fe201f80e2d23445aa9','url':'/images/logo.svg'},{'revision':'ab57101a81d25f409febeed6eb7e32bf','url':'/images/opengraph-image.png'}],skipWaiting:!1,clientsClaim:!1,navigationPreload:!1,runtimeCaching:[{matcher:/\.(?:js|css|woff2?|png|jpg|jpeg|svg|gif|ico)$/,handler:new eL({cacheName:"static-assets"})},{matcher:e=>{let{url:t,request:a}=e,r=t.pathname.startsWith("/api/"),s="/api/chat"===t.pathname&&"POST"===a.method||"/api/chats/all"===t.pathname&&"DELETE"===a.method;return r&&!s},handler:new ea({cacheName:"api-cache",networkTimeoutSeconds:5})},{matcher:e=>{let{request:t}=e;return"navigate"===t.mode},handler:new ea({cacheName:"pages-cache",networkTimeoutSeconds:5,plugins:[{handlerDidError:async()=>caches.match("/offline")}]})}]}).addEventListeners(),self.addEventListener("push",e=>{console.log("[Service Worker] Push Received.",e)}),self.addEventListener("sync",e=>{console.log("[Service Worker] Background Sync.",e)})})(); \ No newline at end of file diff --git a/tests-unit/encryption.test.ts b/tests-unit/encryption.test.ts new file mode 100644 index 00000000..80da71c7 --- /dev/null +++ b/tests-unit/encryption.test.ts @@ -0,0 +1,55 @@ +import { encrypt, decrypt } from '../lib/utils/encryption'; + +console.log("Starting encryption tests..."); + +// Test basic encrypt/decrypt +const originalText = "Hello, GCM authenticated encryption!"; +const encrypted = encrypt(originalText); + +if (!encrypted) { + throw new Error("Encryption failed: returned null"); +} + +console.log("Encrypted Text:", encrypted); + +const parts = encrypted.split(':'); +if (parts.length !== 3) { + throw new Error(`Encrypted text does not have 3 parts separated by colons. Got: ${parts.length}`); +} + +const decrypted = decrypt(encrypted); +if (decrypted !== originalText) { + throw new Error(`Decryption failed! Expected: "${originalText}", got: "${decrypted}"`); +} +console.log("Decryption verified successfully!"); + +// Test integrity protection (GCM authentication tag verification) +// Modify one byte of the ciphertext +const [iv, ciphertext, tag] = parts; +const modifiedCiphertext = ciphertext.substring(0, ciphertext.length - 2) + (ciphertext.endsWith('0') ? '1' : '0'); +const modifiedEncrypted = `${iv}:${modifiedCiphertext}:${tag}`; + +// Decrypting tampered or invalid hex string might log an error and return null. +// We disable console.error temporarily to keep the test logs clean. +const originalConsoleError = console.error; +console.error = () => {}; +try { + const decryptedModified = decrypt(modifiedEncrypted); + if (decryptedModified !== null) { + throw new Error("Decryption of modified ciphertext did not return null! GCM authentication tag failed to catch tamper."); + } +} finally { + console.error = originalConsoleError; +} +console.log("GCM Authentication tag verification passed!"); + +// Test null/undefined handling +if (encrypt(null) !== null || encrypt(undefined) !== null) { + throw new Error("encrypt(null/undefined) did not return null"); +} +if (decrypt(null) !== null || decrypt(undefined) !== null) { + throw new Error("decrypt(null/undefined) did not return null"); +} +console.log("Null/undefined handling passed!"); + +console.log("All encryption tests passed!"); From 33fcb76d2fa7fdfc9ce1f8310c018b0ac76a670d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:49:43 +0000 Subject: [PATCH 06/11] Dynamically resolve redirect URI for SkyFi OAuth and address PR feedback - Update startSkyfiConnection() and skyfiTool to dynamically resolve redirectUri from the incoming request headers using next/headers, making it highly robust against mismatches in localhost, preview, or production domains. - Ensure type safety and compilability are maintained. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- lib/actions/skyfi.ts | 11 +++++++++++ lib/agents/tools/skyfi.tsx | 15 +++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/lib/actions/skyfi.ts b/lib/actions/skyfi.ts index 476c9f48..26eb980e 100644 --- a/lib/actions/skyfi.ts +++ b/lib/actions/skyfi.ts @@ -8,8 +8,19 @@ import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; import { SkyfiOAuthProvider } from '@/lib/skyfi/provider'; import crypto from 'crypto'; import { redirect } from 'next/navigation'; +import { headers } from 'next/headers'; export async function getRedirectUri(): Promise { + try { + const headersList = await headers(); + const host = headersList.get('host'); + if (host) { + const protocol = host.startsWith('localhost') || host.startsWith('127.0.0.1') ? 'http' : 'https'; + return `${protocol}://${host}/api/skyfi/callback`; + } + } catch (e) { + console.warn('[Skyfi] Failed to get host from headers, falling back to ENV:', e); + } const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; return `${baseUrl}/api/skyfi/callback`; } diff --git a/lib/agents/tools/skyfi.tsx b/lib/agents/tools/skyfi.tsx index 183010e2..c8264b34 100644 --- a/lib/agents/tools/skyfi.tsx +++ b/lib/agents/tools/skyfi.tsx @@ -11,10 +11,21 @@ import { skyfiQuerySchema } from '@/lib/schema/skyfi'; import { DrawnFeature } from '@/lib/agents/resolution-search'; import { z } from 'zod'; import crypto from 'crypto'; +import { headers } from 'next/headers'; export type McpClient = MCPClientClass; -function getRedirectUri(): string { +async function getRedirectUri(): Promise { + try { + const headersList = await headers(); + const host = headersList.get('host'); + if (host) { + const protocol = host.startsWith('localhost') || host.startsWith('127.0.0.1') ? 'http' : 'https'; + return `${protocol}://${host}/api/skyfi/callback`; + } + } catch (e) { + console.warn('[SkyfiTool] Failed to get host from headers, falling back to ENV:', e); + } const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; return `${baseUrl}/api/skyfi/callback`; } @@ -143,7 +154,7 @@ export const skyfiTool = ({ } // Resolve OAuth tokens - const redirectUri = getRedirectUri(); + const redirectUri = await getRedirectUri(); const provider = new SkyfiOAuthProvider(userId, redirectUri); const tokens = await provider.tokens(); From bb90e0d309533d7d68aad7f5c651b25963e91d31 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:02:31 +0000 Subject: [PATCH 07/11] Add legacy CBC decryption backwards compatibility to encryption utility - Enable decrypt() to seamlessly detect and decrypt legacy 2-part AES-256-CBC payloads, resolving issues where pre-existing tokens could not be decrypted. - Add unit tests for legacy CBC backwards-compatibility. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- lib/utils/encryption.ts | 39 ++++++++++++++++++++++++----------- tests-unit/encryption.test.ts | 27 ++++++++++++++++++++---- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/lib/utils/encryption.ts b/lib/utils/encryption.ts index 4e17f9f9..34b3ed8d 100644 --- a/lib/utils/encryption.ts +++ b/lib/utils/encryption.ts @@ -1,6 +1,7 @@ import crypto from 'crypto'; -const ALGORITHM = 'aes-256-gcm'; +const ALGORITHM_GCM = 'aes-256-gcm'; +const ALGORITHM_CBC = 'aes-256-cbc'; if (!process.env.ENCRYPTION_KEY) { throw new Error('ENCRYPTION_KEY environment variable is not set'); @@ -12,7 +13,7 @@ export function encrypt(text: string | null | undefined): string | null { const iv = crypto.randomBytes(12); // Standard IV size for GCM is 12 bytes // Ensure the key is exactly 32 bytes const key = crypto.createHash('sha256').update(ENCRYPTION_KEY).digest(); - const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const cipher = crypto.createCipheriv(ALGORITHM_GCM, key, iv); let encrypted = cipher.update(text, 'utf8', 'hex'); encrypted += cipher.final('hex'); const tag = cipher.getAuthTag().toString('hex'); @@ -23,17 +24,31 @@ export function decrypt(encryptedText: string | null | undefined): string | null if (!encryptedText) return null; try { const parts = encryptedText.split(':'); - if (parts.length !== 3) return null; - const [ivHex, encrypted, tagHex] = parts; - if (!ivHex || !encrypted || !tagHex) return null; - const iv = Buffer.from(ivHex, 'hex'); - const tag = Buffer.from(tagHex, 'hex'); const key = crypto.createHash('sha256').update(ENCRYPTION_KEY).digest(); - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); - decipher.setAuthTag(tag); - let decrypted = decipher.update(encrypted, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - return decrypted; + + if (parts.length === 3) { + // New AES-256-GCM format + const [ivHex, encrypted, tagHex] = parts; + if (!ivHex || !encrypted || !tagHex) return null; + const iv = Buffer.from(ivHex, 'hex'); + const tag = Buffer.from(tagHex, 'hex'); + const decipher = crypto.createDecipheriv(ALGORITHM_GCM, key, iv); + decipher.setAuthTag(tag); + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } else if (parts.length === 2) { + // Legacy AES-256-CBC format + const [ivHex, encrypted] = parts; + if (!ivHex || !encrypted) return null; + const iv = Buffer.from(ivHex, 'hex'); + const decipher = crypto.createDecipheriv(ALGORITHM_CBC, key, iv); + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } + + return null; } catch (error) { console.error('[Encryption] Decryption failed:', error); return null; diff --git a/tests-unit/encryption.test.ts b/tests-unit/encryption.test.ts index 80da71c7..d5c1ca14 100644 --- a/tests-unit/encryption.test.ts +++ b/tests-unit/encryption.test.ts @@ -1,8 +1,11 @@ +import crypto from 'crypto'; import { encrypt, decrypt } from '../lib/utils/encryption'; console.log("Starting encryption tests..."); -// Test basic encrypt/decrypt +const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'my-super-secret-key-32-chars-long-or-more'; + +// Test basic GCM encrypt/decrypt const originalText = "Hello, GCM authenticated encryption!"; const encrypted = encrypt(originalText); @@ -10,7 +13,7 @@ if (!encrypted) { throw new Error("Encryption failed: returned null"); } -console.log("Encrypted Text:", encrypted); +console.log("Encrypted GCM Text:", encrypted); const parts = encrypted.split(':'); if (parts.length !== 3) { @@ -19,9 +22,25 @@ if (parts.length !== 3) { const decrypted = decrypt(encrypted); if (decrypted !== originalText) { - throw new Error(`Decryption failed! Expected: "${originalText}", got: "${decrypted}"`); + throw new Error(`GCM Decryption failed! Expected: "${originalText}", got: "${decrypted}"`); +} +console.log("GCM Decryption verified successfully!"); + +// Test legacy CBC decryption backwards compatibility +const legacyText = "Hello, legacy CBC encryption!"; +const ivCbc = crypto.randomBytes(16); +const key = crypto.createHash('sha256').update(ENCRYPTION_KEY).digest(); +const cipherCbc = crypto.createCipheriv('aes-256-cbc', key, ivCbc); +let encryptedCbc = cipherCbc.update(legacyText, 'utf8', 'hex'); +encryptedCbc += cipherCbc.final('hex'); +const legacyPayload = `${ivCbc.toString('hex')}:${encryptedCbc}`; + +console.log("Legacy CBC payload:", legacyPayload); +const decryptedLegacy = decrypt(legacyPayload); +if (decryptedLegacy !== legacyText) { + throw new Error(`Legacy CBC Decryption failed! Expected: "${legacyText}", got: "${decryptedLegacy}"`); } -console.log("Decryption verified successfully!"); +console.log("Legacy CBC Decryption backwards compatibility verified successfully!"); // Test integrity protection (GCM authentication tag verification) // Modify one byte of the ciphertext From b72234468efef334ea49a0b45b8e8ec4fccfc3a0 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:09:54 +0000 Subject: [PATCH 08/11] Force client re-registration during SkyFi connection initiation - Support forceRegister in ensureClientRegistered(). - Force client re-registration in startSkyfiConnection() to guarantee that client ID redirect URIs always match the current domain dynamically, preventing SkyFi authorization redirect_uri mismatch errors. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- lib/actions/skyfi.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/actions/skyfi.ts b/lib/actions/skyfi.ts index 26eb980e..be3fc5f7 100644 --- a/lib/actions/skyfi.ts +++ b/lib/actions/skyfi.ts @@ -29,13 +29,15 @@ export async function getRedirectUri(): Promise { * Ensures the client is registered dynamically with the SkyFi MCP server. * Uses an AbortController with a 10s timeout to prevent hanging. */ -async function ensureClientRegistered(provider: SkyfiOAuthProvider): Promise { - const currentInfo = await provider.clientInformation(); - if (currentInfo?.client_id) { - return currentInfo.client_id; +async function ensureClientRegistered(provider: SkyfiOAuthProvider, forceRegister: boolean = false): Promise { + if (!forceRegister) { + const currentInfo = await provider.clientInformation(); + if (currentInfo?.client_id) { + return currentInfo.client_id; + } } - console.log('[SkyFiAction] Client not registered. Registering dynamically...'); + console.log('[SkyFiAction] Client not registered or force-register active. Registering dynamically...'); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); @@ -89,7 +91,7 @@ export async function startSkyfiConnection(): Promise<{ url?: string; error?: st const redirectUri = await getRedirectUri(); const provider = new SkyfiOAuthProvider(userId, redirectUri); - const clientId = await ensureClientRegistered(provider); + const clientId = await ensureClientRegistered(provider, true); const verifier = generateCodeVerifier(); const challenge = generateCodeChallenge(verifier); From a9b2d3145022fa67e173a15b3cc4e840e6e5c9be Mon Sep 17 00:00:00 2001 From: Manus Date: Fri, 24 Jul 2026 11:39:29 +0000 Subject: [PATCH 09/11] fix: route unauthenticated users to SkyFi sign-in page for MCP connection When an unauthenticated user attempts to connect SkyFi, the server action previously called redirect('/sign-in') which fails from a server action context. Now startSkyfiConnection returns an authRequired flag, and the client-side handler redirects to /sign-in with the current page as redirect_url, leveraging the existing sign-in redirect pattern. This ensures users who aren't logged in are properly sent through the Clerk sign-in flow and returned to settings after authentication, so they can complete the SkyFi OAuth connection. --- components/settings/components/tool-selection-form.tsx | 4 ++++ lib/actions/skyfi.ts | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/components/settings/components/tool-selection-form.tsx b/components/settings/components/tool-selection-form.tsx index 3ac3905c..2fcf440e 100644 --- a/components/settings/components/tool-selection-form.tsx +++ b/components/settings/components/tool-selection-form.tsx @@ -235,6 +235,10 @@ export function ToolSelectionForm({ form }: ToolSelectionFormProps) { setIsConnecting(true); try { const res = await startSkyfiConnection(); + if (res.authRequired) { + window.location.href = `/sign-in?redirect_url=${encodeURIComponent(window.location.href)}`; + return; + } if (res.error) { throw new Error(res.error); } diff --git a/lib/actions/skyfi.ts b/lib/actions/skyfi.ts index be3fc5f7..8bc5cd3c 100644 --- a/lib/actions/skyfi.ts +++ b/lib/actions/skyfi.ts @@ -7,7 +7,7 @@ import { eq } from 'drizzle-orm'; import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; import { SkyfiOAuthProvider } from '@/lib/skyfi/provider'; import crypto from 'crypto'; -import { redirect } from 'next/navigation'; + import { headers } from 'next/headers'; export async function getRedirectUri(): Promise { @@ -81,10 +81,10 @@ async function ensureClientRegistered(provider: SkyfiOAuthProvider, forceRegiste * Starts the SkyFi OAuth connection flow. * Performs DCR, generates PKCE, and returns the authorization URL. */ -export async function startSkyfiConnection(): Promise<{ url?: string; error?: string }> { +export async function startSkyfiConnection(): Promise<{ url?: string; error?: string; authRequired?: boolean }> { const userId = await getCurrentUserIdOnServer(); if (!userId) { - redirect('/sign-in'); + return { authRequired: true, error: 'Please sign in to connect your SkyFi account.' }; } try { From 86be8667afae47ff80e83f7307f5f35a60d39560 Mon Sep 17 00:00:00 2001 From: Manus Date: Fri, 24 Jul 2026 11:47:40 +0000 Subject: [PATCH 10/11] fix: remove Discord account tab and button from Settings Remove the entire Account tab from the settings page, including the Discord linking card, connect/disconnect buttons, and the tab trigger. Clean up the now-unused useUser import and clerkUser variable. --- components/settings/components/settings.tsx | 88 --------------------- 1 file changed, 88 deletions(-) diff --git a/components/settings/components/settings.tsx b/components/settings/components/settings.tsx index 1a3f7904..863b181f 100644 --- a/components/settings/components/settings.tsx +++ b/components/settings/components/settings.tsx @@ -25,7 +25,6 @@ import { getSystemPrompt, saveSystemPrompt } from "../../../lib/actions/chat" import { getSelectedModel, saveSelectedModel } from "../../../lib/actions/users" import { useCurrentUser } from "@/lib/auth/use-current-user" import { SettingsSkeleton } from './settings-skeleton' -import { useUser } from '@clerk/nextjs' // Define the form schema with enum validation for roles const settingsFormSchema = z.object({ @@ -74,7 +73,6 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { const [currentTab, setCurrentTab] = useState(initialTab === "model" ? "tool" : initialTab); const { mapProvider, setMapProvider } = useSettingsStore(); const { user, loading: authLoading } = useCurrentUser(); - const { user: clerkUser } = useUser(); const { theme, setTheme } = useTheme() const [mounted, setMounted] = useState(false) @@ -217,7 +215,6 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { Tools Users Map - Account - - - - Discord Account Linking - Link your Discord account to QCX to access bot features and server sync. - - - {clerkUser?.externalAccounts.find(acc => acc.provider === 'discord') ? ( - (() => { - const discordAccount = clerkUser.externalAccounts.find(acc => acc.provider === 'discord')!; - return ( -
-
- {discordAccount.imageUrl ? ( - Discord Avatar - ) : ( - D - )} -
-
-

Connected as {discordAccount.username || 'Discord User'}

-

ID: {discordAccount.providerUserId}

-
- -
- ) - })() - ) : ( -
-

- You haven't connected your Discord account yet. Link it now to connect your SaaS profile. -

- -
- )} -
-
-
From fc7b417fa4ede644303eb8027f9ead2d80e940ce Mon Sep 17 00:00:00 2001 From: Manus Date: Fri, 24 Jul 2026 11:52:36 +0000 Subject: [PATCH 11/11] fix: hide Discord Account tab from Settings UI while preserving backend functionality The Discord account linking functionality (connect/disconnect via Clerk OAuth) must remain available in the backend and in the tab content for programmatic or background invocation. Only the visible tab trigger button is hidden (className="hidden", disabled) with a clear comment warning future agents not to re-add a visible Discord button to the Settings UI. --- components/settings/components/settings.tsx | 98 +++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/components/settings/components/settings.tsx b/components/settings/components/settings.tsx index 863b181f..08c2a5f7 100644 --- a/components/settings/components/settings.tsx +++ b/components/settings/components/settings.tsx @@ -25,6 +25,7 @@ import { getSystemPrompt, saveSystemPrompt } from "../../../lib/actions/chat" import { getSelectedModel, saveSelectedModel } from "../../../lib/actions/users" import { useCurrentUser } from "@/lib/auth/use-current-user" import { SettingsSkeleton } from './settings-skeleton' +import { useUser } from '@clerk/nextjs' // Define the form schema with enum validation for roles const settingsFormSchema = z.object({ @@ -73,6 +74,7 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { const [currentTab, setCurrentTab] = useState(initialTab === "model" ? "tool" : initialTab); const { mapProvider, setMapProvider } = useSettingsStore(); const { user, loading: authLoading } = useCurrentUser(); + const { user: clerkUser } = useUser(); const { theme, setTheme } = useTheme() const [mounted, setMounted] = useState(false) @@ -215,6 +217,17 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { Tools Users Map + {/* + ACCOUNT TAB — DO NOT ADD A UI BUTTON FOR DISCORD HERE. + Discord connect/disconnect is managed entirely in the backend via + Clerk OAuth (`createExternalAccount` / `destroy`). It is invoked + programmatically when needed (e.g. from bot commands or API flows) + and must NOT be exposed as a visible button or tab in the Settings UI. + Removing this trigger hides the tab from the UI while preserving + the full Discord linking functionality (card, connect, disconnect) + below for future programmatic or background use. + */} + + + + + Discord Account Linking + Link your Discord account to QCX to access bot features and server sync. + + + {clerkUser?.externalAccounts.find(acc => acc.provider === 'discord') ? ( + (() => { + const discordAccount = clerkUser.externalAccounts.find(acc => acc.provider === 'discord')!; + return ( +
+
+ {discordAccount.imageUrl ? ( + Discord Avatar + ) : ( + D + )} +
+
+

Connected as {discordAccount.username || 'Discord User'}

+

ID: {discordAccount.providerUserId}

+
+ +
+ ) + })() + ) : ( +
+

+ You haven't connected your Discord account yet. Link it now to connect your SaaS profile. +

+ +
+ )} +
+
+