Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions components/settings/components/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<typeof settingsFormSchema>
Expand Down Expand Up @@ -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) {
Expand All @@ -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]);
Expand All @@ -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) {
Expand All @@ -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)

Expand Down
58 changes: 56 additions & 2 deletions lib/actions/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ export async function getCrossSessionContext(userId?: string): Promise<string> {

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
Expand All @@ -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 {
Expand Down Expand Up @@ -156,6 +162,54 @@ export async function getChat(id: string, userId: string): Promise<DrizzleChat |
}
}

export async function getUserDomain(
userId: string
): Promise<string | null> {
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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] This server action trusts a caller-supplied userId instead of resolving the authenticated database user. The settings caller passes Clerk user.id, while users.id is the internal UUID, so normal domain saves/loads fail; an authenticated caller can also pass another user's UUID to read or overwrite that user's metadata. Use getCurrentUserIdOnServer() inside both domain actions and ignore the browser-provided ID.

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<string, any>;
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' }
}
}
Comment on lines +165 to +211

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Tracked files around chat.ts:"
git ls-files | grep -E '(^|/)chat\.ts$|auth|get.*currentUser|session' | head -200

echo
echo "Search getUserDomain/saveUserDomain usage and currentUserId helpers:"
rg -n "getUserDomain|saveUserDomain|getCurrentUserIdOnServer|currentUserId|getCurrentUser|auth" -S . --glob '!node_modules' --glob '!dist' --glob '!build' | head -300

echo
echo "Inspect lib/actions/chat.ts relevant section:"
wc -l lib/actions/chat.ts
sed -n '1,260p' lib/actions/chat.ts

Repository: QueueLab/QCX

Length of output: 50368


🌐 Web query:

Nova Framework getCurrentUserIdOnServer getUserDomain saveUserDomain lib/actions/chat.ts

💡 Result:

There is no single "Nova Framework" that natively provides all the functions and file paths referenced in your query as a unified system. Instead, your query combines components from three distinct contexts: 1. Authentication and User IDs: The function getCurrentUserIdOnServer is commonly found in Next.js-based applications (such as the QueueLab/QCX project) to retrieve the authenticated user's ID within Server Actions or API routes [1][2][3][4]. 2. Nova Framework (Erlang/Elixir): The "Nova Framework" is a real web framework for the Erlang ecosystem [5]. It uses a library called nova_auth for authentication [6][7]. It does not use lib/actions/chat.ts (a TypeScript/Next.js pattern) [8][9]. 3. Nova (Code Editor): There is also an app called Nova (a macOS code editor by Panic) [10], which provides its own APIs (e.g., nova.workspace.config) for extensions but is unrelated to web framework backend logic [10]. Regarding the specific items mentioned: - getCurrentUserIdOnServer: This is a custom helper function implemented in specific Next.js projects (like QCX) to bridge authentication sessions with database operations [2][4]. It is not a standard API of any framework called "Nova." - getUserDomain / saveUserDomain: These functions are not standard features of the Erlang Nova framework nor common Next.js boilerplate. If they exist in your codebase, they are likely custom business logic specific to your application's domain management. - lib/actions/chat.ts: This is a standard path structure for Server Actions in Next.js applications [9][11][12]. It typically contains functions for handling chat interactions, such as loading history or persisting messages [9][13]. If you are working in a Next.js project and see these in your lib/actions/chat.ts file, they are application-specific utilities for managing user data or settings, not framework-provided APIs.

Citations:


Authorize domain reads and writes on the server.

