diff --git a/.env.embeddings.example b/.env.embeddings.example index dec0c883..5d0eafd6 100644 --- a/.env.embeddings.example +++ b/.env.embeddings.example @@ -11,3 +11,6 @@ GCP_CREDENTIALS_PATH=/path/to/your/gcp_credentials.json # Optional: Path to the AlphaEarth index file # Defaults to ./aef_index.csv if not specified AEF_INDEX_PATH=./aef_index.csv + +# Firecrawl API Key for business domain scraping features +# FIRECRAWL_API_KEY=your_firecrawl_api_key diff --git a/.env.local.example b/.env.local.example index 61e6578e..0ff92ba0 100644 --- a/.env.local.example +++ b/.env.local.example @@ -36,8 +36,3 @@ 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 c9f8ecfd..a14b3c1a 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -21,7 +21,6 @@ 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' @@ -854,12 +853,6 @@ 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 deleted file mode 100644 index f6847bcf..00000000 --- a/app/api/skyfi/callback/route.ts +++ /dev/null @@ -1,101 +0,0 @@ -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/profile-toggle.tsx b/components/profile-toggle.tsx index b3f73389..df533960 100644 --- a/components/profile-toggle.tsx +++ b/components/profile-toggle.tsx @@ -1,5 +1,5 @@ 'use client' -import { Settings, Shield, CircleUserRound, LogOut } from "lucide-react" +import { User, Settings, Shield, CircleUserRound, LogOut } from "lucide-react" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Button } from "@/components/ui/button" import { ProfileToggleEnum, useProfileToggle } from "./profile-toggle-context" @@ -57,6 +57,10 @@ export function ProfileToggle() { + handleSectionToggle(ProfileToggleEnum.Account)} data-testid="profile-account"> + + Account + handleSectionToggle(ProfileToggleEnum.Settings)} data-testid="profile-settings"> Settings diff --git a/components/settings/components/settings.tsx b/components/settings/components/settings.tsx index a8a922e7..4bfa6817 100644 --- a/components/settings/components/settings.tsx +++ b/components/settings/components/settings.tsx @@ -21,7 +21,7 @@ import { useSettingsStore, MapProvider } from "@/lib/store/settings"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Label } from "@/components/ui/label"; import { useToast } from "@/components/ui/hooks/use-toast" -import { getSystemPrompt, saveSystemPrompt } from "../../../lib/actions/chat" +import { getSystemPrompt, saveSystemPrompt, getUserDomain, saveUserDomain } from "../../../lib/actions/chat" import { getSelectedModel, saveSelectedModel } from "../../../lib/actions/users" import { useCurrentUser } from "@/lib/auth/use-current-user" import { SettingsSkeleton } from './settings-skeleton' @@ -49,7 +49,34 @@ const settingsFormSchema = z.object({ ), newUserEmail: z.string().email().optional().or(z.literal('')), newUserRole: z.enum(["admin", "editor", "viewer"]).optional(), - domain: z.string().url().optional().or(z.literal('')), + domain: z.string().optional().or(z.literal('')).refine((val) => { + if (!val) return true; + try { + const url = val.startsWith('http') ? val : `https://${val}`; + const parsed = new URL(url); + const hostname = parsed.hostname; + + // Explicitly reject localhost and internal/local hostnames + if (hostname === 'localhost') return false; + if (hostname.endsWith('.local') || hostname.endsWith('.internal')) return false; + + // Check private IP ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, and 127.0.0.1 + const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + const match = hostname.match(ipv4Regex); + if (match) { + const octet1 = parseInt(match[1], 10); + const octet2 = parseInt(match[2], 10); + if (octet1 === 10) return false; + if (octet1 === 172 && octet2 >= 16 && octet2 <= 31) return false; + if (octet1 === 192 && octet2 === 168) return false; + if (octet1 === 127) return false; // Loopback + } + + return true; + } catch (e) { + return false; + } + }, { message: "Invalid domain or URL. Only public domains/URLs are allowed." }), }) export type SettingsFormValues = z.infer @@ -97,9 +124,10 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { async function fetchData() { if (!userId || authLoading) return; - const [existingPrompt, selectedModel] = await Promise.all([ + const [existingPrompt, selectedModel, existingDomain] = await Promise.all([ getSystemPrompt(userId), getSelectedModel(), + getUserDomain(userId), ]); if (existingPrompt) { @@ -108,6 +136,9 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { if (selectedModel) { form.setValue("selectedModel", selectedModel, { shouldValidate: true, shouldDirty: false }); } + if (existingDomain) { + form.setValue("domain", existingDomain, { shouldValidate: true, shouldDirty: false }); + } } fetchData(); }, [form, userId, authLoading]); @@ -129,10 +160,11 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { setIsSaving(true) try { - // Save the system prompt and selected model - const [promptSaveResult, modelSaveResult] = await Promise.all([ + // Save the system prompt, selected model, and domain + const [promptSaveResult, modelSaveResult, domainSaveResult] = await Promise.all([ saveSystemPrompt(userId, data.systemPrompt), saveSelectedModel(data.selectedModel), + saveUserDomain(userId, data.domain || ""), ]); if (promptSaveResult?.error) { @@ -141,6 +173,9 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { if (modelSaveResult?.error) { throw new Error(modelSaveResult.error); } + if (domainSaveResult?.error) { + throw new Error(domainSaveResult.error); + } console.log("Submitted data:", data) @@ -212,22 +247,12 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) { - + System 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 + + + + + Discord Account Linking + Link your Discord account to QCX to access bot features and server sync. + + + {clerkUser?.externalAccounts.find(acc => acc.provider === 'discord') ? ( + (() => { + const discordAccount = clerkUser.externalAccounts.find(acc => acc.provider === 'discord')!; + return ( +
+
+ {discordAccount.imageUrl ? ( + Discord Avatar + ) : ( + D + )} +
+
+

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

