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..f6847bcf --- /dev/null +++ b/app/api/skyfi/callback/route.ts @@ -0,0 +1,101 @@ +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'; + +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 redirectUri = await getRedirectUri(); + 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 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)); + + } 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); + 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 53d5ea73..a8a922e7 100644 --- a/components/settings/components/settings.tsx +++ b/components/settings/components/settings.tsx @@ -217,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. + */} + Account ; @@ -45,6 +50,32 @@ 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); + const [isDisconnecting, setIsDisconnecting] = 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. + )} + + { + setIsDisconnecting(true); + try { + const res = await disconnectSkyfi(); + if (res.success) { + setSkyfiConnected(false); + setSkyfiBudget(null); + toast({ + title: "SkyFi Disconnected", + description: "Your SkyFi account has been disconnected.", + }); + } else { + throw new Error(res.error); + } + } catch (err: any) { + toast({ + title: "Disconnection Error", + description: err.message || "Failed to disconnect SkyFi.", + variant: "destructive", + }); + } finally { + setIsDisconnecting(false); + } + }} + > + {isDisconnecting ? ( + <> + + Disconnecting... + > + ) : ( + "Disconnect" + )} + + + + ) : ( + + + Connect your SkyFi account to search existing satellite imagery, task new captures, and manage Areas of Interest (AOIs) directly from your chats. + + { + 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); + } + if (res.url) { + window.location.href = res.url; + } + } catch (err: any) { + toast({ + title: "Connection Error", + description: err.message || "Failed to initiate SkyFi connection.", + variant: "destructive", + }); + setIsConnecting(false); + } + }} + > + {isConnecting ? ( + <> + + Initiating OAuth... + > + ) : ( + <> + + Connect SkyFi Account + > + )} + + + )} + + + )} )} /> diff --git a/components/skyfi-section.tsx b/components/skyfi-section.tsx new file mode 100644 index 00000000..69b56ad4 --- /dev/null +++ b/components/skyfi-section.tsx @@ -0,0 +1,82 @@ +'use client' + +import { Section } from './section' +import { ToolBadge } from './tool-badge' +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' +import { cn } from '@/lib/utils' + +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 + } + } + + // Handle stream error explicitly + if (error) { + return ( + + + {`SkyFi MCP Error`} + + + + {(error as any).message || String(error)} + + + + ) + } + + return ( + + {!pending && data ? ( + <> + + {`SkyFi MCP: ${queryType}`} + + + + + {resultText} + + + + > + ) : ( + + + + )} + + ) +} 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..c3d70384 --- /dev/null +++ b/drizzle/migrations/0009_add_skyfi_oauth_tokens.sql @@ -0,0 +1,18 @@ +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, + "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") +); +--> 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..8bc5cd3c --- /dev/null +++ b/lib/actions/skyfi.ts @@ -0,0 +1,207 @@ +'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'; + +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`; +} + +/** + * 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, 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 or force-register active. Registering dynamically...'); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => 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()}`); + } + + 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; + } +} + +/** + * Starts the SkyFi OAuth connection flow. + * Performs DCR, generates PKCE, and returns the authorization URL. + */ +export async function startSkyfiConnection(): Promise<{ url?: string; error?: string; authRequired?: boolean }> { + const userId = await getCurrentUserIdOnServer(); + if (!userId) { + return { authRequired: true, error: 'Please sign in to connect your SkyFi account.' }; + } + + try { + const redirectUri = await getRedirectUri(); + const provider = new SkyfiOAuthProvider(userId, redirectUri); + + const clientId = await ensureClientRegistered(provider, true); + + 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. + * Uses an AbortController with a 10s timeout to prevent hanging. + */ +export async function getSkyfiConnectionStatus(): Promise<{ connected: boolean; email?: string; budget?: string }> { + try { + const userId = await getCurrentUserIdOnServer(); + if (!userId) { + return { connected: false }; + } + + const redirectUri = await getRedirectUri(); + const provider = new SkyfiOAuthProvider(userId, redirectUri); + const tokens = await provider.tokens(); + + if (!tokens || !tokens.access_token) { + return { connected: false }; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + 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 }; + } 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.' }; + } +} + +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/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..90f2da7c 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, @@ -29,6 +33,13 @@ export const getTools = ({ uiStream, fullResponse, mapProvider }: ToolProps) => }) } + if (selectedModel === 'SkyFi') { + tools.skyfiQueryTool = skyfiTool({ + uiStream, + drawnFeatures + }) + } + if (process.env.TAVILY_API_KEY) { tools.search = searchTool({ uiStream, diff --git a/lib/agents/tools/skyfi.tsx b/lib/agents/tools/skyfi.tsx new file mode 100644 index 00000000..c8264b34 --- /dev/null +++ b/lib/agents/tools/skyfi.tsx @@ -0,0 +1,363 @@ +/** + * 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'; +import crypto from 'crypto'; +import { headers } from 'next/headers'; + +export type McpClient = MCPClientClass; + +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`; +} + +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}))`; + } + 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. + */ +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 and abort signal + let transport; + try { + transport = new StreamableHTTPClientTransport(serverUrl, { + requestInit: { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'X-Skyfi-Api-Key': accessToken, + }, + signal, + } + }); + 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, orderId, params: extraParams, maxResults } = params; + console.log('[SkyfiTool] Execute called for queryType:', queryType, { maxResults, confirm: params.confirm }); + + 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 = await 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); + + // 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(); + uiStream.update(); + 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); + + // 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; + } + + 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); + await closeClient(mcpClient); + 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); + } + } + + // 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(hashInput) + .digest('hex'); + + 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 = { + maxResults: maxResults || 5, + ...extraParams, + aoi: resolvedAoiWkt || extraParams?.aoi + }; + break; + case 'validate_order': + mcpToolName = 'skyfi_validate_archive_order'; + mcpArgs = { + ...extraParams, + ...(orderId ? { orderId } : {}), + aoi: resolvedAoiWkt || extraParams?.aoi + }; + break; + case 'place_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 = { + ...extraParams, + ...(orderId ? { orderId } : {}), + idempotencyKey: stableIdempotencyKey, + aoi: resolvedAoiWkt || extraParams?.aoi + }; + break; + case 'list_orders': + mcpToolName = 'skyfi_list_orders'; + mcpArgs = { + ...extraParams, + ...(orderId ? { orderId } : {}) + }; + 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 } + }, undefined, { signal: controller.signal }); + + 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) { + 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); + + const mcpResult = await mcpClient.callTool({ + name: mcpToolName, + arguments: mcpArgs + }, undefined, { signal: controller.signal }); + + clearTimeout(timeoutId); + + 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); + + 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); + + await closeClient(mcpClient); + uiFeedbackStream.done(); + uiStream.update(); + + return { + type: 'SKYFI_QUERY', + success: true, + queryType: dryRunValidateOnly ? 'validate_order' : queryType, + result: rawText, + error: null, + confirmationRequired: dryRunValidateOnly + }; + } catch (error: any) { + 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); + 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..858888c4 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -130,8 +130,24 @@ 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'), + 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(), +}); + // 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 +157,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..e76ca333 --- /dev/null +++ b/lib/schema/skyfi.tsx @@ -0,0 +1,43 @@ +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"), + 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 new file mode 100644 index 00000000..a728f696 --- /dev/null +++ b/lib/skyfi/provider.ts @@ -0,0 +1,238 @@ +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; + 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: decrypt(row.clientSecret) || undefined, + registration_client_uri: row.registrationClientUri || undefined, + registration_access_token: decrypt(row.registrationAccessToken) || undefined, + }; + } + return undefined; + } + + async saveClientInformation(clientInformation: OAuthClientInformationMixed): Promise { + 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: encrypt(clientInformation.client_secret || null), + registrationClientUri: clientInformation.registration_client_uri || null, + registrationAccessToken: encrypt(clientInformation.registration_access_token || null), + updatedAt: new Date(), + } + }); + } + + async tokens(): Promise { + const [row] = await db.select() + .from(skyfiOAuthTokens) + .where(eq(skyfiOAuthTokens.userId, this.userId)) + .limit(1); + + if (!row || !row.accessToken) { + 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 && decryptedRefreshToken && 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: decryptedRefreshToken, + }), + }); + + 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 || decryptedRefreshToken, + 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: decryptedAccessToken, + refresh_token: decryptedRefreshToken || undefined, + expires_at: expiresAt || undefined, + }; + } + + async saveTokens(tokens: OAuthTokens): Promise { + const tokenExpiryDate = tokens.expires_at ? new Date(tokens.expires_at * 1000) : 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(), + } + }); + } + + async saveCodeVerifier(codeVerifier: string): Promise { + await db.insert(skyfiOAuthTokens) + .values({ + userId: this.userId, + codeVerifier, + }) + .onConflictDoUpdate({ + target: skyfiOAuthTokens.userId, + set: { + codeVerifier, + updatedAt: new Date(), + } + }); + } + + 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 { + await db.insert(skyfiOAuthTokens) + .values({ + userId: this.userId, + state, + }) + .onConflictDoUpdate({ + target: skyfiOAuthTokens.userId, + set: { + state, + updatedAt: new Date(), + } + }); + } + + 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 { + // Handled transiently + } +} diff --git a/lib/utils/encryption.ts b/lib/utils/encryption.ts new file mode 100644 index 00000000..34b3ed8d --- /dev/null +++ b/lib/utils/encryption.ts @@ -0,0 +1,56 @@ +import crypto from 'crypto'; + +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'); +} +const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY; + +export function encrypt(text: string | null | undefined): string | null { + if (!text) return 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_GCM, key, iv); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + 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 parts = encryptedText.split(':'); + const key = crypto.createHash('sha256').update(ENCRYPTION_KEY).digest(); + + 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/public/sw.js b/public/sw.js index 6a92dcb8..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':'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':'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..d5c1ca14 --- /dev/null +++ b/tests-unit/encryption.test.ts @@ -0,0 +1,74 @@ +import crypto from 'crypto'; +import { encrypt, decrypt } from '../lib/utils/encryption'; + +console.log("Starting encryption tests..."); + +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); + +if (!encrypted) { + throw new Error("Encryption failed: returned null"); +} + +console.log("Encrypted GCM 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(`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("Legacy CBC Decryption backwards compatibility 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!");
SkyFi Account Linked
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. +