getUserDomain and saveUserDomain use the caller-provided userId directly, so any authenticated user can supply another user ID and read or overwrite that account’s domain metadata. Derive the ID from getCurrent UserIdOnServer() inside these actions, and require domain to be a real value before persisting it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/actions/chat.ts` around lines 165 - 211, Update getUserDomain and
saveUserDomain to obtain the authenticated user ID from
getCurrentUserIdOnServer() instead of trusting the userId argument, and use that
server-derived ID for all database queries and updates. In saveUserDomain,
validate that domain is a real non-empty value before persisting it, returning
an appropriate error when it is missing or invalid.


/**
* Retrieves all messages for a specific chat.
*/
Expand Down
87 changes: 69 additions & 18 deletions lib/actions/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,30 @@ 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 WORKER_TIMEOUT_MS = 1000 * 60 * 2; // 2 minutes
Expand Down Expand Up @@ -51,6 +69,54 @@ export async function startSystemPromptGeneration(domain: string) {
}
}

async function scrapeDomain(domain: string): Promise<string> {
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 && 'markdown' in scrapeResult && scrapeResult.markdown) {
return scrapeResult.markdown;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Apply the minimum-content threshold to Firecrawl output.

Any non-empty markdown bypasses the 100-character check, so short error pages can generate a business prompt. Fall through to the fallback or fail when Firecrawl content is too short.

Proposed fix
-      if (scrapeResult && 'markdown' in scrapeResult && scrapeResult.markdown) {
-        return scrapeResult.markdown;
+      const markdown = scrapeResult && 'markdown' in scrapeResult
+        ? scrapeResult.markdown
+        : undefined;
+      if (typeof markdown === 'string' && markdown.trim().length >= 100) {
+        return markdown.trim();
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (scrapeResult && 'markdown' in scrapeResult && scrapeResult.markdown) {
return scrapeResult.markdown;
}
const markdown = scrapeResult && 'markdown' in scrapeResult
? scrapeResult.markdown
: undefined;
if (typeof markdown === 'string' && markdown.trim().length >= 100) {
return markdown.trim();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/actions/system-prompt.ts` around lines 81 - 83, Update the Firecrawl
markdown branch in the system prompt generation flow to require at least 100
characters before returning scrapeResult.markdown. For shorter or empty
markdown, continue through the existing fallback or failure path instead of
returning it.

} catch (e) {
console.warn('Firecrawl scraping failed, falling back to standard fetch scraper:', e);
}
}

// Fallback scraper using standard fetch
try {
const response = await fetch(targetUrl, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] This is an unrestricted server-side fetch after only a literal hostname check. A public name can resolve to RFC1918/loopback/link-local/IPv6 space, and fetch follows redirects by default, so an attacker-controlled domain can make the worker reach internal services and send their response to the model. Resolve and validate every address (including IPv6) at connection time and validate/disable each redirect, or route this through a hardened scraper proxy.

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(`Fetch failed with status ${response.status}`);
}
const html = await response.text();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] The fallback loads the entire response into memory before applying the 5,000-character model cap. A reachable endpoint can return a very large or never-ending body and exhaust worker memory/CPU during one generation request. Reject oversized Content-Length values and stream with a hard byte limit before parsing HTML.

// Basic extraction of visible text from HTML if it contains tags
let textContent = html;
if (html.includes('<html') || html.includes('<body')) {
// Remove scripts, styles, and tags
textContent = html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
if (textContent.length < 100) {
throw new Error('Fallback fetch scraped insufficient content');
}
return textContent;
} catch (error: any) {
throw new Error(`Scraping failed: ${error.message || error}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Cancel and bound scraping when the worker deadline expires.

Promise.race marks the job failed but does not stop fetch or workPromise; the continued worker can later call updateJob(...complete) and overwrite the timeout result. response.text() also buffers an unbounded body. Propagate an AbortSignal, abort at the deadline, cap response bytes, and prevent post-timeout terminal updates.

Also applies to: 129-129

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/actions/system-prompt.ts` around lines 91 - 116, Update the scraping flow
around the targetUrl fetch to accept and propagate an AbortSignal, aborting it
when the worker deadline expires so both fetch and workPromise stop promptly.
Read the response with a bounded byte limit instead of unbounded response.text
buffering, and treat over-limit or aborted reads as failures. Guard post-timeout
terminal updates so the worker cannot call updateJob(...complete) after the
timeout result has been recorded.

}
}

async function runBackgroundWorker(jobId: string, userId: string, domain: string) {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Job execution timeout')), WORKER_TIMEOUT_MS)
Expand All @@ -60,22 +126,7 @@ async function runBackgroundWorker(jobId: string, userId: string, domain: string
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);
}

const content = scrapeResult.markdown;
if (content.length < 100) {
throw new Error('Insufficient content scraped from domain');
}
const content = await scrapeDomain(domain);

const model = await getModel();
const { text } = await generateText({
Expand Down
36 changes: 25 additions & 11 deletions lib/agents/report/executive-summary.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
36 changes: 25 additions & 11 deletions lib/agents/report/strategic-synthesis.ts
Original file line number Diff line number Diff line change
@@ -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}` }
],
Expand Down
2 changes: 1 addition & 1 deletion public/sw.js

Large diffs are not rendered by default.

Loading