+

ID: {discordAccount.providerUserId}

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

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

+ +
+ )} +
+
+
diff --git a/components/settings/components/system-prompt-form.tsx b/components/settings/components/system-prompt-form.tsx index 41022b80..5334121d 100644 --- a/components/settings/components/system-prompt-form.tsx +++ b/components/settings/components/system-prompt-form.tsx @@ -45,49 +45,65 @@ export function SystemPromptForm({ form }: SystemPromptFormProps) { } } + const toastRef = React.useRef(toast) + useEffect(() => { + toastRef.current = toast + }, [toast]) + useEffect(() => { let interval: NodeJS.Timeout | null = null if (jobId) { const pollStartTime = Date.now() - const MAX_POLL_DURATION = 120000 // 2 minutes + const MAX_POLL_DURATION = 150000 // 2.5 minutes interval = setInterval(async () => { - const elapsed = Date.now() - pollStartTime - if (elapsed > MAX_POLL_DURATION) { - if (interval) clearInterval(interval) - setJobId(null) - setIsGenerating(false) - toast({ - title: "Generation timeout", - description: "The prompt generation took too long. Please try again.", - variant: "destructive", - }) - return - } + try { + const elapsed = Date.now() - pollStartTime + if (elapsed > MAX_POLL_DURATION) { + if (interval) clearInterval(interval) + setJobId(null) + setIsGenerating(false) + toastRef.current({ + title: "Generation timeout", + description: "The prompt generation took too long. Please try again.", + variant: "destructive", + }) + return + } - const job = await getSystemPromptGenerationJob(jobId) + const job = await getSystemPromptGenerationJob(jobId) - if (job.error || job.status === 'error') { + if (job.error || job.status === 'error') { + if (interval) clearInterval(interval) + setJobId(null) + setIsGenerating(false) + toastRef.current({ + title: "Generation error", + description: job.errorMessage || job.error || "An error occurred during generation.", + variant: "destructive", + }) + } else if (job.status === 'complete') { + if (interval) clearInterval(interval) + setJobId(null) + setIsGenerating(false) + if (job.resultPrompt) { + form.setValue("systemPrompt", job.resultPrompt, { shouldValidate: true, shouldDirty: true }) + toastRef.current({ + title: "Prompt generated", + description: "Your system prompt has been generated based on the domain content.", + }) + } + } + } catch (pollErr: any) { if (interval) clearInterval(interval) setJobId(null) setIsGenerating(false) - toast({ - title: "Generation error", - description: job.errorMessage || job.error || "An error occurred during generation.", + toastRef.current({ + title: "Connection error", + description: "Failed to check generation status. Please check your connection and try again.", variant: "destructive", }) - } else if (job.status === 'complete') { - if (interval) clearInterval(interval) - setJobId(null) - setIsGenerating(false) - if (job.resultPrompt) { - form.setValue("systemPrompt", job.resultPrompt, { shouldValidate: true, shouldDirty: true }) - toast({ - title: "Prompt generated", - description: "Your system prompt has been generated based on the domain content.", - }) - } } }, 3000) } @@ -95,7 +111,7 @@ export function SystemPromptForm({ form }: SystemPromptFormProps) { return () => { if (interval) clearInterval(interval) } - }, [jobId, form, toast]) + }, [jobId, form]) return (
diff --git a/components/settings/components/tool-selection-form.tsx b/components/settings/components/tool-selection-form.tsx index 2fcf440e..1eaabc6f 100644 --- a/components/settings/components/tool-selection-form.tsx +++ b/components/settings/components/tool-selection-form.tsx @@ -1,6 +1,5 @@ "use client"; -import { useState, useEffect } from "react"; import type { UseFormReturn } from "react-hook-form"; import { FormField, @@ -20,11 +19,7 @@ import { import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { startSkyfiConnection, getSkyfiConnectionStatus, disconnectSkyfi } from "@/lib/actions/skyfi"; -import { Earth, Orbit, ShieldCheck, Loader2 } from "lucide-react"; -import { useToast } from "@/components/ui/hooks/use-toast"; -import { cn } from "@/lib/utils"; +import { Earth, Orbit } from "lucide-react"; interface ToolSelectionFormProps { form: UseFormReturn; @@ -50,32 +45,6 @@ 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.

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

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

- -
- )} -
-
- )} )} /> diff --git a/components/skyfi-section.tsx b/components/skyfi-section.tsx deleted file mode 100644 index 69b56ad4..00000000 --- a/components/skyfi-section.tsx +++ /dev/null @@ -1,82 +0,0 @@ -'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 eb39af41..f2a22c10 100644 --- a/components/tool-badge.tsx +++ b/components/tool-badge.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Search, Orbit } from 'lucide-react' +import { Search } from 'lucide-react' import { Badge } from './ui/badge' type ToolBadgeProps = { @@ -14,8 +14,7 @@ export const ToolBadge: React.FC = ({ className }) => { const icon: Record = { - search: , - skyfiQueryTool: + search: } return ( diff --git a/drizzle/migrations/0009_add_skyfi_oauth_tokens.sql b/drizzle/migrations/0009_add_skyfi_oauth_tokens.sql deleted file mode 100644 index c3d70384..00000000 --- a/drizzle/migrations/0009_add_skyfi_oauth_tokens.sql +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 068620d1..00000000 --- a/drizzle/migrations/meta/0006_snapshot.json +++ /dev/null @@ -1,1081 +0,0 @@ -{ - "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 d416fd22..ee11d5f4 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -43,34 +43,6 @@ "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/chat.ts b/lib/actions/chat.ts index b14de1ed..2ca37a6b 100644 --- a/lib/actions/chat.ts +++ b/lib/actions/chat.ts @@ -85,6 +85,12 @@ export async function getCrossSessionContext(userId?: string): Promise { export async function generateReportContext(messages: AIMessage[]) { try { + const userId = await getCurrentUserIdOnServer() + const [systemPrompt, domain] = await Promise.all([ + userId ? getSystemPrompt(userId) : null, + userId ? getUserDomain(userId) : null + ]) + const crossSessionContext = await getCrossSessionContext() const activeMessages = messages @@ -109,8 +115,8 @@ export async function generateReportContext(messages: AIMessage[]) { const strategicContent = activeMessages.filter(msg => msg.role === 'assistant') const [execSummary, strategicSynthesis] = await Promise.all([ - executiveSummaryAgent(crossSessionContext, activeMessages), - strategicSynthesisAgent(sensorFusionFindings, strategicContent) + executiveSummaryAgent(crossSessionContext, activeMessages, systemPrompt, domain), + strategicSynthesisAgent(sensorFusionFindings, strategicContent, systemPrompt, domain) ]) return { @@ -156,6 +162,54 @@ export async function getChat(id: string, userId: string): Promise { + if (!userId) return null + + try { + const result = await db.select({ metadata: users.metadata }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + const metadata = result[0]?.metadata as { domain?: string } | null; + return metadata?.domain || null; + } catch (error) { + console.error('getUserDomain: Error:', error) + return null + } +} + +export async function saveUserDomain( + userId: string, + domain: string +): Promise<{ success?: boolean; error?: string }> { + if (!userId) return { error: 'User ID is required' } + + try { + const result = await db.select({ metadata: users.metadata }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + const currentMetadata = (result[0]?.metadata || {}) as Record; + await db.update(users) + .set({ + metadata: { + ...currentMetadata, + domain: domain || null + } + }) + .where(eq(users.id, userId)); + + return { success: true } + } catch (error) { + console.error('saveUserDomain: Error:', error) + return { error: 'Failed to save user domain' } + } +} + /** * Retrieves all messages for a specific chat. */ diff --git a/lib/actions/skyfi.ts b/lib/actions/skyfi.ts deleted file mode 100644 index 8bc5cd3c..00000000 --- a/lib/actions/skyfi.ts +++ /dev/null @@ -1,207 +0,0 @@ -'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/actions/system-prompt.ts b/lib/actions/system-prompt.ts index ae933a99..afa0a1c6 100644 --- a/lib/actions/system-prompt.ts +++ b/lib/actions/system-prompt.ts @@ -11,14 +11,32 @@ const domainSchema = z.string().min(1).refine((val) => { try { // Basic validation to check if it's a valid URL or domain const url = val.startsWith('http') ? val : `https://${val}`; - new URL(url); + const parsed = new URL(url); + const hostname = parsed.hostname; + + // Explicitly reject localhost and internal/local hostnames + if (hostname === 'localhost') return false; + if (hostname.endsWith('.local') || hostname.endsWith('.internal')) return false; + + // Check private IP ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, and 127.0.0.1 + const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + const match = hostname.match(ipv4Regex); + if (match) { + const octet1 = parseInt(match[1], 10); + const octet2 = parseInt(match[2], 10); + if (octet1 === 10) return false; + if (octet1 === 172 && octet2 >= 16 && octet2 <= 31) return false; + if (octet1 === 192 && octet2 === 168) return false; + if (octet1 === 127) return false; // Loopback + } + return true; } catch (e) { return false; } -}, { message: "Invalid domain or URL" }); +}, { message: "Invalid domain or URL. Only public domains/URLs are allowed." }); -const STALENESS_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes +const STALENESS_THRESHOLD_MS = 1000 * 60 * 3; // 3 minutes const WORKER_TIMEOUT_MS = 1000 * 60 * 2; // 2 minutes export async function startSystemPromptGeneration(domain: string) { @@ -51,60 +69,128 @@ export async function startSystemPromptGeneration(domain: string) { } } +async function scrapeDomain(domain: string): Promise { + const targetUrl = domain.startsWith('http') ? domain : `https://${domain}`; + const apiKey = process.env.FIRECRAWL_API_KEY; + if (apiKey && apiKey !== 'mock_key' && apiKey !== '') { + try { + const firecrawl = getFirecrawlClient(); + const scrapeResult = await firecrawl.scrapeUrl(targetUrl, { + formats: ['markdown'], + }); + if (!scrapeResult) { + throw new Error('Firecrawl returned null or undefined response.'); + } + if ('success' in scrapeResult && !scrapeResult.success) { + const errorMsg = (scrapeResult as any).error || 'Firecrawl success flag was false.'; + throw new Error(errorMsg); + } + if ('markdown' in scrapeResult && scrapeResult.markdown) { + return scrapeResult.markdown; + } + throw new Error('Firecrawl response did not contain markdown content.'); + } catch (e: any) { + console.warn('Firecrawl scraping failed, falling back to standard fetch scraper:', e); + } + } + + // Fallback scraper using standard fetch + try { + const response = await fetch(targetUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + } + }); + if (!response.ok) { + throw new Error(`Standard fetch request failed with status ${response.status}`); + } + const html = await response.text(); + // Basic extraction of visible text from HTML if it contains tags + let textContent = html; + if (html.includes(')<[^<]*)*<\/script>/gi, '') + .replace(/)<[^<]*)*<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + } + if (textContent.length < 100) { + throw new Error('Fetched page has insufficient content (less than 100 characters)'); + } + return textContent; + } catch (error: any) { + throw new Error(`Standard fetch scraping failed: ${error.message || error}`); + } +} + async function runBackgroundWorker(jobId: string, userId: string, domain: string) { const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Job execution timeout')), WORKER_TIMEOUT_MS) ); + let isSettled = false; + try { await updateJob(jobId, userId, { status: 'processing' }); const workPromise = (async () => { - const firecrawl = getFirecrawlClient(); - const scrapeResult = await firecrawl.scrapeUrl(domain, { - formats: ['markdown'], - }); - - // The library's type definitions for scrapeUrl can be complex. - // We check for markdown presence to determine success. - if (!scrapeResult || !('markdown' in scrapeResult) || !scrapeResult.markdown) { - const errorMsg = (scrapeResult as any)?.error || 'Failed to scrape content'; - throw new Error(errorMsg); + let content = ''; + try { + content = await scrapeDomain(domain); + } catch (err: any) { + throw new Error(`Domain scraping failed: ${err.message || err}`); } - const content = scrapeResult.markdown; if (content.length < 100) { - throw new Error('Insufficient content scraped from domain'); + throw new Error('Insufficient content scraped from domain (less than 100 characters)'); } - const model = await getModel(); - const { text } = await generateText({ - model, - system: 'You are an expert at creating concise and effective AI system prompts for business copilots.', - prompt: `Based on the following content scraped from ${domain}, generate a concise system prompt (10-2000 characters) for an AI business copilot. The prompt should define the business's identity, products/services, tone, and how it should assist users. + let generatedPrompt = ''; + try { + const model = await getModel(); + const { text } = await generateText({ + model, + system: 'You are an expert at creating concise and effective AI system prompts for business copilots.', + prompt: `Based on the following content scraped from ${domain}, generate a concise system prompt (10-2000 characters) for an AI business copilot. The prompt should define the business's identity, products/services, tone, and how it should assist users. + + Content: + ${content.substring(0, 5000)}`, // Limit content size for LLM + }); + generatedPrompt = text.trim(); + } catch (err: any) { + throw new Error(`LLM prompt generation failed: ${err.message || err}`); + } - Content: - ${content.substring(0, 5000)}`, // Limit content size for LLM - }); + if (generatedPrompt.length < 10 || generatedPrompt.length > 2000) { + throw new Error(`Generated prompt length (${generatedPrompt.length}) is outside the allowed constraints (10-2000 characters)`); + } - const finalPrompt = text.trim(); - if (finalPrompt.length < 10 || finalPrompt.length > 2000) { - throw new Error('Generated prompt is outside the allowed length constraints'); + if (isSettled) { + console.warn(`workPromise finished but job ${jobId} was already settled (likely timed out). Discarding update.`); + return; } await updateJob(jobId, userId, { status: 'complete', - resultPrompt: finalPrompt, + resultPrompt: generatedPrompt, }); })(); await Promise.race([workPromise, timeoutPromise]); + isSettled = true; } catch (error: any) { + isSettled = true; console.error(`Background worker error for job ${jobId}:`, error); - await updateJob(jobId, userId, { - status: 'error', - errorMessage: error.message || 'An unexpected error occurred during generation', - }); + try { + await updateJob(jobId, userId, { + status: 'error', + errorMessage: error.message || 'An unexpected error occurred during generation', + }); + } catch (dbError) { + console.error(`Failed to update job status to error for job ${jobId}:`, dbError); + } } } @@ -124,9 +210,18 @@ export async function getSystemPromptGenerationJob(jobId: string) { const lastUpdate = new Date(job.updatedAt).getTime(); const now = new Date().getTime(); if (now - lastUpdate > STALENESS_THRESHOLD_MS) { + const errorMessage = 'Job timed out or was abandoned. Please try again.'; + try { + await updateJob(jobId, userId, { + status: 'error', + errorMessage, + }); + } catch (dbError) { + console.error(`Failed to update stale job status to error for job ${jobId}:`, dbError); + } return { status: 'error', - errorMessage: 'Job timed out or was abandoned. Please try again.', + errorMessage, }; } } diff --git a/lib/agents/report/executive-summary.ts b/lib/agents/report/executive-summary.ts index b2512bc2..9ce79af8 100644 --- a/lib/agents/report/executive-summary.ts +++ b/lib/agents/report/executive-summary.ts @@ -1,22 +1,36 @@ import { generateText } from 'ai' import { getModel } from '@/lib/utils' -export async function executiveSummaryAgent(crossSessionContext: string, activeMessages: any[]) { +export async function executiveSummaryAgent( + crossSessionContext: string, + activeMessages: any[], + systemPrompt?: string | null, + domain?: string | null +) { try { const model = await getModel() + let systemInstruction = `You are a high-level geospatial intelligence analyst. Based on the provided user history and current conversation, generate: + 1. A professional, concise report title (max 60 characters). + 2. A 150-200 word executive summary that synthesizes the intelligence findings, observations, and spatial analysis discussed, taking into account broader system-wide context from previous sessions.`; + + if (systemPrompt) { + systemInstruction += `\n\nWhen writing, align the tone, persona, and focus with the user's custom system prompt/business profile: "${systemPrompt}"`; + } + if (domain) { + systemInstruction += `\n\nThis intelligence report is generated specifically for the business domain: ${domain}. Ensure the observations and summary are tailored to this business context.`; + } + + systemInstruction += `\n\nFormat your response as a JSON object: + { + "title": "The Title Here", + "summary": "The executive summary here..." + } + Do not include any other text or markdown formatting in your response.`; + const { text } = await generateText({ model, - system: `You are a high-level geospatial intelligence analyst. Based on the provided user history and current conversation, generate: - 1. A professional, concise report title (max 60 characters). - 2. A 150-200 word executive summary that synthesizes the intelligence findings, observations, and spatial analysis discussed, taking into account broader system-wide context from previous sessions. - - Format your response as a JSON object: - { - "title": "The Title Here", - "summary": "The executive summary here..." - } - Do not include any other text or markdown formatting in your response.`, + system: systemInstruction, messages: [ { role: 'system', content: `User History Context:\n${crossSessionContext}` }, ...activeMessages diff --git a/lib/agents/report/strategic-synthesis.ts b/lib/agents/report/strategic-synthesis.ts index 6e86411f..bb6eab49 100644 --- a/lib/agents/report/strategic-synthesis.ts +++ b/lib/agents/report/strategic-synthesis.ts @@ -1,25 +1,39 @@ import { generateText } from 'ai' import { getModel } from '@/lib/utils' -export async function strategicSynthesisAgent(sensorFusionFindings: any[], strategicContent: any[]) { +export async function strategicSynthesisAgent( + sensorFusionFindings: any[], + strategicContent: any[], + systemPrompt?: string | null, + domain?: string | null +) { try { const model = await getModel() const findingsContext = sensorFusionFindings.map(f => f.summary || JSON.stringify(f)).join('\n\n') const strategyContext = strategicContent.map(s => s.content).join('\n\n') + let systemInstruction = `You are a strategic intelligence officer. Your task is to synthesize "new knowledge" narrative derived from exploration. + Focus on insights, implications, and decisions. + DO NOT restate sensor-fusion observations or raw data. + Instead, explain what these findings mean for the overall mission and what strategic actions are recommended.`; + + if (systemPrompt) { + systemInstruction += `\n\nEnsure recommendations, insights, and strategic implications align with the user's custom system prompt/business profile: "${systemPrompt}"`; + } + if (domain) { + systemInstruction += `\n\nTailor the strategic assessment specifically for the business domain: ${domain}`; + } + + systemInstruction += `\n\nFormat your response as a JSON object: + { + "strategicOutput": "The synthesized strategic narrative here..." + } + Do not include any other text or markdown formatting in your response.`; + const { text } = await generateText({ model, - system: `You are a strategic intelligence officer. Your task is to synthesize "new knowledge" narrative derived from exploration. - Focus on insights, implications, and decisions. - DO NOT restate sensor-fusion observations or raw data. - Instead, explain what these findings mean for the overall mission and what strategic actions are recommended. - - Format your response as a JSON object: - { - "strategicOutput": "The synthesized strategic narrative here..." - } - Do not include any other text or markdown formatting in your response.`, + system: systemInstruction, messages: [ { role: 'user', content: `Sensor Fusion Findings:\n${findingsContext}\n\nStrategic Content:\n${strategyContext}` } ], diff --git a/lib/agents/researcher.tsx b/lib/agents/researcher.tsx index 3b874b5f..81d00fa8 100644 --- a/lib/agents/researcher.tsx +++ b/lib/agents/researcher.tsx @@ -13,12 +13,11 @@ 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[], selectedModel?: string | null) => raw` +const getDefaultSystemPrompt = (date: string, drawnFeatures?: DrawnFeature[]) => 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:** @@ -34,7 +33,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'. -${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.`} +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. @@ -52,18 +51,11 @@ ${selectedModel === 'SkyFi' ? `3. **SkyFi Priority:** The user has selected **Sk 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. -${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** +#### **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\` @@ -77,19 +69,16 @@ ${selectedModel === 'SkyFi' ? `#### **3. SkyFi Satellite Imagery and AOI** - “How long to walk from Central Park to Times Square?” - “Areas reachable in 30 minutes from downtown Portland” -${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\`:** +**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) -${selectedModel === 'SkyFi' ? `3. SkyFi account, satellite imagery search, geocoding AOI, or ordering? → \`skyfiQueryTool\` (mandatory)` : `3. Location/distance/direction/maps? → \`geospatialQueryTool\` (mandatory)`} +3. Location/distance/direction/maps? → \`geospatialQueryTool\` (mandatory) 4. Everything else needing external data? → \`search\` 5. Otherwise → answer from knowledge @@ -120,12 +109,11 @@ export async function researcher( ) const currentDate = new Date().toLocaleString() - const selectedModel = await getSelectedModel(); const systemPromptToUse = dynamicSystemPrompt?.trim() ? dynamicSystemPrompt - : getDefaultSystemPrompt(currentDate, drawnFeatures, selectedModel) + : getDefaultSystemPrompt(currentDate, drawnFeatures) // Check if any message contains an image const hasImage = messages.some(message => @@ -160,7 +148,7 @@ export async function researcher( maxTokens: 4096, system: systemPromptToUse, messages, - tools: getTools({ uiStream, fullResponse, mapProvider, selectedModel, drawnFeatures }), + tools: getTools({ uiStream, fullResponse, mapProvider }), }) uiStream.update(null) // remove spinner diff --git a/lib/agents/tools/firecrawl.ts b/lib/agents/tools/firecrawl.ts index de1702b3..b8fae814 100644 --- a/lib/agents/tools/firecrawl.ts +++ b/lib/agents/tools/firecrawl.ts @@ -1,9 +1,9 @@ import FirecrawlApp from '@mendable/firecrawl-js'; export function getFirecrawlClient() { - const apiKey = process.env.FIRECRAWL_API_KEY; - if (!apiKey) { - throw new Error('FIRECRAWL_API_KEY is not set'); + const apiKey = process.env.FIRECRAWL_API_KEY?.trim(); + if (!apiKey || apiKey === '') { + throw new Error('FIRECRAWL_API_KEY is not configured or is empty. Please set it in your environment variables.'); } return new FirecrawlApp({ apiKey }); } diff --git a/lib/agents/tools/index.tsx b/lib/agents/tools/index.tsx index 90f2da7c..c0f74360 100644 --- a/lib/agents/tools/index.tsx +++ b/lib/agents/tools/index.tsx @@ -4,8 +4,6 @@ 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' @@ -13,11 +11,9 @@ export interface ToolProps { uiStream: ReturnType fullResponse: string mapProvider?: MapProvider - selectedModel?: string | null - drawnFeatures?: DrawnFeature[] } -export const getTools = ({ uiStream, fullResponse, mapProvider, selectedModel, drawnFeatures }: ToolProps) => { +export const getTools = ({ uiStream, fullResponse, mapProvider }: ToolProps) => { const tools: any = { retrieve: retrieveTool({ uiStream, @@ -33,13 +29,6 @@ export const getTools = ({ uiStream, fullResponse, mapProvider, selectedModel, d }) } - 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 deleted file mode 100644 index c8264b34..00000000 --- a/lib/agents/tools/skyfi.tsx +++ /dev/null @@ -1,363 +0,0 @@ -/** - * 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 858888c4..7900d466 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -130,24 +130,8 @@ 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, ({ one, many }) => ({ +export const usersRelations = relations(users, ({ many }) => ({ chats: many(chats), messages: many(messages), calendarNotes: many(calendarNotes), @@ -157,14 +141,6 @@ export const usersRelations = relations(users, ({ one, 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 deleted file mode 100644 index e76ca333..00000000 --- a/lib/schema/skyfi.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index a728f696..00000000 --- a/lib/skyfi/provider.ts +++ /dev/null @@ -1,238 +0,0 @@ -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 deleted file mode 100644 index 34b3ed8d..00000000 --- a/lib/utils/encryption.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 32db4c43..a2398d68 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':'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 +This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),v(e,async()=>{let t=new ew;this.precacheStrategy.plugins.push(t),await ep(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let r=this._cacheKeysToIntegrities.get(a),s=this._urlsToCacheModes.get(t),i=new Request(t,{integrity:r,cache:s,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:i,url:new URL(i.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:r}=t;return{updatedURLs:a,notUpdatedURLs:r}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return v(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),r=[];for(let s of t)a.has(s.url)||(await e.delete(s),r.push(s.url));return{deletedCacheRequests:r}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,es(e))}setCatchHandler(e){this._catchHandler=es(e)}registerCapture(e,t,a){let r=ey(e,t,a);return this.registerRoute(r),r}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new o("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new o("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new o("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,r=new URL(e.url,location.href);if(!r.protocol.startsWith("http"))return;let s=r.origin===location.origin,{params:i,route:n}=this.findMatchingRoute({event:t,request:e,sameOrigin:s,url:r}),c=n?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:r,request:e,event:t,params:i})}catch(e){a=Promise.reject(e)}let l=n?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:r,request:e,event:t,params:i})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:r,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:r}){for(let s of this._routes.get(a.method)||[]){let i,n=s.match({url:e,sameOrigin:t,request:a,event:r});if(n)return Array.isArray(i=n)&&0===i.length||n.constructor===Object&&0===Object.keys(n).length?i=void 0:"boolean"==typeof n&&(i=void 0),{route:s,params:i}}return{}}}({precacheEntries:[{'revision':null,'url':'/_next/static/chunks/1463-9fbf44fc07aec709.js'},{'revision':null,'url':'/_next/static/chunks/164f4fb6.ce777fe0c2f313d4.js'},{'revision':null,'url':'/_next/static/chunks/1751-a7421f933b645f53.js'},{'revision':null,'url':'/_next/static/chunks/2121.224732b1794a9682.js'},{'revision':null,'url':'/_next/static/chunks/2211-9af661711aa18c6d.js'},{'revision':null,'url':'/_next/static/chunks/245.71c45fa6b1e823c4.js'},{'revision':null,'url':'/_next/static/chunks/2642.376b3c421b31c78e.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/5255-24858493b8af1ae6.js'},{'revision':null,'url':'/_next/static/chunks/6399.9dbce694e39ce768.js'},{'revision':null,'url':'/_next/static/chunks/7034.85f74aed4274c767.js'},{'revision':null,'url':'/_next/static/chunks/7797-93c0d2f54719fac4.js'},{'revision':null,'url':'/_next/static/chunks/8032.7f14d4cd7ad780ed.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-a0cfc2cc93231534.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-6a93eee733cb7f4d.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chats/%5Bid%5D/participants/route-6d26e467a88cfdb3.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chats/all/route-b1e75d303332e462.js'},{'revision':null,'url':'/_next/static/chunks/app/api/chats/route-f33f89c750ea5319.js'},{'revision':null,'url':'/_next/static/chunks/app/api/clerk/webhook/route-b10a26f927783dc4.js'},{'revision':null,'url':'/_next/static/chunks/app/api/embeddings/route-437e73e3ddf89d89.js'},{'revision':null,'url':'/_next/static/chunks/app/api/health/route-d562b6412875a563.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-03110d31e9788763.js'},{'revision':null,'url':'/_next/static/chunks/app/manifest.webmanifest/route-ed45a02dfee77be3.js'},{'revision':null,'url':'/_next/static/chunks/app/offline/page-cc51ffd178cab1d1.js'},{'revision':null,'url':'/_next/static/chunks/app/page-ef28896005743180.js'},{'revision':null,'url':'/_next/static/chunks/app/search/%5Bid%5D/page-4f9cfc13e0ea617b.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-8032958514bcd4bd.js'},{'revision':null,'url':'/_next/static/css/2f46e0db0d4a3aed.css'},{'revision':null,'url':'/_next/static/css/4183e00ad0e88413.css'},{'revision':null,'url':'/_next/static/css/7e0f36defcfd41e8.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':'b0d60226e43cc1d1624d9c886a86535f','url':'/_next/static/pHAivC_y1jh8ecBHEW4MK/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/pHAivC_y1jh8ecBHEW4MK/_ssgManifest.js'},{'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/server.log b/server.log index 1bc7314c..c6d5100d 100644 Binary files a/server.log and b/server.log differ diff --git a/tests-unit/encryption.test.ts b/tests-unit/encryption.test.ts deleted file mode 100644 index d5c1ca14..00000000 --- a/tests-unit/encryption.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -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!"); diff --git a/tests/business-domain.test.ts b/tests/business-domain.test.ts new file mode 100644 index 00000000..25cc054d --- /dev/null +++ b/tests/business-domain.test.ts @@ -0,0 +1,52 @@ +import { expect, test, describe } from "bun:test"; +import { z } from "zod"; + +const domainSchema = z.string().min(1).refine((val) => { + try { + const url = val.startsWith('http') ? val : `https://${val}`; + const parsed = new URL(url); + const hostname = parsed.hostname; + + if (hostname === 'localhost') return false; + if (hostname.endsWith('.local') || hostname.endsWith('.internal')) return false; + + const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + const match = hostname.match(ipv4Regex); + if (match) { + const octet1 = parseInt(match[1], 10); + const octet2 = parseInt(match[2], 10); + if (octet1 === 10) return false; + if (octet1 === 172 && octet2 >= 16 && octet2 <= 31) return false; + if (octet1 === 192 && octet2 === 168) return false; + if (octet1 === 127) return false; + } + + return true; + } catch (e) { + return false; + } +}, { message: "Invalid domain or URL" }); + +describe("Business Domain Validation", () => { + test("allows public domains", () => { + expect(domainSchema.safeParse("google.com").success).toBe(true); + expect(domainSchema.safeParse("https://my-saas.co.uk").success).toBe(true); + }); + + test("rejects localhost", () => { + expect(domainSchema.safeParse("localhost").success).toBe(false); + expect(domainSchema.safeParse("http://localhost:3000").success).toBe(false); + }); + + test("rejects internal and local hostnames", () => { + expect(domainSchema.safeParse("service.local").success).toBe(false); + expect(domainSchema.safeParse("api.internal").success).toBe(false); + }); + + test("rejects private IPv4 ranges", () => { + expect(domainSchema.safeParse("10.0.0.1").success).toBe(false); + expect(domainSchema.safeParse("172.16.4.5").success).toBe(false); + expect(domainSchema.safeParse("192.168.1.100").success).toBe(false); + expect(domainSchema.safeParse("127.0.0.1").success).toBe(false); + }); +}); diff --git a/tests/header.spec.ts b/tests/header.spec.ts index 90b0a549..5111d42d 100644 --- a/tests/header.spec.ts +++ b/tests/header.spec.ts @@ -27,8 +27,8 @@ test.describe('Header and Navigation', () => { test('should open the profile menu', async ({ page }) => { await page.click('[data-testid="profile-toggle"]'); - const settingsMenu = page.locator('[data-testid="profile-settings"]'); - await expect(settingsMenu).toBeVisible(); + const accountMenu = page.locator('[data-testid="profile-account"]'); + await expect(accountMenu).toBeVisible(); }); test('should open the calendar', async ({ page }) => {